Skip to content

Commit dfc1dab

Browse files
committed
fix(messenger): hold a message link's landing until the group gate settles
The landing paged history as soon as the chat id was known, before the open had resolved membership or access. A link into a group the viewer cannot read fetched and stored the transcript the blur is there to withhold. The landing now waits on State.messageLinkLanding: a member, an eligible non-member of a group with listener rules, or a DM pages to the message; a withheld group or a missing chat fetches nothing and drops the open-at-message hold. A viewer who buys in from the gate is still walked to the message.
1 parent 00599fb commit dfc1dab

2 files changed

Lines changed: 116 additions & 4 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ import kotlinx.coroutines.flow.onStart
220220
import kotlinx.coroutines.flow.stateIn
221221
import kotlinx.coroutines.flow.take
222222
import kotlinx.coroutines.flow.transformLatest
223+
import kotlinx.coroutines.flow.transformWhile
223224
import kotlinx.coroutines.flow.MutableStateFlow
224225
import kotlinx.coroutines.flow.asStateFlow
225226
import kotlinx.coroutines.flow.update
@@ -628,6 +629,32 @@ internal class ChatViewModel @AssistedInject constructor(
628629
val obscuresTranscript: Boolean
629630
get() = isOutsideGroup && !readsFromOutside
630631

632+
/**
633+
* What a message link's landing may do here, or null while that is not known yet.
634+
*
635+
* A link carries a chat id before the open has said what the chat is or where the viewer
636+
* stands in it, and paging toward the message in that window would fetch a withheld
637+
* group's transcript, the one [obscuresTranscript] is there to keep off the device. So a
638+
* group waits on its membership and, for a non-member, on [groupAccess] settling, and
639+
* reads as [MessageLinkLanding.Readable] only where the transcript would be fetched anyway.
640+
*/
641+
val messageLinkLanding: MessageLinkLanding?
642+
get() = when {
643+
chatNotFound -> MessageLinkLanding.Withheld
644+
subject is ChatSubject.Group -> when (subject.isMember) {
645+
true -> MessageLinkLanding.Readable
646+
null -> null
647+
false -> when (groupAccess) {
648+
null, GroupAccess.Undetermined -> null
649+
else -> if (readsFromOutside) MessageLinkLanding.Readable
650+
else MessageLinkLanding.Withheld
651+
}
652+
}
653+
chatType == ChatType.TIP_DM || chatType == ChatType.CONTACT_DM ->
654+
MessageLinkLanding.Readable
655+
else -> null
656+
}
657+
631658
/**
632659
* The host's Join Requests anchor under the title bar (node 11354:70): only while someone is
633660
* waiting, and not while a message action has taken the bar over.
@@ -2903,11 +2930,27 @@ internal class ChatViewModel @AssistedInject constructor(
29032930

29042931
// A message link's landing. Started here rather than dispatched as JumpToMessage: eventFlow
29052932
// is replay-0 and the chat id is not known yet, so the request waits on the open instead.
2933+
// Held until the gate settles (see State.messageLinkLanding). A withheld landing gives up
2934+
// the open-at-message hold, but keeps listening: a viewer who buys in from the gate can
2935+
// still be walked to the message.
29062936
if (openAtMessageId != null) {
2907-
viewModelScope.launch {
2908-
val chatId = stateFlow.mapNotNull { it.chatId }.first()
2909-
resolveJump(chatId, openAtMessageId)
2910-
}
2937+
stateFlow
2938+
.mapNotNull { state ->
2939+
val chatId = state.chatId ?: return@mapNotNull null
2940+
state.messageLinkLanding?.let { chatId to it }
2941+
}
2942+
.distinctUntilChanged()
2943+
.transformWhile { landing ->
2944+
emit(landing)
2945+
landing.second != MessageLinkLanding.Readable
2946+
}
2947+
.onEach { (chatId, landing) ->
2948+
when (landing) {
2949+
MessageLinkLanding.Readable -> resolveJump(chatId, openAtMessageId)
2950+
MessageLinkLanding.Withheld -> dispatchEvent(Event.JumpUnreachable)
2951+
}
2952+
}
2953+
.launchIn(viewModelScope)
29112954
}
29122955

29132956
// Read once per chat id. The list holds read reporting until this lands, so the pointer
@@ -3811,3 +3854,12 @@ private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State {
38113854
}
38123855
return copy(quickReactionStrip = strip)
38133856
}
3857+
3858+
/** See [ChatViewModel.State.messageLinkLanding]. */
3859+
internal enum class MessageLinkLanding {
3860+
/** The transcript is the viewer's to read, so the landing pages to the message and walks there. */
3861+
Readable,
3862+
3863+
/** The transcript is withheld from the viewer, or the chat is gone: nothing is fetched for it. */
3864+
Withheld,
3865+
}

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatViewModelStateTest.kt‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,4 +658,64 @@ class ChatViewModelStateTest {
658658
assertTrue(state.canSpeak)
659659
assertFalse(state.speakerBlocksReactions)
660660
}
661+
662+
@Test
663+
fun `a message link waits while the viewer's standing in a group is unknown`() {
664+
// A chat id alone, before the open says what it is: paging now could fetch a withheld group.
665+
assertNull(ChatViewModel.State(chatId = groupChatId).messageLinkLanding)
666+
assertNull(ChatViewModel.State(subject = group(isMember = null)).messageLinkLanding)
667+
assertNull(ChatViewModel.State(subject = group(isMember = false)).messageLinkLanding)
668+
assertNull(
669+
ChatViewModel.State(
670+
subject = group(isMember = false),
671+
groupAccess = GroupAccess.Undetermined,
672+
).messageLinkLanding
673+
)
674+
}
675+
676+
@Test
677+
fun `a message link lands where the transcript is the viewer's to read`() {
678+
assertEquals(
679+
MessageLinkLanding.Readable,
680+
ChatViewModel.State(subject = group(isMember = true)).messageLinkLanding,
681+
)
682+
assertEquals(
683+
MessageLinkLanding.Readable,
684+
ChatViewModel.State(
685+
subject = group(isMember = false),
686+
groupAccess = GroupAccess.Eligible,
687+
).messageLinkLanding,
688+
)
689+
assertEquals(
690+
MessageLinkLanding.Readable,
691+
ChatViewModel.State(chatType = ChatType.TIP_DM).messageLinkLanding,
692+
)
693+
assertEquals(
694+
MessageLinkLanding.Readable,
695+
ChatViewModel.State(subject = dm, chatType = ChatType.CONTACT_DM).messageLinkLanding,
696+
)
697+
}
698+
699+
@Test
700+
fun `a message link into a withheld group fetches nothing`() {
701+
assertEquals(
702+
MessageLinkLanding.Withheld,
703+
ChatViewModel.State(
704+
subject = group(isMember = false),
705+
groupAccess = GroupAccess.Blocked(unmet),
706+
).messageLinkLanding,
707+
)
708+
// No listener rules: eligible to join, but nothing to read from outside.
709+
assertEquals(
710+
MessageLinkLanding.Withheld,
711+
ChatViewModel.State(
712+
subject = group(isMember = false, rules = ChatRules(emptyList(), emptyList())),
713+
groupAccess = GroupAccess.Eligible,
714+
).messageLinkLanding,
715+
)
716+
assertEquals(
717+
MessageLinkLanding.Withheld,
718+
ChatViewModel.State(chatNotFound = true).messageLinkLanding,
719+
)
720+
}
661721
}

0 commit comments

Comments
 (0)