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 @@ -455,6 +455,12 @@ internal class ChatViewModel @AssistedInject constructor(
* group's history, so [messageLinkLanding] waits on the group for such a link.
*/
val linkedAsGroup: Boolean = false,
/**
* The linked message, drawn alone over the blurred transcript when a message link lands in
* a group that withholds its transcript from the viewer (flow 6). See [gatedLandingBubble].
* Null for any other visit, and cleared once the viewer can read the transcript itself.
*/
val gatedLanding: ChatListItem.ContentBubble? = null,
/**
* 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 @@ -1100,6 +1106,9 @@ internal class ChatViewModel @AssistedInject constructor(

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

/** The row a withheld landing draws over the blur changed. See [State.gatedLanding]. */
data class GatedLandingChanged(val item: ChatListItem.ContentBubble?) : Event
}

/**
Expand Down Expand Up @@ -1228,6 +1237,9 @@ internal class ChatViewModel @AssistedInject constructor(
* anyone it omits (a sender who left, a member past the cap) is asked for one profile at a time.
* See SenderResolver.
*/
/** The message a withheld landing peeked at; see [State.gatedLanding]. */
private val gatedLandingMessage = MutableStateFlow<ChatMessage?>(null)

@OptIn(ExperimentalCoroutinesApi::class)
private val senderProfiles: StateFlow<Map<String, UserProfile>?> =
stateFlow.map { it.chatType == ChatType.GROUP }
Expand Down Expand Up @@ -3049,11 +3061,27 @@ internal class ChatViewModel @AssistedInject constructor(
}
.onEach { (chatId, landing) ->
when (landing) {
MessageLinkLanding.Readable -> resolveJump(chatId, openAtMessageId)
MessageLinkLanding.Withheld -> dispatchEvent(Event.JumpUnreachable)
MessageLinkLanding.Readable -> {
gatedLandingMessage.value = null
resolveJump(chatId, openAtMessageId)
}
MessageLinkLanding.Withheld -> {
dispatchEvent(Event.JumpUnreachable)
// A group the viewer is outside of still shows the linked message
// itself, redacted as the server sees fit; nothing else is fetched.
val state = stateFlow.value
if (state.obscuresTranscript && !state.chatNotFound) {
chatCoordinator.peekMessage(chatId, openAtMessageId)
.onSuccess { gatedLandingMessage.value = it }
}
}
}
}
.launchIn(viewModelScope)

gatedLandingItems()
.onEach { dispatchEvent(Event.GatedLandingChanged(it)) }
.launchIn(viewModelScope)
}

// Read once per chat id. The list holds read reporting until this lands, so the pointer
Expand Down Expand Up @@ -3563,6 +3591,25 @@ internal class ChatViewModel @AssistedInject constructor(
}
}

/** [gatedLandingMessage] as the row it draws, named once its sender's profile resolves. */
@OptIn(ExperimentalCoroutinesApi::class)
private fun gatedLandingItems(): Flow<ChatListItem.ContentBubble?> =
gatedLandingMessage
.flatMapLatest { message ->
if (message == null) return@flatMapLatest flowOf(null)
val senderId = message.senderId?.takeIf { !message.isFromSelf }
senderId?.let { chatCoordinator.requestSenderProfile(it) }
chatCoordinator.observeSenderProfiles()
.onStart { chatCoordinator.currentSenderProfiles()?.let { emit(it) } }
.map { profiles -> senderId?.let { profiles[it.hexEncodedString()] } }
.onStart { emit(null) }
.distinctUntilChanged()
.map { profile ->
gatedLandingBubble(message, profile, resources.getString(R.string.title_unnamedUser))
}
}
.distinctUntilChanged()

