Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,13 @@ sealed interface AppRoute : NavKey, Parcelable {
// other open keeps the empty chat, because a DM on its derived id has no record either
// until someone writes in it.
val isGroupLink: Boolean = false,
// Open scrolled to this message (its per-chat sequence number) and flash it: a message
// link's landing.
val jumpToMessageId: Long? = null,
// The title of the chat a message link was followed from, when it was followed from
// inside the app. The conversation offers a chip back to it; a link from outside the
// app has nowhere to go back to and sets none.
val returnToTitle: String? = null,
) : Messaging, FlowRoute {
override val initialStack: List<NavKey>
get() = when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ sealed interface DeeplinkType: Parcelable {
*/
@Serializable data class GroupChatInvite(val chatId: ChatId): DeeplinkType, Navigatable

/**
* One message in a chat — `app.flipcash.com/chat/{chatId}/m/{seq}`.
*
* [messageId] is the message's per-chat sequence number, its canonical identity together with
* [chatId]. The chat id takes the form `/chat/{id}` already accepts: a dashed UUID for a
* group, base64url for a DM. [isGroup] records which form the link used, because the two open
* differently: a group the server has no record of is a dead link, while a DM on its derived id
* has no record until someone writes in it.
*/
@Serializable
data class ChatMessage(
val chatId: ChatId,
val messageId: Long,
val isGroup: Boolean,
) : DeeplinkType, Navigatable

/**
* A tip card addressed by account id — the older `app.flipcash.com/tip/{uuid}` that links
* already shared still carry.
Expand Down Expand Up @@ -74,6 +90,7 @@ sealed interface DeeplinkType: Parcelable {
is Tipcard,
is Profile,
is GroupChatInvite,
is ChatMessage,
-> true

is Login,
Expand Down
2 changes: 2 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,8 @@
<string name="title_requestedJustNow">Requested just now</string>
<string name="title_requestedYesterday">Requested yesterday</string>
<string name="action_review">Review</string>
<!-- A chip under a chat's title bar after a message link was followed there from another chat; %1$s is that chat's title. -->
<string name="action_backToChat">Back to %1$s</string>
<!-- A private group's members (nodes 11210:68, 11210:70). -->
<string name="title_members">Members</string>
<plurals name="header_memberCount">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,14 @@ fun ChatFlowScreen(
// first frame already has what the open can draw from memory (a group's name and picture). The
// steps get this instance from flowSharedViewModel: the flow's store owner is this entry's.
val viewModel = hiltViewModel<ChatViewModel, ChatViewModel.Factory>(
creationCallback = { factory -> factory.create(route.identifier) },
creationCallback = { factory ->
factory.create(
route.identifier,
route.jumpToMessageId,
route.returnToTitle,
linkedAsGroup = route.jumpToMessageId != null && route.isGroupLink,
)
},
)

// A group link the server has no record of: say so and leave, rather than sit on an empty chat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
Expand All @@ -244,6 +245,21 @@ data class TypingConstraints(
internal class ChatViewModel @AssistedInject constructor(
/** The chat this view model opens on creation. Null opens nothing; tests open their own. */
@Assisted private val identifier: ChatIdentifier?,
/**
* The message this visit opens scrolled to — a message link's landing — by its per-chat
* sequence number. Null opens where any other visit does.
*/
@Assisted private val openAtMessageId: Long? = null,
/**
* The title of the chat a message link was followed from inside the app, offered as a chip
* back to it. Null for any other open.
*/
@Assisted private val returnToTitle: String? = null,
/**
* Whether the link that opened this visit named a group (a dashed UUID id) rather than a DM.
* See [State.linkedAsGroup].
*/
@Assisted private val linkedAsGroup: Boolean = false,
private val chatCoordinator: ChatCoordinator,
private val mediaUploads: ChatMediaUploads,
private val e2eePolicy: E2eePolicy,
Expand Down Expand Up @@ -280,7 +296,11 @@ internal class ChatViewModel @AssistedInject constructor(
linkCardMemory: LinkCardMemory? = null,
featureFlags: FeatureFlagController = NoOpFeatureFlagController,
) : BaseViewModel<ChatViewModel.State, ChatViewModel.Event>(
initialState = State(),
initialState = State(
openingAtMessage = openAtMessageId != null,
returnToTitle = returnToTitle,
linkedAsGroup = linkedAsGroup,
),
updateStateForEvent = updateStateForEvent,
defaultDispatcher = dispatchers.Default,
) {
Expand Down Expand Up @@ -417,6 +437,24 @@ internal class ChatViewModel @AssistedInject constructor(
* it. Set alongside [jumpTarget] and cleared with it.
*/
val jumpBudget: Int? = null,
/**
* True while a visit opened at a message has not yet landed on it, or found it cannot. The
* list holds its unread-divider positioning until this clears, so the two don't both walk
* the transcript on open; a message that can't be reached falls back to that positioning.
*/
val openingAtMessage: Boolean = false,
/**
* The chat this visit can go back to, shown as a chip under the bar: set when a message
* link was followed here from another chat. It stays for the life of the screen: the chip
* goes only by being tapped or by leaving the chat.
*/
val returnToTitle: String? = null,
/**
* Whether a message link opened this visit with a group's id. The open can mark the chat a
* tip DM before the group resolves, and a landing that believed it would page a withheld
* group's history, so [messageLinkLanding] waits on the group for such a link.
*/
val linkedAsGroup: Boolean = false,
/**
* Where the "N Unread Messages" divider sits, read once per visit from the viewer's stored
* READ pointer. Never re-read: the list advances that pointer as the reader scrolls, and a
Expand Down Expand Up @@ -624,6 +662,36 @@ internal class ChatViewModel @AssistedInject constructor(
val obscuresTranscript: Boolean
get() = isOutsideGroup && !readsFromOutside

/**
* What a message link's landing may do here, or null while that is not known yet.
*
* A link carries a chat id before the open has said what the chat is or where the viewer
* stands in it, and paging toward the message in that window would fetch a withheld
* group's transcript, the one [obscuresTranscript] is there to keep off the device. So a
* group waits on its membership and, for a non-member, on [groupAccess] settling, and
* reads as [MessageLinkLanding.Readable] only where the transcript would be fetched anyway.
*/
val messageLinkLanding: MessageLinkLanding?
get() = when {
chatNotFound -> MessageLinkLanding.Withheld
// A null membership is a group hydrated by id, which the device holds no row for;
// like the gate, read it as outside (see ChatMembership.isMember).
subject is ChatSubject.Group -> when (subject.isMember) {
true -> MessageLinkLanding.Readable
null, false -> when (groupAccess) {
null, GroupAccess.Undetermined -> null
else -> if (readsFromOutside) MessageLinkLanding.Readable
else MessageLinkLanding.Withheld
}
}
// The open can call a group a tip DM before the group resolves, so a link that
// named a group never reads as one.
linkedAsGroup -> null
chatType == ChatType.TIP_DM || chatType == ChatType.CONTACT_DM ->
MessageLinkLanding.Readable
else -> null
}

/**
* The host's Join Requests anchor under the title bar (node 11354:70): only while someone is
* waiting, and not while a message action has taken the bar over.
Expand Down Expand Up @@ -1029,6 +1097,9 @@ internal class ChatViewModel @AssistedInject constructor(
/** The server confirmed a message the viewer sent; the read pointer has moved past everything. */
data object OwnMessageSent : Event
data object JumpConsumed : Event

/** A jump whose message is neither stored nor on the server: nothing to scroll to. */
data object JumpUnreachable : Event
}

/**
Expand Down Expand Up @@ -1181,6 +1252,31 @@ internal class ChatViewModel @AssistedInject constructor(
* Null until the transcript has resolved them, which it has by the time their picture is
* drawn — the picture is what this is reached from.
*/
/**
* Follows a tapped message link inside the app. True when [uri] was one: a message in this
* chat is jumped to in place, and one in another chat is pushed over this one with a chip back
* here. False leaves [uri] to the ordinary link handling.
*/
fun followMessageLink(uri: String): Boolean {
val link = linkCardClassifier.messageLink(uri) ?: return false
val state = stateFlow.value
if (link.chatId == state.chatId) {
dispatchEvent(Event.JumpToMessage(link.messageId))
} else {
dispatchEvent(
Event.OpenScreen(
AppRoute.Messaging.Chat(
identifier = ChatIdentifier.ByChatId(link.chatId),
isGroupLink = link.isGroup,
jumpToMessageId = link.messageId,
returnToTitle = state.subject?.title,
)
)
)
}
return true
}

fun memberParticipant(userId: ID): ChatParticipant.TipUser? =
senderProfiles.value?.get(userId.hexEncodedString())
?.let { ChatParticipant.TipUser(userId, it) }
Expand Down Expand Up @@ -2926,17 +3022,39 @@ internal class ChatViewModel @AssistedInject constructor(
}
.launchIn(viewModelScope)

// A distance the device cannot measure is a message it never stored, and no walk would
// reach it. Resolving here rather than in the list keeps the read off the composition and
// gives the walk a bound before it starts.
// Resolving here rather than in the list keeps the read off the composition and gives the
// walk a bound before it starts. See resolveJump for a message the device never stored.
eventFlow.filterIsInstance<Event.JumpToMessage>()
.onEach { event ->
val chatId = stateFlow.value.chatId ?: return@onEach
val distance = chatCoordinator.distanceFromNewest(chatId, event.messageId)
?: return@onEach
dispatchEvent(Event.JumpResolved(event.messageId, distance))
}
.launchIn(viewModelScope)
resolveJump(chatId, event.messageId)
}
.launchIn(viewModelScope)

// A message link's landing. Started here rather than dispatched as JumpToMessage: eventFlow
// is replay-0 and the chat id is not known yet, so the request waits on the open instead.
// Held until the gate settles (see State.messageLinkLanding). A withheld landing gives up
// the open-at-message hold, but keeps listening: a viewer who buys in from the gate can
// still be walked to the message.
if (openAtMessageId != null) {
stateFlow
.mapNotNull { state ->
val chatId = state.chatId ?: return@mapNotNull null
state.messageLinkLanding?.let { chatId to it }
}
.distinctUntilChanged()
.transformWhile { landing ->
emit(landing)
landing.second != MessageLinkLanding.Readable
}
.onEach { (chatId, landing) ->
when (landing) {
MessageLinkLanding.Readable -> resolveJump(chatId, openAtMessageId)
MessageLinkLanding.Withheld -> dispatchEvent(Event.JumpUnreachable)
}
}
.launchIn(viewModelScope)
}

// Read once per chat id. The list holds read reporting until this lands, so the pointer
// it reads is the one the viewer arrived with.
Expand Down Expand Up @@ -3445,9 +3563,33 @@ internal class ChatViewModel @AssistedInject constructor(
}
}

/**
* Resolves the walk's bound for a jump to [messageId] and hands it to the list. A message the
* device never stored is paged in from the server first, back from the oldest stored message so
* the transcript stays contiguous; a link to old history is the usual case.
*/
private suspend fun resolveJump(chatId: ChatId, messageId: Long) {
val distance = chatCoordinator.distanceFromNewest(chatId, messageId)
?: if (chatCoordinator.loadHistoryThrough(chatId, messageId)) {
chatCoordinator.distanceFromNewest(chatId, messageId)
} else {
null
}
if (distance == null) {
dispatchEvent(Event.JumpUnreachable)
} else {
dispatchEvent(Event.JumpResolved(messageId, distance))
}
}

@AssistedFactory
interface Factory {
fun create(identifier: ChatIdentifier?): ChatViewModel
fun create(
identifier: ChatIdentifier?,
openAtMessageId: Long?,
returnToTitle: String?,
linkedAsGroup: Boolean,
): ChatViewModel
}

companion object {
Expand Down Expand Up @@ -3780,7 +3922,10 @@ internal class ChatViewModel @AssistedInject constructor(
is Event.JumpResolved -> { state ->
state.copy(jumpTarget = event.messageId, jumpBudget = event.budget)
}
Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) }
Event.JumpConsumed -> { state ->
state.copy(jumpTarget = null, jumpBudget = null, openingAtMessage = false)
}
Event.JumpUnreachable -> { state -> state.copy(openingAtMessage = false) }
is Event.UnreadBoundaryResolved -> { state ->
state.copy(
unreadBoundary = event.boundary,
Expand Down Expand Up @@ -3826,3 +3971,12 @@ private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State {
}
return copy(quickReactionStrip = strip)
}

/** See [ChatViewModel.State.messageLinkLanding]. */
internal enum class MessageLinkLanding {
/** The transcript is the viewer's to read, so the landing pages to the message and walks there. */
Readable,

/** The transcript is withheld from the viewer, or the chat is gone: nothing is fetched for it. */
Withheld,
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ internal class LinkCardClassifier @Inject constructor(
fragment == null &&
pathSegments.first().lowercase() !in SECRET_SEGMENTS

/**
* The message a tapped link points at, when it is a message link on our hosts (wrapped in a
* jump link or not). The transcript follows these itself rather than through the system, so the
* hop keeps the chat it was tapped in to come back to.
*/
fun messageLink(url: String): DeeplinkType.ChatMessage? {
val target = unwrapJumpTarget(url) ?: url
val host = runCatching { target.toUri().host }.getOrNull() ?: return null
if (host.lowercase() !in CARD_HOSTS) return null
return router.classify(DeepLink(target)) as? DeeplinkType.ChatMessage
}

private fun classify(link: DetectedUrl): LinkCard? {
val target = unwrapJumpTarget(link.url) ?: link.url
val host = runCatching { target.toUri().host }.getOrNull() ?: return null
Expand Down
Loading
Loading