Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import com.flipcash.shared.chat.models.ReceiptStatus
import com.flipcash.shared.chat.models.splitAroundLinkCard
import com.flipcash.shared.chat.models.SenderIdentity
import com.flipcash.shared.chat.models.SeparatorConfig
import com.flipcash.shared.chat.readOnly
import com.flipcash.shared.chat.resolveCapabilities
import com.flipcash.shared.chat.ui.detectUrls
import com.flipcash.shared.chat.ui.linkableText
Expand Down Expand Up @@ -308,29 +309,66 @@ internal class ChatViewModel @Inject constructor(
get() = selection?.capabilities.orEmpty()

/**
* Whether this conversation is being read from outside the group.
* Whether this conversation is being read from outside the group: a group the viewer has
* not joined, or one whose membership is not known yet.
*/
val isOutsideGroup: Boolean
get() = subject is ChatSubject.Group && subject.isMember != true

/**
* Whether the server will hand this viewer the group's transcript in full without their
* joining it.
*
* Drives the blur over the transcript, the placeholder standing in for it, and the gate that
* stands where the composer does.
* The contract's line (`messaging.v1.ViewMode`): a non-member who satisfies a group's
* listener rules may read it in full, and a non-member of a group with no listener rules may
* not read it at all. So [GroupAccess.Eligible] alone is not enough — it is also the answer
* for a group with no rules, where there is nothing to read.
*
* Read off the subject rather than off [groupAccess], even though `Membered` is the same
* fact, because this one has to be right on the frame the group first renders. [groupAccess]
* arrives through the balance and staff flows, so it is null for as long as those take — and
* a withheld transcript that starts unblurred and then blurs has already shown what it was
* withholding. The subject is what the info card and the title bar draw from, so tying the
* blur to it means the three cannot disagree by a frame.
* Every unknown answers no. A null [groupAccess] is the balance or the staff flag not having
* arrived, and a null membership is a group hydrated by id that GetChat could not place the
* viewer in; reading either as eligible would show a transcript the viewer may not be owed.
*/
val readsFromOutside: Boolean
get() = subject is ChatSubject.Group &&
subject.isMember == false &&
groupAccess == GroupAccess.Eligible &&
subject.rules?.listener.orEmpty().isNotEmpty()

/**
* Whether the transcript is blurred, and the placeholder stands in for it when empty.
*
* [groupAccess] still decides what the gate's button does, which is the part that genuinely
* depends on the balance.
* Read off the subject first, not off [groupAccess] alone, because this one has to be right
* on the frame the group first renders. [groupAccess] arrives through the balance and staff
* flows, so it is null for as long as those take — and a withheld transcript that starts
* unblurred and then blurs has already shown what it was withholding. So the blur goes on
* with the subject and only comes off once the access says [GroupAccess.Eligible]: an
* eligible viewer watches it fade, which is the direction that leaks nothing.
*
* A succeeded join keeps it true through the button's checkmark: one flag for all three
* renderers means the composer cannot arrive while the transcript is still blurred, and the
* roster — which can confirm membership on the next frame — cannot cut the confirmation
* short.
* [GroupAccess.Blocked] keeps it on, and a balance that drops while the chat is open turns
* [GroupAccess.Eligible] into [GroupAccess.Blocked] and puts it back.
*
* Not held by a succeeded join. The only viewer who can join is an eligible one, who is
* already reading the transcript sharp, and a group with no listener rules unblurring as the
* roster confirms the join is the same sharp-transcript-over-gate state an eligible viewer
* sits in.
*
* Mirrors iOS `ConversationGatePresentation.obscuresTranscript`.
*/
val isGatedPreview: Boolean
get() = joinProgress.success ||
(subject is ChatSubject.Group && subject.isMember != true)
val obscuresTranscript: Boolean
get() = isOutsideGroup && !readsFromOutside

/**
* Whether the gate stands where the composer does. Every viewer outside the group, eligible
* or not: reading a group from outside is not posting in it.
*
* A succeeded join keeps it true through the button's checkmark, so the roster — which can
* confirm membership on the next frame — cannot cut the confirmation short by swapping the
* composer in under it.
*
* Mirrors iOS `ConversationGatePresentation.replacesComposer`.
*/
val replacesComposer: Boolean
get() = joinProgress.success || isOutsideGroup

/**
* The link that invites someone into this group, or `null` when there is nobody to invite:
Expand Down Expand Up @@ -529,6 +567,13 @@ internal class ChatViewModel @Inject constructor(
*/
private val messagePolicy = stateFlow.map { it.messagePolicy }.distinctUntilChanged()

/**
* Re-runs the transcript mapping when the viewer joins or leaves, so a message resolved while
* they were reading from outside picks up Reply the moment the join lands, rather than on the
* next page.
*/
private val viewerCanPost = stateFlow.map { !it.isOutsideGroup }.distinctUntilChanged()

/**
* Null for a DM, a map for a group — the distinction the transcript needs, because a DM's
* counterparty is already named in the title bar and attributing each of their bubbles would be
Expand Down Expand Up @@ -588,7 +633,8 @@ internal class ChatViewModel @Inject constructor(
pendingMutations,
messagePolicy,
senderProfiles,
) { pagingData, mutations, policy, profiles ->
viewerCanPost,
) { pagingData, mutations, policy, profiles, canPost ->
pagingData.flatMap { stored ->
val message = stored.applying(mutations[stored.messageId])
message.content.flatMapIndexed { index, content ->
Expand Down Expand Up @@ -672,7 +718,7 @@ internal class ChatViewModel @Inject constructor(
// Resolved once, here, so no menu re-derives it: a later group-role
// taxonomy becomes another input to the resolver rather than a branch at
// each action site.
capabilities = resolveCapabilities(message, policy),
capabilities = resolveCapabilities(message, policy, canPost = canPost),
quote = quote,
sender = sender,
// Independent of the profile lookup above: the runs have to break by
Expand Down Expand Up @@ -999,7 +1045,9 @@ internal class ChatViewModel @Inject constructor(
* as a placeholder rather than fetched, so asking for its messages would pull the very ones the
* blur is over — and an unknown membership is withheld here for the same reason it is on screen.
* A member whose feed has not synced yet is not stranded: the group feed sync sends its own load
* for a group whose cursor is still at zero.
* for a group whose cursor is still at zero. An eligible non-member's transcript is fetched once
* the gate says so, off [State.readsFromOutside], rather than here, where the balance that
* decides it has not arrived yet.
*/
private suspend fun openTranscript(chatId: ChatId) {
val hydrated = chatCoordinator.hydrateChat(chatId) ?: run {
Expand Down Expand Up @@ -1245,11 +1293,25 @@ internal class ChatViewModel @Inject constructor(
.onEach { dispatchEvent(Event.ChatDeactivated(isReadOnly = it)) }
.launchIn(viewModelScope)

// An eligible non-member reads the group in full, but nothing else fetches its transcript:
// openTranscript leaves a group the viewer is outside of unloaded, and the group feed only
// carries groups the viewer is in. Keyed on the chat so a balance that dips and recovers
// refetches rather than trusting a page that may have moved on.
stateFlow.map { state -> state.chatId.takeIf { state.readsFromOutside } }
.distinctUntilChanged()
.filterNotNull()
.onEach { chatId -> chatCoordinator.loadMessages(chatId) }
.launchIn(viewModelScope)

// Advance read pointer when user scrolls to messages
eventFlow
.filterIsInstance<Event.AdvanceReadPointer>()
.onEach { event ->
val chatId = stateFlow.value.chatId ?: return@onEach
val state = stateFlow.value
val chatId = state.chatId ?: return@onEach
// A read pointer is a member's: an eligible non-member can see the messages now,
// but the server refuses to move a pointer for someone outside the chat.
if (state.isOutsideGroup) return@onEach
viewModelScope.launch { chatCoordinator.advanceReadPointer(chatId, event.messageId) }
}
.launchIn(viewModelScope)
Expand Down Expand Up @@ -2068,7 +2130,15 @@ internal class ChatViewModel @Inject constructor(
}
is Event.OnGroupResolved -> { state ->
val metadata = event.membership.metadata
val previous = state.subject as? ChatSubject.Group
// The access was decided for the old membership and rules. Kept across a change
// to either, a stale Eligible would unblur a group whose rules just tightened
// until the balance flow caught up, so it goes back to unknown instead.
val accessStale = previous == null ||
previous.isMember != event.membership.isMember ||
previous.rules != metadata.rules
state.copy(
groupAccess = if (accessStale) null else state.groupAccess,
subject = ChatSubject.Group(
chatId = metadata.chatId,
groupTitle = metadata.title,
Expand Down Expand Up @@ -2175,10 +2245,13 @@ internal class ChatViewModel @Inject constructor(
// The transcript resolved this bubble when it was mapped, which may have been
// well inside a window that has since closed. Narrow it again here so the bar
// offers what is open now rather than what was open when the row was built.
// The same goes for membership: a viewer who has left since keeps only what a
// reader outside the group may do.
val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble ->
val open = bubble.capabilities
.withinWindows(bubble.timestamp, state.messagePolicy)
bubble.copy(
capabilities = bubble.capabilities
.withinWindows(bubble.timestamp, state.messagePolicy),
capabilities = if (state.isOutsideGroup) open.readOnly() else open,
)
}
state.copy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
// open. The title bar stays sharp — it is how a non-member knows what they are looking at.
BlurredContent(
modifier = Modifier.fillMaxSize(),
enabled = state.isGatedPreview,
enabled = state.obscuresTranscript,
) {
MessageList(
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,10 @@ internal fun UserControlBottomBar(
}

// The same shape as the block above it, and for the same reason: there is no composer to
// configure when the viewer cannot post. The decision is `isGatedPreview`, the same flag the
// transcript's blur reads, so a composer can never appear over a blurred chat — including for
// the frames before the gate has decided anything, and for as long as the gate holds its join
// confirmation.
if (state.isGatedPreview) {
// configure when the viewer cannot post. `replacesComposer` holds for every viewer outside the
// group — including an eligible one reading the transcript sharp, and the frames before the gate
// has decided anything — and for as long as the gate holds its join confirmation.
if (state.replacesComposer) {
GroupGateBar(
access = state.groupAccess,
// The chat's own rule, read the same way the info card at the head of the transcript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ import com.getcode.ui.components.chat.MessageNodeDefaults
/**
* What sits behind the blur of a group the viewer has not joined — node 10127:117171.
*
* Shapes, not messages. No RPC serves a transcript to a non-member: `GetChat` gives a group's
* title, picture, roster summary and rules, and nothing else, so there is no conversation to fetch
* and blur. What the design shows through the blur is therefore drawn rather than loaded — bars in
* the proportions of an incoming run, so the gate reads as a conversation withheld rather than as a
* screen that failed to load.
* Shapes, not messages. A withheld transcript is never fetched: a viewer short of the group's rules
* could be served it redacted at best, and one whose standing is not known yet may not be owed it at
* all, so there is no conversation to blur. (An eligible non-member is the exception, and reads the
* real transcript sharp — see `ChatViewModel.State.readsFromOutside`.) What the design shows through
* the blur is therefore drawn rather than loaded — bars in the proportions of an incoming run, so the
* gate reads as a conversation withheld rather than as a screen that failed to load.
*
* Deliberately unlocalised and free of any text: the closer this got to real message content, the
* more a screenshot with the blur stripped would look like one. Every value here is fixed, so it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ internal fun MessageList(
when {
// Node 10127:117171. The blur over this list is what makes it a preview;
// see GatedTranscriptPlaceholder for why it is drawn rather than fetched.
state.isGatedPreview -> item(key = "gated-transcript-placeholder") {
state.obscuresTranscript -> item(key = "gated-transcript-placeholder") {
GatedTranscriptPlaceholder(modifier = Modifier.fillParentMaxWidth())
}

Expand Down
Loading
Loading