/**
* 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
Expand Down Expand Up @@ -3926,6 +3973,7 @@ internal class ChatViewModel @AssistedInject constructor(
state.copy(jumpTarget = null, jumpBudget = null, openingAtMessage = false)
}
Event.JumpUnreachable -> { state -> state.copy(openingAtMessage = false) }
is Event.GatedLandingChanged -> { state -> state.copy(gatedLanding = event.item) }
is Event.UnreadBoundaryResolved -> { state ->
state.copy(
unreadBoundary = event.boundary,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.flipcash.app.messenger.internal

import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.ChatMessage
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.services.models.handle
import com.flipcash.services.models.nameOrHandle
import com.flipcash.shared.chat.MessageReactions
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.SenderIdentity

/**
* The one row a message link draws when it lands in a group whose transcript is withheld from the
* viewer (flow 6): [message] as `GetMessage` in `FULL_OR_REDACTED` answered it, over the blurred
* placeholder transcript.
*
* Display only. It carries no capabilities and cannot be reacted to, so nothing selects, replies
* to, copies or reacts to it; [ChatListItem.ContentBubble.redacted] keeps a withheld body blurred.
* The sender, time and reactions are the server's whole answer even to a redacted reader, so they
* draw as they are.
*
* Null for a message with nothing to draw: deleted, or no content at all.
*
* @param senderProfile the sender's profile once it has resolved, or null to draw the row without
* a name until it does.
* @param unnamed what the name line says for a sender with neither a name nor a handle.
*/
internal fun gatedLandingBubble(
message: ChatMessage,
senderProfile: UserProfile?,
unnamed: String,
): ChatListItem.ContentBubble? {
val content = message.content.firstOrNull() ?: return null
if (content is MessageContent.Deleted) return null
val senderId = message.senderId?.takeIf { !message.isFromSelf }
val sender = senderId?.let { id ->
senderProfile?.let { profile ->
SenderIdentity(
userId = id,
displayName = nameOrHandle(profile.displayName, profile.handle) ?: unnamed,
initialsName = nameOrHandle(profile.displayName, profile.handle),
picture = profile.profilePicture,
)
}
}
return ChatListItem.ContentBubble(
messageId = message.messageId,
contentIndex = 0,
content = content,
isFromSelf = message.isFromSelf,
timestamp = message.timestamp,
isEdited = message.lastEditedTs != null,
capabilities = emptySet(),
sender = sender,
senderId = senderId,
reactionPills = MessageReactions.from(message.reactions).pills,
canReact = false,
redacted = message.redacted,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge
import com.flipcash.app.messenger.internal.screens.components.GatedLandingRow
import com.flipcash.app.messenger.internal.screens.components.JoinRequestsAnchor
import com.flipcash.app.messenger.internal.screens.components.ReturnToChatChip
import com.flipcash.app.messenger.internal.screens.components.MessageList
Expand Down Expand Up @@ -549,6 +550,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}
}
// Outside the blur: a message link that lands where the transcript is withheld still shows
// the message it points at, over the placeholder rows, between the bars.
state.gatedLanding?.let { item ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(overlapPadding),
contentAlignment = Alignment.Center,
) {
GatedLandingRow(item = item)
}
}
}

openPhotoId?.let { id ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import com.flipcash.services.models.chat.BlobAccessContext
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.ui.BubblePosition
import com.flipcash.shared.chat.ui.ChatAnimations
import com.flipcash.shared.chat.ui.ContentBubble
import com.flipcash.shared.chat.ui.ReactionPillRow
import com.flipcash.shared.chat.ui.bubbleMaxWidth
import com.flipcash.shared.common.ui.ContactAvatar
import com.getcode.theme.CodeTheme
import kotlinx.coroutines.delay

/**
* The linked message a link lands on in a group that withholds its transcript (flow 6), drawn on
* its own above the blur. Sender, picture and reactions are sharp; the body is the server's
* placeholder, blurred by the bubble ([ChatListItem.ContentBubble.redacted]).
*
* Display only: the bubble is not interactive, and the avatar and pills answer nothing. It flashes
* once when it appears, as a jump in a readable transcript flashes the message it lands on.
*/
@Composable
internal fun GatedLandingRow(
item: ChatListItem.ContentBubble,
modifier: Modifier = Modifier,
) {
val attention = remember(item.messageId) { Animatable(0f) }
LaunchedEffect(item.messageId) {
attention.snapTo(1f)
delay(ChatAnimations.attentionHoldMs)
attention.animateTo(0f, ChatAnimations.attentionFade)
}
val sender = item.sender
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = CodeTheme.dimens.inset)
.testTag("gated_landing_row"),
) {
if (sender != null && sender.displayName.isNotBlank()) {
Text(
modifier = Modifier.padding(
start = senderNameInset(!item.isFromSelf),
bottom = CodeTheme.dimens.grid.x1,
),
text = sender.displayName,
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1),
) {
if (!item.isFromSelf) {
Box(
modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x6),
) {
if (sender != null) {
ContactAvatar(
image = sender.picture,
displayName = sender.initialsName.orEmpty(),
access = BlobAccessContext.profile(sender.userId),
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
)
}
}
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = if (item.isFromSelf) Alignment.End else Alignment.Start,
) {
ContentBubble(
item = item,
position = BubblePosition.Solo,
interactive = false,
attention = { attention.value },
)
if (item.reactionPills.isNotEmpty()) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
ReactionPillRow(
pills = item.reactionPills,
canReact = false,
onToggle = {},
onPillLongClick = {},
onOpenPicker = {},
modifier = Modifier
.align(if (item.isFromSelf) Alignment.TopEnd else Alignment.TopStart)
.width(
bubbleMaxWidth(
content = item.content,
rowWidth = maxWidth,
rowInset = CodeTheme.dimens.inset,
),
),
alignEnd = item.isFromSelf,
)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ private fun SwipeToReplyAffordance(
* does not, just the `grid.x1`.
*/
@Composable
private fun senderNameInset(showsGutter: Boolean): Dp =
internal fun senderNameInset(showsGutter: Boolean): Dp =
if (showsGutter) {
CodeTheme.dimens.staticGrid.x5 + CodeTheme.dimens.grid.x1 + CodeTheme.dimens.grid.x1
} else {
Expand Down
Loading
Loading