diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 47559d7ee7..8d17b0e5a3 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1832,6 +1832,21 @@ Share Profile Share Message Forward + Pin + Unpin + Jump to message + Pinned message + Pinned message from %1$s: %2$s + Encrypted message + Pinned Messages + Pinned by %1$s ยท %2$s + you + Pinned messages are full. Unpin one to pin \"%1$s\" + Pinned messages are full. Unpin one to pin this message + Couldn\'t pin message + Couldn\'t unpin message + Couldn\'t find that message + Pinned messages Show Profile Card Copy Link diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt index edcdcf301c..69c637abc3 100644 --- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -42,6 +42,11 @@ enum class InviteLinkAction : Parcelable { Share, Revoke } @Serializable data class NewInviteLinkChoice(val expiry: String, val uses: String) : Parcelable +/** The pinned message tapped in [ChatStep.PinnedMessages]: the chat jumps to it once the list closes. */ +@Parcelize +@Serializable +data class PinnedMessageJump(val messageId: Long) : Parcelable + /** A custom paid-reaction amount, in whole US cents. */ @Parcelize @Serializable @@ -357,6 +362,21 @@ sealed interface ChatStep : FlowStep, Parcelable { @Parcelize @Serializable data class Reactors(val chatId: ChatId, val messageId: Long) : ChatStep, Sheet, HalfSheet + + /** + * The chat's pinned messages, newest first (L1). The pins are the conversation's, read when the + * chat opened; the route names the chat only. [pinAfterUnpin] is set when it opened because the + * chat is at its pin cap (L1b): that message is pinned once an unpin frees a slot. + * + * Returns the tapped row as a [PinnedMessageJump]; the conversation jumps to it. + */ + @Parcelize + @Serializable + data class PinnedMessages(val chatId: ChatId, val pinAfterUnpin: Long? = null) : + ChatStep, + NavigationRetVal, + Sheet, + HalfSheet } /** What opened a [ChatStep.Profile], which decides the actions it offers. */ diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index b72d1fd13c..4680aa9b02 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -44,6 +45,10 @@ import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.GroupInviteViewModel import com.flipcash.app.core.chat.InviteLinkAction import com.flipcash.app.core.chat.NewInviteLinkChoice +import com.flipcash.app.core.chat.PinnedMessageJump +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.app.messenger.internal.pins.pinnedListDone +import com.flipcash.app.messenger.internal.screens.components.PinnedMessagesSheet import com.flipcash.app.messenger.internal.invites.InviteLinkActions import com.flipcash.app.messenger.internal.invites.NewInviteLinkContent import com.flipcash.app.messenger.internal.invites.toChoice @@ -334,6 +339,9 @@ private fun chatEntryProvider( annotatedEntry { step -> FlowReactorsScreen(step.chatId, step.messageId) } + annotatedEntry { step -> + FlowPinnedMessagesSheet(step.pinAfterUnpin) + } } @Composable @@ -837,6 +845,39 @@ private fun FlowReactionPickerScreen(messageId: Long) { ) } +/** + * [ChatStep.PinnedMessages]: the conversation's pins on the app's sheet scene (L1, L1c, and L1b when + * [pinAfterUnpin] is set). The pins are the conversation's own, so this reads its view model; a + * tapped row closes the sheet and returns to the conversation as a [PinnedMessageJump]. + */ +@Composable +private fun FlowPinnedMessagesSheet(pinAfterUnpin: Long?) { + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val resultBack = resultBackNavigator(exit = dismissSheet) + + // Built off the main thread: a cash row looks up its token before it can draw. + val rows by produceState?>(null, state.pins) { value = viewModel.pinnedRows(state.pins) } + // The last rows stay up while the sheet slides away, so it never empties on the way out. + var shownRows by remember { mutableStateOf>(emptyList()) } + rows?.takeIf { it.isNotEmpty() }?.let { shownRows = it } + + // The last unpin, or the waiting message landing in the slot it freed, leaves nothing to do here. + val done = state.pins.pinnedListDone(pinAfterUnpin) + LaunchedEffect(done) { if (done) dismissSheet() } + + PinnedMessagesSheet( + rows = shownRows, + canUnpin = state.canPin, + full = pinAfterUnpin != null, + fullQuote = state.waitingPin?.takeIf { it.messageId == pinAfterUnpin }?.quote, + onOpen = { resultBack.returnValue(PinnedMessageJump(it)) }, + onUnpin = { viewModel.dispatchEvent(ChatViewModel.Event.UnpinMessage(it, pinAfter = pinAfterUnpin)) }, + onClose = { dismissSheet() }, + ) +} + /** * Who reacted to [messageId], and with what, from the entry's own [ReactorsViewModel]. Tapping a * named row dismisses this sheet, then opens that person's profile on the chat. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 96244bbe24..67cd3d42ca 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -7,6 +7,7 @@ import com.flipcash.shared.chat.ui.settledUsd import com.flipcash.shared.chat.ui.partFor import com.flipcash.shared.chat.media.ChatPhoto import com.flipcash.services.models.chat.MediaItemRendition +import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.LinkPreview import com.flipcash.shared.chat.ui.linkPreview import android.content.ClipboardManager @@ -98,6 +99,12 @@ import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.ViewerState +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.PinMessageError +import com.flipcash.app.messenger.internal.pins.ChatPins +import com.flipcash.app.messenger.internal.pins.WaitingPin +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.shared.chat.ui.previewText import com.flipcash.services.models.chat.isActiveAt import com.flipcash.services.models.chat.silence import com.flipcash.services.models.chat.isDmAddressable @@ -327,6 +334,9 @@ internal class ChatViewModel @AssistedInject constructor( updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { + /** The highest pin summary version heard of; a read follows any that passes the held list. */ + private val pinVersionHeard = MutableStateFlow(null) + /** * Whether the transcript may be drawn. Saved link previews load at app start, but a chat opened @@ -382,6 +392,12 @@ internal class ChatViewModel @AssistedInject constructor( * needs the deadline this keeps. */ val viewerState: ViewerState? = null, + /** The chat's pins, read on open and again whenever the stored summary moves past them. */ + val pins: ChatPins = ChatPins(), + /** Which visible pin the bar shows, newest first. A tap jumps to it and steps to the next older. */ + val pinBarIndex: Int = 0, + /** The message the server refused at the pin cap; the pinned list quotes it while it waits for a slot. */ + val waitingPin: WaitingPin? = null, /** * The clock a timed silence is read against. A silence lapses with no event, so [silence] * compares the deadline to this and a timer bumps it at the deadline. @@ -655,6 +671,10 @@ internal class ChatViewModel @AssistedInject constructor( val participant: ChatParticipant? get() = subject?.asParticipant() + /** `can_pin`: the creator of a group while a member, either member of a DM. Read, never derived from the type. */ + val canPin: Boolean + get() = viewerState?.permissions?.canPin == true + /** * Cash sent here pays members rather than posting a cash link. Private groups only: they * are where the client holds the whole roster. @@ -988,6 +1008,26 @@ internal class ChatViewModel @AssistedInject constructor( /** This chat's viewer state moved, from the stream or from the viewer's own request. */ data class OnViewerStateResolved(val viewerState: ViewerState?) : Event + /** A read of the chat's pins came back, made at summary [version]. */ + data class PinsRead(val pins: List, val version: Long) : Event + + /** The transcript's stored copies of the pinned messages, keyed by message id. */ + data class PinnedCopiesStored(val messages: Map) : Event + + /** The stored pin summary moved; its count says whether there is anything to wait for. */ + data class PinSummaryChanged(val count: Int) : Event + + /** Pin [messageId]; [quote] names it in the full-list header if the chat is at its cap. */ + data class PinMessage(val messageId: Long, val quote: String?) : Event + /** Unpin [messageId]; once it succeeds, [pinAfter] takes the freed slot (the list opened full). */ + data class UnpinMessage(val messageId: Long, val pinAfter: Long? = null) : Event + + /** The server refused a pin with `TOO_MANY_PINS`: the screen opens the list to make room for it. */ + data class PinCapReached(val waiting: WaitingPin) : Event + + /** The pin bar was tapped: jump to the pin it shows and step to the next older one. */ + data object PinBarTapped : Event + /** A timed silence reached its deadline; nothing else says so. */ data object SilenceLapsed : Event @@ -1336,6 +1376,8 @@ internal class ChatViewModel @AssistedInject constructor( val canBan: Boolean, /** The group's creator, whom no moderation action may name. */ val creatorId: ID?, + /** `can_pin`, read from the viewer state for DMs and groups alike. */ + val canPin: Boolean, ) /** @@ -1554,6 +1596,7 @@ internal class ChatViewModel @AssistedInject constructor( canSilence = it.chatType == ChatType.GROUP && moderation?.canSilence == true, canBan = it.chatType == ChatType.GROUP && moderation?.canBan == true, creatorId = (it.subject as? ChatSubject.Group)?.creator, + canPin = it.canPin, ) }.distinctUntilChanged(), ::ViewerGates, @@ -1587,12 +1630,7 @@ internal class ChatViewModel @AssistedInject constructor( // message is still in the page: its key, and so the next page's, are unchanged. if (content.paysRequestShare) return@flatMapIndexed emptyList() - val enriched = if (content is MessageContent.Cash && content.tokenName.isBlank()) { - val token = tokenCoordinator.getTokenMetadata(content.mint).getOrNull()?.token - if (token != null) { - content.copy(tokenName = token.name, tokenImageUrl = token.imageUrl) - } else content - } else content + val enriched = content.withTokenMetadata() // Resolved here, next to the token-metadata lookup, because this is the one // place in the transcript that already does async per-item work. A citation of @@ -1698,6 +1736,7 @@ internal class ChatViewModel @AssistedInject constructor( canSilence = facts.canSilence, canBan = facts.canBan, creatorId = facts.creatorId, + canPin = facts.canPin, ), quote = quote, sender = sender, @@ -3409,6 +3448,14 @@ internal class ChatViewModel @AssistedInject constructor( } .launchIn(viewModelScope) + // A jump that can't land says so: a pin or a link to a message the server no longer + // returns would otherwise be a tap that does nothing. + eventFlow.filterIsInstance() + .onEach { toastController.showToast(R.string.title_messageUnreachable, replacePrevious = true) } + .launchIn(viewModelScope) + + observePins() + // Only distanceFromNewest, not resolveJump: an origin was on screen when its quote was // tapped, so one no longer stored has gone from the chat, and is skipped rather than fetched. eventFlow.filterIsInstance() @@ -4200,6 +4247,209 @@ internal class ChatViewModel @AssistedInject constructor( * 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. */ + /** + * Pins are read with `GetPinnedMessages` on open and read again whenever a newer + * `PinSummary.version` is heard of: from the stored summary, which the event stream advances on + * `PinsChanged`, or from the viewer's own pin or unpin. The list is never patched from an event. + * A summary missing from a feed read leaves the stored one alone, so it never clears pins here. + */ + private fun observePins() { + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> chatCoordinator.observeMetadata(chatId) } + .mapNotNull { it?.metadata?.pinSummary } + .distinctUntilChanged() + .onEach { summary -> + dispatchEvent(Event.PinSummaryChanged(summary.count)) + notePinVersion(summary.version) + } + .launchIn(viewModelScope) + + // Restarted by each newer version, so a read overtaken by another pin is dropped for the next. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> pinVersionHeard.map { chatId to it } } + .filter { (_, heard) -> stateFlow.value.pins.let { !it.loaded || it.isStale(heard) } } + .mapLatest { (chatId, _) -> + chatCoordinator.getPinnedMessages(chatId) + .onSuccess { dispatchEvent(Event.PinsRead(it.pins, it.version)) } + .onFailure { trace(tag = "Pins", message = "Pin read failed", error = it, type = TraceType.Error) } + } + .launchIn(viewModelScope) + + // A delete or edit of a pinned message moves no pin version; its stored copy shows it. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> + combine( + chatCoordinator.observeMessages(chatId), + stateFlow.map { it.pins.pinnedIds }.distinctUntilChanged(), + ) { messages, ids -> messages.filter { it.messageId in ids }.associateBy { it.messageId } } + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.PinnedCopiesStored(it)) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> pin(event.messageId, event.quote) } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + chatCoordinator.unpinMessage(chatId, event.messageId) + .onSuccess { summary -> + notePinVersion(summary.version) + // The list opened full: the slot just freed goes to the message that was waiting. + event.pinAfter?.let { waiting -> pin(waiting, quote = null, waited = true) } + } + .onFailure { toastController.showToast(R.string.title_unpinFailed, replacePrevious = true) } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + // The reducer has already stepped the bar on, so the pin it showed is one back. + val state = stateFlow.value + val shown = state.pins.visible.getOrNull( + (state.pinBarIndex - 1).mod(state.pins.visible.size.coerceAtLeast(1)) + ) ?: return@onEach + dispatchEvent(Event.JumpToMessage(shown.messageId)) + } + .launchIn(viewModelScope) + + } + + private fun notePinVersion(version: Long) { + pinVersionHeard.update { heard -> maxOf(heard ?: version, version) } + } + + /** + * The pin bar's one line for [pin]: the chat list's preview of the message, with no + * attribution. Null when there is nothing readable to quote, which the bar says as an + * encrypted message. + */ + internal fun pinPreview(pin: Pin): String? { + val isGroup = stateFlow.value.chatType == ChatType.GROUP + return pin.message.content.firstNotNullOfOrNull { content -> + content.previewText( + sentBySelf = false, + senderName = null, + isGroup = isGroup, + tokensByMint = emptyMap(), + resources = resources, + exchange = exchange, + paymentRequests = emptyMap(), + ) { null } + } + } + + /** + * The pin bar's quote for [pin], built the way the composer's reply strip quotes a message so + * the two read alike. A message with nothing readable quotes as an encrypted message. + */ + internal suspend fun pinQuote(pin: Pin): ChatQuote { + val quote = pin.message.toQuote() + val unreadable = pinPreview(pin) == null && + (quote.snippet as? ChatQuoteSnippet.Text)?.body.isNullOrEmpty() + return if (unreadable) { + quote.copy(snippet = ChatQuoteSnippet.Text(resources.getString(R.string.label_pinnedEncryptedMessage))) + } else { + quote + } + } + + /** A cash message names its token only by mint; the bubble's header needs the name and image. */ + private suspend fun MessageContent.withTokenMetadata(): MessageContent { + if (this !is MessageContent.Cash || tokenName.isNotBlank()) return this + val token = tokenCoordinator.getTokenMetadata(mint).getOrNull()?.token ?: return this + return copy(tokenName = token.name, tokenImageUrl = token.imageUrl) + } + + /** + * The pinned list's rows, drawn with the transcript's bubble, so a pinned message reads the same + * as it does in place: cash gets its token and the viewer's side of the payment the same way. + */ + internal suspend fun pinnedRows(pins: ChatPins): List { + val selfId = userManager.accountId + return pins.visible.mapNotNull { pin -> + val message = pin.message + val content = message.content.firstOrNull()?.withTokenMetadata() ?: return@mapNotNull null + PinnedRow( + pin = pin, + bubble = ChatListItem.ContentBubble( + messageId = message.messageId, + contentIndex = 0, + content = content, + isFromSelf = message.isFromSelf, + timestamp = message.timestamp, + isEdited = message.lastEditedTs != null, + receivedPart = (content as? MessageContent.Cash)?.partFor(selfId), + groupSendRecipients = (content as? MessageContent.Cash) + ?.takeIf { stateFlow.value.chatType == ChatType.GROUP } + ?.let { groupSendRecipients(it, senderProfiles.value) }, + ), + sender = pinPerson(message.senderId), + pinnedByName = if (pin.pinnedBy == selfId) { + resources.getString(R.string.label_pinnedByYou) + } else { + pinPerson(pin.pinnedBy).displayName + }, + spoken = pinPreview(pin), + ) + } + } + + /** + * A member as a pinned row shows them: the viewer's own, a group member's profile, or a DM's other + * member. The viewer reads as "You" but their avatar keeps their own initials. + */ + @OptIn(ExperimentalStdlibApi::class) + private fun pinPerson(userId: ID?): SenderIdentity { + val state = stateFlow.value + val id = userId ?: emptyList() + if (userId != null && userId == userManager.accountId) { + val profile = userManager.profile + return SenderIdentity( + userId = id, + displayName = resources.getString(R.string.title_you), + picture = profile?.profilePicture, + initialsName = profile?.let { nameOrHandle(it.displayName, it.handle) }, + ) + } + if (state.chatType != ChatType.GROUP) { + (state.participant as? ChatParticipant.TipUser)?.let { other -> + return SenderIdentity( + userId = id, + displayName = other.name ?: resources.getString(R.string.title_unnamedUser), + picture = other.profile.profilePicture, + initialsName = other.name, + ) + } + } + val person = groupSendPerson(userId?.hexEncodedString()) + return SenderIdentity(userId = id, displayName = person.name, picture = person.profile?.profilePicture) + } + + /** + * Pins [messageId]. [waited] is set when it is the message the full list was opened for; the + * list closes on its own once the fresh read shows it pinned. + */ + private suspend fun pin(messageId: Long, quote: String?, waited: Boolean = false) { + val chatId = stateFlow.value.chatId ?: return + chatCoordinator.pinMessage(chatId, messageId) + .onSuccess { summary -> notePinVersion(summary.version) } + .onFailure { error -> + // At the cap the list opens to make room. If it was already open for this message, a + // concurrent pin took the slot: say so and leave the list up for another try. + if (error is PinMessageError.TooManyPins && !waited) { + dispatchEvent(Event.PinCapReached(WaitingPin(messageId, quote))) + } else { + toastController.showToast(R.string.title_pinNotAdded, replacePrevious = true) + } + } + } + private suspend fun resolveJump(chatId: ChatId, messageId: Long) { val distance = chatCoordinator.distanceFromNewest(chatId, messageId) ?: if (chatCoordinator.loadHistoryThrough(chatId, messageId)) { @@ -4334,6 +4584,24 @@ internal class ChatViewModel @AssistedInject constructor( is Event.OnViewerStateResolved -> { state -> state.copy(viewerState = event.viewerState, silenceClock = kotlin.time.Clock.System.now()) } + is Event.PinsRead -> { state -> + val pins = state.pins.read(event.pins, event.version) + // A pin that left can take the bar's place out from under it; start over at the newest. + val index = state.pinBarIndex.takeIf { it < pins.visible.size } ?: 0 + state.copy(pins = pins, pinBarIndex = index) + } + is Event.PinnedCopiesStored -> { state -> + val pins = state.pins.copy(stored = event.messages) + val index = state.pinBarIndex.takeIf { it < pins.visible.size } ?: 0 + state.copy(pins = pins, pinBarIndex = index) + } + is Event.PinSummaryChanged -> { state -> + state.copy(pins = state.pins.copy(summaryCount = event.count)) + } + is Event.PinMessage -> { state -> state.copy(selection = null, confirmingDelete = false) } + is Event.UnpinMessage -> { state -> state.copy(selection = null, confirmingDelete = false) } + is Event.PinCapReached -> { state -> state.copy(waitingPin = event.waiting) } + Event.PinBarTapped -> { state -> state.copy(pinBarIndex = state.pins.stepAfter(state.pinBarIndex)) } Event.SilenceLapsed -> { state -> state.copy(silenceClock = kotlin.time.Clock.System.now()) } Event.SilenceLifted -> { state -> state } Event.CopyInviteLink, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/pins/ChatPins.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/pins/ChatPins.kt new file mode 100644 index 0000000000..e3246c42de --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/pins/ChatPins.kt @@ -0,0 +1,164 @@ +package com.flipcash.app.messenger.internal.pins + +import com.flipcash.app.messenger.internal.request.DeckPlacement +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity + +/** + * The open chat's pins as `GetPinnedMessages` last returned them, newest pin first. + * + * The list is read once on open and again whenever the chat's [PinSummary.version] passes the one + * it was read at; nothing here builds pins from stream events. `PinsChanged` is best-effort, so a + * version is a signal to read again rather than a change to apply. + * + * @property version the summary version [pins] were read at, or null before the first read. + * @property summaryCount the stored summary's pin count, which includes deleted messages. Only says + * whether there is anything to fetch; what shows is counted from [visible]. + * @property stored the transcript's stored copies of pinned messages. Deleting or editing a pinned + * message moves no pin version, so the read's copy goes stale; a stored copy wins over it. + */ +internal data class ChatPins( + val version: Long? = null, + val pins: List = emptyList(), + val summaryCount: Int = 0, + val stored: Map = emptyMap(), +) { + val loaded: Boolean get() = version != null + + /** Every pinned id, tombstones included: what decides whether the menu offers Pin or Unpin. */ + val pinnedIds: Set = pins.mapTo(mutableSetOf()) { it.messageId } + + /** + * The pins that show, each with its newest copy: a message deleted after it was pinned stays + * pinned but is never drawn. + */ + val visible: List = pins + .map { pin -> stored[pin.messageId]?.let { pin.copy(message = it) } ?: pin } + .filterNot { it.message.isTombstone } + + /** + * Whether a stored summary at [summaryVersion] is newer than what is held. A null summary is a + * feed or list read that left `pin_summary` unset, which says nothing about pins. + */ + fun isStale(summaryVersion: Long?): Boolean = + summaryVersion != null && (version == null || summaryVersion > version) + + /** Takes a read made at [readVersion], unless one at a later version already landed. */ + fun read(pins: List, readVersion: Long): ChatPins = + if (version != null && readVersion < version) this else copy(version = readVersion, pins = pins) + + /** What the bar shows when it points at the [index]th visible pin, or null when it hides. */ + fun bar(index: Int): PinBar? { + if (!loaded) return if (summaryCount > 0) PinBar.Fetching else null + if (visible.isEmpty()) return null + val at = index.coerceIn(0, visible.lastIndex) + return PinBar.Showing(pin = visible[at], position = at + 1, of = visible.size) + } + + /** + * The index the bar moves to after a tap on the pin at [index]: the next older pin, wrapping + * back to the newest after the oldest. + */ + fun stepAfter(index: Int): Int = if (visible.isEmpty()) 0 else (index + 1) % visible.size +} + +/** What the pin bar draws. */ +internal sealed interface PinBar { + /** Pins exist but the list has not come back yet. */ + data object Fetching : PinBar + + /** + * [pin] is the [position]th newest of the [of] pins that show. Newest-first, so position 1 is + * the latest pin. + */ + data class Showing(val pin: Pin, val position: Int, val of: Int) : PinBar +} + +/** + * The pin bar's segmented rule for the [PinBar.Showing.position]th of [PinBar.Showing.of] pins, or + * null with one pin, which keeps the single full-height rule. + * + * Segments stack bottom up: segment 1, the latest pin, is the bottom one, and each tap lights the + * one above. The rule's window fits [SEGMENT_WINDOW] of them, so past that the track scrolls just far + * enough to keep the lit one in view (Figma Cycling variant, node 13016:87: pin 2 of 3 lit at the + * top of the window, the latest below it, pin 3 scrolled out above). + */ +internal fun PinBar.Showing.segments(): PinSegments? { + if (of < 2) return null + val lowest = (position - SEGMENT_WINDOW + 1).coerceIn(1, of - SEGMENT_WINDOW + 1) + return PinSegments(count = of, lit = position, lowest = lowest) +} + +/** + * @property count every segment on the track, one per visible pin. + * @property lit the segment drawn in the author's colour, counted from the bottom. + * @property lowest the bottom segment in the window, counted from the bottom; the track sits + * `lowest - 1` segments down. + */ +internal data class PinSegments(val count: Int, val lit: Int, val lowest: Int) { + /** The segments in the window, bottom first. */ + val visible: IntRange get() = lowest..minOf(count, lowest + SEGMENT_WINDOW - 1) +} + +internal const val SEGMENT_WINDOW = 2 + +/** Where pins sit in the chat's chrome. */ +internal enum class PinSlot { + /** Nothing pinned that shows. */ + None, + + /** The bar under the top bar (C1, C6b, C7b). */ + Bar, + + /** A pin button in the top bar's trailing slot that opens the list (C6). */ + TopBarButton, +} + +/** + * Where pins go given the request deck's resting place. Payment requests win the space under the + * top bar: while the deck rests as the strip, pins step aside to the top bar's trailing slot, free + * because the request isn't the pill. Once the deck rests as the pill, by scroll or by a swipe up on + * the strip, the pill holds the trailing slot and the bar comes back. They swap; they never stack. + * + * @param hasPins whether the bar has something to show, fetching included. + * @param deckRest where the request deck rests, or null when the chat has no requests drawn. + * @param collapsing whether the strip is partway into the pill. The pill grows into the trailing + * slot from the first pixel, so the button leaves then; the bar waits until the pill is at rest. + */ +internal fun pinSlot(hasPins: Boolean, deckRest: DeckPlacement?, collapsing: Boolean = false): PinSlot = when { + !hasPins -> PinSlot.None + deckRest == DeckPlacement.Strip && collapsing -> PinSlot.None + deckRest == DeckPlacement.Strip -> PinSlot.TopBarButton + else -> PinSlot.Bar +} + +internal val ChatMessage.isTombstone: Boolean + get() = content.any { it is MessageContent.Deleted } + +/** + * Whether the pinned list has nothing left to do after a fresh read. Opened for a message waiting + * on a slot ([pinAfterUnpin]), it is done once that message is pinned; otherwise once nothing + * visible is left in it. + */ +internal fun ChatPins.pinnedListDone(pinAfterUnpin: Long?): Boolean = + if (pinAfterUnpin != null) pinAfterUnpin in pinnedIds else visible.isEmpty() + +/** A message whose pin the server refused at the cap; it is pinned once the viewer frees a slot. */ +internal data class WaitingPin(val messageId: Long, val quote: String?) + +/** + * One row of the pinned list: the message as the transcript would draw it, who sent it, and who + * pinned it. + */ +internal data class PinnedRow( + val pin: Pin, + val bubble: ChatListItem.ContentBubble, + val sender: SenderIdentity, + val pinnedByName: String, + /** The message as one line, what a screen reader says for the bubble. */ + val spoken: String? = null, +) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt index fb9c402341..4d0523489a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt @@ -102,6 +102,19 @@ internal class DeckHostState(owed: Set) { } } + /** + * A swipe up on the strip docks it into the pill without scrolling: the same collapse a scroll + * drives, by however far the finger has moved up ([deltaPx] negative) or back down. + */ + fun dragDock(deltaPx: Float) { + if (!following) return + collapsePx = (collapsePx - deltaPx).coerceIn(0f, stripPx) + if (collapsePx >= stripPx) pin() + } + + /** A dock swipe let go: past halfway it completes and holds for the visit, short of it the strip snaps back. */ + suspend fun releaseDock() = settle() + /** Finishes a collapse released partway, toward whichever end is nearer. */ private suspend fun settle() { val from = collapsePx diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt index 180fd31615..e4be646b99 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt @@ -251,6 +251,10 @@ internal fun RequestDeckStrip( onCycle: () -> Unit, onPay: (PaymentRequest) -> Unit, onHeightChange: (Int) -> Unit, + /** A swipe up on the strip, by however far it moved: docks it into the pill without a scroll. */ + onDock: (Float) -> Unit, + /** The dock swipe let go. */ + onDockReleased: suspend () -> Unit, modifier: Modifier = Modifier, ) { var shown by remember { mutableStateOf(deck) } @@ -283,6 +287,8 @@ internal fun RequestDeckStrip( onExpand = onExpand, onCycle = onCycle, onPay = onPay, + onDock = onDock, + onDockReleased = onDockReleased, ) } } @@ -306,8 +312,8 @@ private val DeckPlacement.deckStyle: DeckStyle /** * The front request with up to two cards peeking beneath it, in one of the deck's placements. - * Swipe down cycles; tap expands. Shown while [transition] is at [style], whose scope its front - * card moves between placements in. + * Swipe down cycles; tap expands. On the strip a swipe up docks it ([onDock]). Shown while + * [transition] is at [style], whose scope its front card moves between placements in. */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -320,6 +326,8 @@ private fun DeckStack( onExpand: () -> Unit, onCycle: () -> Unit, onPay: (PaymentRequest) -> Unit, + onDock: ((Float) -> Unit)? = null, + onDockReleased: (suspend () -> Unit)? = null, ) { transition.AnimatedVisibility( visible = { it == style }, @@ -342,6 +350,9 @@ private fun DeckStack( val pillHeightPx = with(density) { geometry.height.toPx() } val canCycle = deck.size > 1 val pillWidth = remember(scope) { PillWidth(scope) } + // A drag that starts upward from rest docks the strip for the whole gesture, back down + // included; one that starts downward cycles, as it always has. + var docking by remember { mutableStateOf(false) } val label = deckLabel(deck, now, viewerId, strip) val nextLabel = stringResource(R.string.action_nextRequest) @@ -464,6 +475,11 @@ private fun DeckStack( .draggable( orientation = Orientation.Vertical, state = rememberDraggableState { delta -> + if (onDock != null && (docking || delta < 0f && drag.value <= 0f)) { + docking = true + onDock(delta) + return@rememberDraggableState + } scope.launch { val value = drag.value val next = if (!canCycle) { @@ -481,6 +497,11 @@ private fun DeckStack( } }, onDragStopped = { velocity -> + if (docking) { + docking = false + onDockReleased?.invoke() + return@draggable + } val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY if (canCycle && past) { // Hand over on release: the card that rose becomes the front and the diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 9396919933..fdf3f57cd4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -1,12 +1,18 @@ package com.flipcash.app.messenger.internal.screens +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.expandVertically +import com.flipcash.shared.chat.models.ChatQuote import com.flipcash.services.models.paymentrequest.unpaidShareOf import com.flipcash.app.messenger.internal.request.RequestDeckStrip import com.flipcash.app.messenger.internal.request.rememberDeckHostState import android.os.Parcelable import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -33,6 +39,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -59,6 +66,7 @@ import com.getcode.opencode.model.core.ID import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.PinnedMessageJump import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import com.flipcash.app.core.chat.ProfileAddress @@ -81,10 +89,16 @@ 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.PinListButton +import com.flipcash.app.messenger.internal.screens.components.PinnedMessageBar +import com.flipcash.app.messenger.internal.pins.PinBar +import com.flipcash.app.messenger.internal.pins.PinSlot +import com.flipcash.app.messenger.internal.pins.pinSlot import com.flipcash.app.messenger.internal.screens.components.ReturnToChatChip import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.BlurredContent import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.models.ChatAction @@ -120,6 +134,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.flipcash.app.messenger.internal.request.RequestPaySheet import com.flipcash.app.messenger.internal.request.RequestPayViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MessengerScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -506,6 +521,32 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val requestPayToken by requestPay.token.collectAsStateWithLifecycle() LaunchedEffect(requestPay) { requestPay.routes.collect { route -> navigator.navigate(route) } } + // Requests win the space under the top bar: while the strip is out, pins move to the top bar's + // trailing slot; once the strip is the pill, the pill has that slot and the bar comes back. + val pinBar = state.pins.bar(state.pinBarIndex) + val pinPlace = pinSlot( + hasPins = pinBar != null, + deckRest = deckHosts.rest.host.takeIf { state.chatType == ChatType.GROUP && drawnDeck.isNotEmpty() }, + collapsing = deckHosts.collapsing, + ) + // The list is a sheet destination over the chat; a row tapped there comes back as a jump. + val openPins = { pinAfterUnpin: Long? -> + val chatId = state.chatId + if (chatId != null) keyboard.hideIfVisible { + navigator.navigateForResult(ChatStep.PinnedMessages(chatId, pinAfterUnpin)) { result -> + if (result is NavResultOrCanceled.ReturnValue) { + viewModel.dispatchEvent(ChatViewModel.Event.JumpToMessage(result.value.messageId)) + } + } + } + } + // At the pin cap the list opens to make room, holding the refused message for the freed slot. + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .collect { openPins(it.waiting.messageId) } + } + // The collapsed pill's last bounds, where the expanded deck puts its close button. var deckPill by remember { mutableStateOf(null) } @@ -531,6 +572,18 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { chatActionHandler = chatActionHandler, dispatch = viewModel::dispatchEvent, onConfirmRemove = moderation::confirmRemove, + actions = { + // Leaves the composition in the frame the slot goes: the pill takes the + // title's end at full width as the strip starts to collapse, and + // AnimatedVisibility keeps exiting content laid out for a few frames even + // with no exit transition. + if (pinPlace == PinSlot.TopBarButton) { + val shown = remember { MutableTransitionState(false) }.apply { targetState = true } + AnimatedVisibility(visibleState = shown, enter = fadeIn() + scaleIn()) { + PinListButton(onClick = { openPins(null) }) + } + } + }, trailing = { if (state.chatType == ChatType.GROUP) { RequestDeckPill( @@ -570,6 +623,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } }, onHeightChange = { deckHosts.stripPx = it.toFloat() }, + onDock = deckHosts::dragDock, + onDockReleased = deckHosts::releaseDock, ) } if (stripBehindBackdrop) { @@ -585,6 +640,39 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } } + // Held through the exit, so the bar doesn't empty as it slides away. + var lastPinBar by remember { mutableStateOf(pinBar) } + if (pinBar != null) lastPinBar = pinBar + // Slides down from the top bar and fades, as iOS's bar moves in from the top edge. + // The expand alongside keeps the transcript's top inset (this column's height) + // moving with the bar, so the oldest message never snaps under it. + AnimatedVisibility( + visible = pinPlace == PinSlot.Bar, + enter = expandVertically(expandFrom = Alignment.Top) + + slideInVertically { -it } + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + + slideOutVertically { -it } + fadeOut(), + ) { + lastPinBar?.let { bar -> + val shown = (bar as? PinBar.Showing)?.pin + val spoken = remember(shown) { shown?.let(viewModel::pinPreview) } + val quote by produceState(null, shown) { + value = shown?.let { viewModel.pinQuote(it) } + } + PinnedMessageBar( + bar = bar, + quote = quote.takeIf { shown != null }, + spoken = spoken, + onTap = { keyboard.hideIfVisible { viewModel.dispatchEvent(ChatViewModel.Event.PinBarTapped) } }, + onOpenList = { openPins(null) }, + hazeState = hazeState, + modifier = Modifier + // Edges line up with the back button and the top bar's pin button. + .padding(horizontal = AppBarDefaults.ControlInset) + .padding(top = CodeTheme.dimens.grid.x2), + ) + } + } // Hold the last list through the exit, so the card doesn't empty as it slides away. var lastRequests by remember { mutableStateOf(state.joinRequests) } if (state.joinRequests.isNotEmpty()) lastRequests = state.joinRequests diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index d689e4ff9c..79f69c0147 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -10,6 +10,7 @@ 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.RowScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth @@ -24,6 +25,7 @@ import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.PushPin import com.flipcash.app.messenger.internal.screens.openMessageShare import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -90,6 +92,8 @@ internal fun ChatTopBar( /** Asks the host to confirm a removal: chat, member, first name, group title. Supplied by the screen so the bar needs no Hilt. */ onConfirmRemove: (ChatId, ID, String, String) -> Unit = { _, _, _, _ -> }, trailing: @Composable () -> Unit = {}, + /** Controls in the bar's end slot, which sit the same distance from the edge as back does. */ + actions: @Composable RowScope.() -> Unit = {}, ) { val bgColor = CodeTheme.colors.background val statusBars = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -118,7 +122,7 @@ internal fun ChatTopBar( label = "chat top bar", ) { target -> when (target) { - TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler, trailing) + TopBarMode.Conversation -> ConversationTitleBar(navigator, state, chatActionHandler, trailing, actions) TopBarMode.Editing -> EditingBar(dispatch) is TopBarMode.Selecting -> { val sender = target.selection.sender @@ -130,6 +134,7 @@ internal fun ChatTopBar( } MessageSelectionBar( selection = target.selection, + pinned = target.selection.messageId in state.pins.pinnedIds, keyboard = keyboard, dispatch = dispatch, // Resolved here rather than in the bar: reporting is its own top-level @@ -209,8 +214,10 @@ private fun ConversationTitleBar( state: ChatViewModel.State, chatActionHandler: ChatActionHandler, trailing: @Composable () -> Unit, + actions: @Composable RowScope.() -> Unit, ) { AppBarWithTitle( + rightContents = actions, leftIcon = { // The avatar sits just past back and opens the profile, so a thumb that missed the // arrow landed in the dead strip before it or on the avatar. Everything before the @@ -314,6 +321,8 @@ private fun ConversationTitleBar( @Composable private fun MessageSelectionBar( selection: ChatListItem.ContentBubble, + /** Whether the selected message is pinned, so the pin action unpins it instead. */ + pinned: Boolean, keyboard: KeyboardController, dispatch: (ChatViewModel.Event) -> Unit, onShareMessage: () -> Unit, @@ -366,6 +375,26 @@ private fun MessageSelectionBar( ) ) } + // Granted by can_pin on a message that can be pinned; whether it pins or unpins is the + // chat's pinned ids', which the capability doesn't carry. + if (MessageCapability.Pin in capabilities) { + add( + MessageAction( + label = stringResource(if (pinned) R.string.action_unpinMessage else R.string.action_pinMessage), + icon = Icons.Filled.PushPin, + testTag = if (pinned) "action_unpin_message" else "action_pin_message", + onClick = { + dispatch( + if (pinned) { + ChatViewModel.Event.UnpinMessage(selection.messageId) + } else { + ChatViewModel.Event.PinMessage(selection.messageId, body) + } + ) + }, + ) + ) + } if (body != null && MessageCapability.Edit in capabilities) { add( MessageAction( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinBar.kt new file mode 100644 index 0000000000..708f97f8da --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinBar.kt @@ -0,0 +1,232 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.pins.PinBar +import com.flipcash.app.messenger.internal.pins.PinSegments +import com.flipcash.app.messenger.internal.pins.segments +import com.flipcash.features.messenger.R +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.ui.ChatQuoteContent +import com.flipcash.shared.chat.ui.ChatQuoteRule +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.CircularIconButton +import com.getcode.ui.components.chat.ChatInputDefaults +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur + +/** + * The bar under the top bar that quotes a pinned message (C1), drawn like the composer's reply + * strip: the author's rule, their name and one line of the message. With several pins the rule + * splits into one segment per pin, the shown one lit. A tap on the body jumps to the pin it shows + * and steps the bar to the next older one; the list button opens every pin. + * + * Keeps its own glass, like [JoinRequestsAnchor] beside it; only the quote comes from the strip. + * + * @param quote the shown pin as a quote, or null while the list is on its way. + * @param spoken the shown pin's one line for screen readers, which no longer get a title. + */ +@Composable +internal fun PinnedMessageBar( + bar: PinBar, + quote: ChatQuote?, + spoken: String?, + onTap: () -> Unit, + onOpenList: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val shape = CodeTheme.shapes.medium + val ground = if (hazeState != null) { + Modifier + .hazeBlur(HazeInput.Sources(hazeState), chatGlassMaterial()) + .background(ChatInputDefaults.ContainerColor) + } else { + Modifier.background(Color.White.copy(alpha = 0.1f)) + } + + val showing = bar as? PinBar.Showing + val description = if (quote != null) { + stringResource(R.string.cd_pinnedMessageFrom, quote.authorName, spoken.orEmpty()) + } else { + stringResource(R.string.title_pinnedMessage) + } + + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .then(ground) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .heightIn(min = PinBarMinHeight) + .height(IntrinsicSize.Min) + .testTag("pin_bar"), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable(enabled = showing != null, onClick = onTap) + .clearAndSetSemantics { + contentDescription = description + role = Role.Button + if (showing != null) onClick { onTap(); true } + } + // Inset like iOS's bar, so the rule sits off the glass edge; the tap target stays full. + .padding( + start = CodeTheme.dimens.staticGrid.x3, + top = CodeTheme.dimens.staticGrid.x2, + bottom = CodeTheme.dimens.staticGrid.x2, + ), + contentAlignment = Alignment.CenterStart, + ) { + val rule: @Composable (Color) -> Unit = { color -> + val segments = showing?.segments() + if (segments != null) SegmentedRule(color = color, segments = segments) else ChatQuoteRule(color) + } + if (quote != null) { + // iOS's bar: 10pt from the rule to the text, and no padding of the quote's own. + ChatQuoteContent( + quote = quote, + rule = rule, + gap = 10.dp, + textVerticalPadding = 0.dp, + ) + } else { + // Nothing to quote yet: the rule and a skeleton line hold the bar's place. + Row( + modifier = Modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + ChatQuoteRule(CodeTheme.colors.tertiary) + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(CodeTheme.dimens.staticGrid.x2) + .clip(CircleShape) + .background(CodeTheme.colors.textSecondary.copy(alpha = 0.3f)), + ) + } + } + } + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x10) + .clickable(onClick = onOpenList) + .testTag("action_open_pinned_list"), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_list_bullet_pin), + contentDescription = stringResource(R.string.cd_openPinnedMessages), + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), + ) + } + } +} + +/** + * The quote's rule split into one segment per visible pin, stacked bottom up from the latest, the + * one the bar shows lit in the author's colour and the rest dimmed. A window two segments tall + * clips the track, which slides to keep the lit segment in view as taps step to older pins. + */ +@Composable +private fun SegmentedRule(color: Color, segments: PinSegments) { + val step = SegmentHeight + SegmentGap + // The track hangs from the window's bottom edge; scrolling up the track moves it down. + val scroll by animateDpAsState(targetValue = step * (segments.lowest - 1), label = "pin segments scroll") + Box( + modifier = Modifier + .size(width = SegmentWidth, height = SegmentWindowHeight) + .clipToBounds(), + contentAlignment = Alignment.BottomStart, + ) { + Column( + modifier = Modifier + .wrapContentHeight(align = Alignment.Bottom, unbounded = true) + .offset { IntOffset(0, scroll.roundToPx()) }, + verticalArrangement = Arrangement.spacedBy(SegmentGap), + ) { + for (segment in segments.count downTo 1) { + val lit by animateColorAsState( + targetValue = if (segment == segments.lit) color else color.copy(alpha = 0.3f), + label = "pin segment colour", + ) + Box( + modifier = Modifier + .size(width = SegmentWidth, height = SegmentHeight) + .background(lit), + ) + } + } + } +} + +/** + * The top bar's trailing list button (C6): stands in for the bar while the request strip holds the + * space under the top bar, and opens the list. + */ +@Composable +internal fun PinListButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + CircularIconButton( + modifier = modifier, + onClick = onClick, + testTag = "action_open_pinned_list_top_bar", + ) { size -> + Icon( + painter = painterResource(R.drawable.ic_list_bullet_pin), + contentDescription = stringResource(R.string.cd_openPinnedMessages), + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } +} + +// The join-requests anchor's height, which the bar matches so the two read as one family. +private val PinBarMinHeight = 50.dp +// Figma Cycling variant (node 13016:87): two segments fit the window, the rest scroll. +private val SegmentWidth = 3.dp +private val SegmentHeight = 16.dp +private val SegmentGap = 2.dp +private val SegmentWindowHeight = 34.dp diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheet.kt new file mode 100644 index 0000000000..7842f32626 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheet.kt @@ -0,0 +1,324 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import com.getcode.ui.utils.AllowSheetExpansionWhenScrollable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.CircularIconButtonDefaults +import com.getcode.ui.core.expandedTouchTarget +import com.getcode.ui.components.AppBarWithTitle +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.material3.ripple +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.composed +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import com.flipcash.shared.chat.ui.bubbleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.getcode.ui.components.BottomScrollEdge +import com.getcode.ui.components.TopScrollEdge +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.chat.ui.BubblePosition +import com.flipcash.shared.chat.ui.ContentBubble +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.time.toJavaInstant + +/** + * Every visible pin, newest first (L1). Each row is the sender, the message drawn as its transcript + * bubble, and who pinned it when; a tap closes the list and jumps to the message. Unpin shows only + * with `can_pin` (L1c without). [full] is set when the list opened because the chat is at its pin + * cap (L1b): a header asks for an unpin, never naming the cap. + * + * @param fullQuote the message waiting on a slot, quoted in the header; null quotes nothing. + */ +@Composable +internal fun PinnedMessagesSheet( + rows: List, + canUnpin: Boolean, + full: Boolean, + fullQuote: String?, + onOpen: (Long) -> Unit, + onUnpin: (Long) -> Unit, + onClose: () -> Unit, +) { + val listState = rememberLazyListState() + AllowSheetExpansionWhenScrollable(listState) + val hazeState = rememberHazeState() + val density = LocalDensity.current + // The header's height, so the first row starts below it and the top blur covers it. + var headerHeight by remember { mutableStateOf(0.dp) } + val navigationBar = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + // As iOS's sheet: the title stays put while rows scroll under it, blurring in at the top and + // out at the bottom. + Box(modifier = Modifier.fillMaxWidth().testTag("pinned_messages_sheet")) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .hazeSource(hazeState), + contentPadding = PaddingValues( + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + top = headerHeight, + bottom = navigationBar + CodeTheme.dimens.grid.x4, + ), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + items(rows, key = { it.pin.messageId }) { row -> + PinnedMessageRow( + modifier = Modifier.animateItem(), + row = row, + canUnpin = canUnpin, + onOpen = { onOpen(row.pin.messageId) }, + onUnpin = { onUnpin(row.pin.messageId) }, + ) + } + } + + TopScrollEdge( + hazeState = hazeState, + listState = listState, + height = headerHeight, + modifier = Modifier.align(Alignment.TopCenter), + ) + BottomScrollEdge( + hazeState = hazeState, + listState = listState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .onSizeChanged { headerHeight = with(density) { it.height.toDp() } } + .padding(bottom = CodeTheme.dimens.grid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + // The reactors sheet's header: a centred title and the bar's close at the end, frosted + // as the invite sheet's is, since rows scroll under it. + AppBarWithTitle( + title = stringResource(R.string.title_pinnedMessages), + titleAlignment = Alignment.CenterHorizontally, + endContent = { + val closeLabel = stringResource(R.string.cd_close) + // The bar's close is drawn smaller than 48dp; grow its touch area to that. + val reach = ((48.dp - CircularIconButtonDefaults.ButtonSize) / 2).coerceAtLeast(0.dp) + AppBarDefaults.Close( + // The shared close draws its icon with no description. + modifier = Modifier.expandedTouchTarget(PaddingValues(reach), onTap = onClose).semantics { + contentDescription = closeLabel + role = Role.Button + }, + hazeState = hazeState, + onClick = onClose, + ) + }, + ) + if (full) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .testTag("pinned_messages_full"), + text = fullQuote?.let { stringResource(R.string.label_pinsFull, it) } + ?: stringResource(R.string.label_pinsFullUntitled), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } +} + +/** + * One pinned message. Only the bubble (jump to the message) and Unpin take taps: the avatar, the + * name, the pinned-by line and the space around them do nothing, as on iOS. + */ +@Composable +internal fun PinnedMessageRow( + row: PinnedRow, + canUnpin: Boolean, + onOpen: () -> Unit, + onUnpin: () -> Unit, + modifier: Modifier = Modifier, +) { + val avatarSize = CodeTheme.dimens.staticGrid.x5 + val gutter = CodeTheme.dimens.grid.x1 + val jumpLabel = stringResource(R.string.action_jumpToMessage) + Column( + modifier = modifier + .fillMaxWidth() + .testTag("pinned_message_${row.pin.messageId}"), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + Text( + modifier = Modifier.padding(start = avatarSize + gutter), + text = row.sender.displayName, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + Row( + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(gutter), + ) { + ContactAvatar( + image = row.sender.picture, + displayName = row.sender.initialsName.orEmpty(), + access = BlobAccessContext.profile(row.sender.userId), + modifier = Modifier + .size(avatarSize) + .clip(CircleShape) + .clearAndSetSemantics { }, + ) + // ContentBubble fills the row but puts [modifier] on the bubble itself, so the jump + // covers the bubble and not the empty width beside it. + Box(Modifier.weight(1f)) { + ContentBubble( + item = row.bubble, + position = BubblePosition.Solo, + interactive = false, + leading = true, + modifier = Modifier + .clip(bubbleShape(BubblePosition.Solo, row.bubble.isFromSelf)) + .jumpTarget(onOpen) + .clearAndSetSemantics { + testTag = "pinned_bubble_${row.pin.messageId}" + row.spoken?.let { contentDescription = it } + role = Role.Button + onClick(label = jumpLabel) { onOpen(); true } + }, + ) + } + } + Row( + modifier = Modifier.padding(start = avatarSize + gutter), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.label_pinnedBy, row.pinnedByName, pinnedDate(row)), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + if (canUnpin) { + Spacer(Modifier.size(CodeTheme.dimens.grid.x2)) + // 48dp to touch, laid out at that height so it sits below the bubble, not over it. + Box( + modifier = Modifier + .minimumInteractiveComponentSize() + .clickable(role = Role.Button, onClick = onUnpin) + .testTag("action_unpin_${row.pin.messageId}"), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.action_unpinMessage), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + ) + } + } + } + } +} + +@Composable +private fun pinnedDate(row: PinnedRow): String { + val formatter = remember { + DateTimeFormatter.ofPattern( + android.text.format.DateFormat.getBestDateTimePattern(Locale.getDefault(), "MMMd") + ) + } + return remember(row.pin.pinnedAt) { + formatter.format(row.pin.pinnedAt.toJavaInstant().atZone(ZoneId.systemDefault())) + } +} + +/** + * Takes a tap anywhere on the bubble before its own links and buttons see it, so the whole bubble + * is one jump. Only the down and the up are consumed: a drag still scrolls the list, and moving + * past touch slop drops the press. + */ +private fun Modifier.jumpTarget(onClick: () -> Unit): Modifier = composed { + val interactions = remember { MutableInteractionSource() } + val currentOnClick by rememberUpdatedState(onClick) + this + .indication(interactions, ripple()) + .pointerInput(interactions) { + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + down.consume() + val press = PressInteraction.Press(down.position) + interactions.tryEmit(press) + val slop = viewConfiguration.touchSlop + var released = false + while (true) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes + .firstOrNull { it.id == down.id } ?: break + if (!change.pressed) { + change.consume() + released = !change.isOutOfBounds(size, extendedTouchPadding) + break + } + if ((change.position - down.position).getDistance() > slop) break + } + if (released) { + interactions.tryEmit(PressInteraction.Release(press)) + currentOnClick() + } else { + interactions.tryEmit(PressInteraction.Cancel(press)) + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/res/drawable/ic_list_bullet_pin.xml b/apps/flipcash/features/messenger/src/main/res/drawable/ic_list_bullet_pin.xml new file mode 100644 index 0000000000..dfd75632f4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/res/drawable/ic_list_bullet_pin.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index 8c785cc0ab..d91c0af0a3 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -75,6 +75,7 @@ class ChatEventStallTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } private val contactCoordinator = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 765ebfff99..b1051ff9d1 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -85,6 +85,7 @@ class ChatGroupAnalyticsTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) coEvery { sampleChatters(any()) } returns Result.failure(IllegalStateException("not sampled")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 8766b176aa..0fa10d4bf2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -86,6 +86,7 @@ class ChatGroupCashLinkTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) coEvery { sampleChatters(any()) } returns Result.failure(IllegalStateException("not sampled")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index ba4d6d6382..21861f5879 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -59,6 +59,7 @@ class ChatMentionPickerTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) coEvery { sampleChatters(any()) } returns Result.failure(IllegalStateException("not sampled")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 063432f1fc..63e511c7ab 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -62,6 +62,7 @@ class ChatOpenTranscriptTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands unless a test emits one. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt index a4d184db23..d9372bc769 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt @@ -16,6 +16,7 @@ import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Rate import io.mockk.every +import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -45,6 +46,7 @@ class ChatPreviewsReadyTest { var mainCoroutineRule = MainCoroutineRule(StandardTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns emptyFlow() } private val tipPaymentDelegate = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt index 62119e785f..6c020ed16f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt @@ -61,6 +61,7 @@ class ChatReturnToOriginTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands unless a test emits one. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 63d22d5b09..f29fbd3c83 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -75,6 +75,7 @@ class ChatSendFailureAnalyticsTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() } private val contactCoordinator = mockk(relaxed = true) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt index 84e8028f19..87ba4ea81f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSilenceLiftedToastTest.kt @@ -70,6 +70,7 @@ class ChatSilenceLiftedToastTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands unless a test emits one. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt index b26e464431..88eb63355c 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -58,6 +58,7 @@ class ChatTypingGateTest { var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) private val chatCoordinator = mockk(relaxed = true) { + coEvery { getPinnedMessages(any()) } returns Result.failure(IllegalStateException("no pins in this test")) every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() // No load ever lands; a relaxed Flow completes empty and fails the open's first { }. every { newestPageLoads() } returns MutableSharedFlow() diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/pins/ChatPinsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/pins/ChatPinsTest.kt new file mode 100644 index 0000000000..efac4c4d9c --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/pins/ChatPinsTest.kt @@ -0,0 +1,230 @@ +package com.flipcash.app.messenger.internal.pins + +import com.flipcash.app.messenger.internal.request.DeckPlacement +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.time.Instant + +class ChatPinsTest { + + private val alex = listOf(1) + private val sentAt = Instant.fromEpochSeconds(1_000) + + private fun pin(id: Long, deleted: Boolean = false) = Pin( + messageId = id, + pinnedBy = alex, + pinnedAt = sentAt, + message = ChatMessage( + messageId = id, + senderId = alex, + content = listOf( + if (deleted) MessageContent.Deleted(sentAt, alex) else MessageContent.Text("message $id"), + ), + timestamp = sentAt, + unreadSeq = 0, + eventSequence = id, + ), + ) + + // PinsChanged moves the stored summary; the list is read again only past the held version. + @Test + fun `a summary newer than the held list is stale`() { + val held = ChatPins().read(listOf(pin(3)), readVersion = 4) + + assertTrue(held.isStale(5)) + assertFalse(held.isStale(4)) + assertFalse(held.isStale(3)) + } + + // Chat feeds leave pin_summary unset; that says nothing about pins. + @Test + fun `a missing summary never reads as stale or clears pins`() { + val held = ChatPins().read(listOf(pin(3)), readVersion = 4) + + assertFalse(held.isStale(null)) + assertEquals(listOf(3L), held.visible.map { it.messageId }) + } + + @Test + fun `before the first read any summary is stale`() { + assertTrue(ChatPins().isStale(0)) + } + + // Two reads racing: the one made at an older version lands last and must not win. + @Test + fun `a read at an older version doesn't replace a newer one`() { + val newer = ChatPins().read(listOf(pin(3), pin(2)), readVersion = 6) + + val after = newer.read(listOf(pin(2)), readVersion = 5) + + assertEquals(6L, after.version) + assertEquals(listOf(3L, 2L), after.pins.map { it.messageId }) + } + + @Test + fun `a read at a newer version replaces the list, emptying it included`() { + val held = ChatPins().read(listOf(pin(3)), readVersion = 4) + + val after = held.read(emptyList(), readVersion = 5) + + assertEquals(emptyList(), after.pins) + assertNull(after.bar(0)) + } + + // Deleted pinned messages stay pinned server-side; the client hides them. + @Test + fun `the bar skips a deleted latest pin for the next`() { + val pins = ChatPins().read(listOf(pin(5, deleted = true), pin(3), pin(1)), readVersion = 1) + + val bar = pins.bar(0) as PinBar.Showing + + assertEquals(3L, bar.pin.messageId) + assertEquals(1, bar.position) + assertEquals(2, bar.of) + } + + @Test + fun `a deleted pin still counts as pinned`() { + val pins = ChatPins().read(listOf(pin(5, deleted = true), pin(3)), readVersion = 1) + + assertTrue(5L in pins.pinnedIds) + } + + // A delete moves no pin version, so the list isn't read again; the stored tombstone hides it. + @Test + fun `a pinned message deleted after the read hides from its stored copy`() { + val read = ChatPins().read(listOf(pin(5), pin(3)), readVersion = 1) + + val pins = read.copy(stored = mapOf(5L to pin(5, deleted = true).message)) + + assertEquals(listOf(3L), pins.visible.map { it.messageId }) + assertEquals(1, (pins.bar(0) as PinBar.Showing).of) + assertTrue(5L in pins.pinnedIds) + } + + @Test + fun `a later read keeps the stored copies`() { + val pins = ChatPins() + .read(listOf(pin(5), pin(3)), readVersion = 1) + .copy(stored = mapOf(5L to pin(5, deleted = true).message)) + .read(listOf(pin(5), pin(3)), readVersion = 2) + + assertEquals(listOf(3L), pins.visible.map { it.messageId }) + } + + @Test + fun `only deleted pins hide the bar`() { + val pins = ChatPins().read(listOf(pin(5, deleted = true)), readVersion = 1) + + assertNull(pins.bar(0)) + } + + @Test + fun `a tap steps to the next older pin and wraps to the newest`() { + val pins = ChatPins().read(listOf(pin(5), pin(3), pin(1)), readVersion = 1) + + assertEquals(1, pins.stepAfter(0)) + assertEquals(2, pins.stepAfter(1)) + assertEquals(0, pins.stepAfter(2)) + } + + @Test + fun `the bar waits on the list when the summary says there are pins`() { + assertEquals(PinBar.Fetching, ChatPins(summaryCount = 2).bar(0)) + assertNull(ChatPins(summaryCount = 0).bar(0)) + } + + // C1: no requests drawn, the bar is under the top bar. + @Test + fun `without a request deck pins take the bar`() { + assertEquals(PinSlot.Bar, pinSlot(hasPins = true, deckRest = null)) + } + + // C6: the strip holds the space under the top bar, so pins move to its trailing slot. + @Test + fun `while the strip is out pins are a top bar button`() { + assertEquals(PinSlot.TopBarButton, pinSlot(hasPins = true, deckRest = DeckPlacement.Strip)) + } + + // C6b and C7b: scrolled or swiped into the pill, the pill has the trailing slot and the bar returns. + @Test + fun `once the strip is the pill pins take the bar`() { + assertEquals(PinSlot.Bar, pinSlot(hasPins = true, deckRest = DeckPlacement.Pill)) + } + + // C7a: the pill grows into the trailing slot as the strip collapses, so the button leaves first. + @Test + fun `a strip partway into the pill takes the button away and holds the bar back`() { + assertEquals(PinSlot.None, pinSlot(hasPins = true, deckRest = DeckPlacement.Strip, collapsing = true)) + } + + @Test + fun `nothing pinned takes no slot`() { + assertEquals(PinSlot.None, pinSlot(hasPins = false, deckRest = DeckPlacement.Strip)) + assertEquals(PinSlot.None, pinSlot(hasPins = false, deckRest = null)) + } + + @Test + fun `the list is done when its last visible pin leaves`() { + assertTrue(ChatPins().read(emptyList(), readVersion = 5).pinnedListDone(pinAfterUnpin = null)) + val tombstonesOnly = ChatPins().read(listOf(pin(3, deleted = true)), readVersion = 5) + assertTrue(tombstonesOnly.pinnedListDone(pinAfterUnpin = null)) + } + + @Test + fun `the list stays while pins remain`() { + assertFalse(ChatPins().read(listOf(pin(3)), readVersion = 5).pinnedListDone(pinAfterUnpin = null)) + } + + // L1b: an unpin frees the slot, the waiting message lands, and only then does the list close. + @Test + fun `a list opened full is done once the waiting message is pinned`() { + assertFalse(ChatPins().read(emptyList(), readVersion = 5).pinnedListDone(pinAfterUnpin = 9)) + assertFalse(ChatPins().read(listOf(pin(3)), readVersion = 5).pinnedListDone(pinAfterUnpin = 9)) + assertTrue(ChatPins().read(listOf(pin(3), pin(9)), readVersion = 6).pinnedListDone(pinAfterUnpin = 9)) + } + + // Segments stack bottom up from the latest pin; the two-segment window scrolls with the lit one. + @Test + fun `one pin keeps the single rule`() { + assertNull(showing(position = 1, of = 1).segments()) + } + + @Test + fun `segments for each step through 2, 3 and 5 pins`() { + // of to, per position: (visible bottom first, lit) + val expected = mapOf( + 2 to listOf(1..2 to 1, 1..2 to 2), + 3 to listOf(1..2 to 1, 1..2 to 2, 2..3 to 3), + 5 to listOf(1..2 to 1, 1..2 to 2, 2..3 to 3, 3..4 to 4, 4..5 to 5), + ) + for ((of, steps) in expected) { + steps.forEachIndexed { index, (visible, lit) -> + val segments = showing(position = index + 1, of = of).segments()!! + assertEquals("$of pins, step ${index + 1}: visible", visible, segments.visible) + assertEquals("$of pins, step ${index + 1}: lit", lit, segments.lit) + assertEquals(of, segments.count) + } + } + } + + @Test + fun `tapping steps the lit segment up and wraps back to the latest`() { + val pins = ChatPins().read((5 downTo 1).map { pin(it.toLong()) }, readVersion = 1) + var index = 0 + val lit = (1..6).map { + val segments = (pins.bar(index) as PinBar.Showing).segments()!! + index = pins.stepAfter(index) + segments.lit + } + assertEquals(listOf(1, 2, 3, 4, 5, 1), lit) + } + + private fun showing(position: Int, of: Int) = PinBar.Showing(pin = pin(position.toLong()), position = position, of = of) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckDockTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckDockTest.kt new file mode 100644 index 0000000000..2fcb8e7231 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/request/DeckDockTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.app.messenger.internal.pins.PinSlot +import com.flipcash.app.messenger.internal.pins.pinSlot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** C7: a swipe up on the strip docks it into the pill without a scroll, through the collapse a scroll drives. */ +class DeckDockTest { + + private val dinner = listOf(1) + private val sunday = listOf(2) + + private fun strip() = DeckHostState(setOf(dinner)).apply { stripPx = 100f } + + @Test + fun `a swipe up partway collapses without docking`() { + val deck = strip() + + deck.dragDock(-40f) + + assertTrue(deck.collapsing) + assertEquals(DeckPlacement.Strip, deck.rest.host) + } + + @Test + fun `a swipe back down undoes the collapse`() { + val deck = strip() + + deck.dragDock(-40f) + deck.dragDock(60f) + + assertFalse(deck.collapsing) + assertEquals(DeckPlacement.Strip, deck.rest.host) + } + + // C7a: mid-swipe the pill is growing into the trailing slot, so the pin button is already gone. + @Test + fun `a swipe partway up takes the pin button out of the top bar`() { + val deck = strip() + + deck.dragDock(-30f) + + assertEquals( + PinSlot.None, + pinSlot(hasPins = true, deckRest = deck.rest.host, collapsing = deck.collapsing), + ) + } + + // C7b: docked, the pill holds the trailing slot and the pin bar comes back. + @Test + fun `a swipe the strip's full height docks it and gives pins the bar`() { + val deck = strip() + + deck.dragDock(-100f) + + assertEquals(DeckPlacement.Pill, deck.rest.host) + assertEquals(PinSlot.Bar, pinSlot(hasPins = true, deckRest = deck.rest.host)) + } + + @Test + fun `the dock holds while the same requests are owed`() { + val deck = strip() + deck.dragDock(-100f) + + deck.owing(setOf(dinner), hasDeck = true) + + assertEquals(DeckPlacement.Pill, deck.rest.host) + } + + @Test + fun `a new request brings the docked strip back`() { + val deck = strip() + deck.dragDock(-100f) + + deck.owing(setOf(dinner, sunday), hasDeck = true) + + assertEquals(DeckPlacement.Strip, deck.rest.host) + assertEquals(PinSlot.TopBarButton, pinSlot(hasPins = true, deckRest = deck.rest.host)) + } + + @Test + fun `nothing docks while the deck rests as the pill`() { + val deck = DeckHostState(emptySet()).apply { stripPx = 100f } + + deck.dragDock(-100f) + + assertFalse(deck.collapsing) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessageRowTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessageRowTest.kt new file mode 100644 index 0000000000..8033ba7f31 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessageRowTest.kt @@ -0,0 +1,160 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.click +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTouchInput +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity +import com.getcode.theme.DesignSystem +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Instant + +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "en") +class PinnedMessageRowTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val alex = listOf(1) + private val sentAt = Instant.fromEpochSeconds(1_000) + private val content = MessageContent.Text("see https://flipcash.com") + + private val row = PinnedRow( + pin = Pin( + messageId = 7, + pinnedBy = alex, + pinnedAt = sentAt, + message = ChatMessage( + messageId = 7, + senderId = alex, + content = listOf(content), + timestamp = sentAt, + unreadSeq = 0, + eventSequence = 7, + ), + ), + bubble = ChatListItem.ContentBubble( + messageId = 7, + contentIndex = 0, + content = content, + isFromSelf = false, + timestamp = sentAt, + ), + sender = SenderIdentity(userId = alex, displayName = "Alex", picture = null), + pinnedByName = "You", + spoken = "see https://flipcash.com", + ) + + // The merged tree, as accessibility reads it. The bubble's own text and link still sit in the + // unmerged tree under the cover's clearAndSetSemantics. + private val clickable = SemanticsMatcher.keyIsDefined(SemanticsActions.OnClick) + + private class RecordingUriHandler : UriHandler { + val opened = mutableListOf() + override fun openUri(uri: String) { + opened += uri + } + } + + @Test + fun `only the bubble and Unpin take a click`() { + var opened = 0 + var unpinned = 0 + composeTestRule.setContent { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = true, onOpen = { opened++ }, onUnpin = { unpinned++ }) + } + } + + val clicks = composeTestRule.onAllNodes(clickable) + .fetchSemanticsNodes() + .map { it.config.getOrNull(SemanticsProperties.TestTag) } + assertEquals(listOf("pinned_bubble_7", "action_unpin_7"), clicks) + + composeTestRule.onNodeWithTag("pinned_bubble_7").performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.onNodeWithTag("action_unpin_7").performSemanticsAction(SemanticsActions.OnClick) + assertEquals(1, opened) + assertEquals(1, unpinned) + } + + @Test + fun `the bubble is labelled as the jump and reads its message`() { + composeTestRule.setContent { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = true, onOpen = {}, onUnpin = {}) + } + } + + val bubble = composeTestRule.onNodeWithTag("pinned_bubble_7").fetchSemanticsNode() + assertEquals("Jump to message", bubble.config[SemanticsActions.OnClick].label) + assertEquals(listOf("see https://flipcash.com"), bubble.config[SemanticsProperties.ContentDescription]) + // The bubble's own text is not a second node: the cover speaks for it. + composeTestRule.onAllNodesWithText("see https://flipcash.com") + .fetchSemanticsNodes() + .let { assertEquals(0, it.size) } + } + + @Test + fun `a real tap fires only on the bubble and Unpin`() { + var opened = 0 + var unpinned = 0 + val uris = RecordingUriHandler() + composeTestRule.setContent { + CompositionLocalProvider(LocalUriHandler provides uris) { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = true, onOpen = { opened++ }, onUnpin = { unpinned++ }) + } + } + } + + composeTestRule.onNodeWithText("Alex").performTouchInput { click() } + composeTestRule.onNodeWithText("Pinned by You", substring = true).performTouchInput { click() } + assertEquals(0, opened) + assertEquals(0, unpinned) + + // Off-centre, near the bubble's corner and Unpin's edge. + composeTestRule.onNodeWithTag("pinned_bubble_7").performTouchInput { click(topLeft + Offset(4f, 4f)) } + composeTestRule.onNodeWithTag("action_unpin_7").performTouchInput { click(centerRight - Offset(2f, 0f)) } + assertEquals(1, opened) + assertEquals(1, unpinned) + + // On the link inside the bubble: the jump, not the link. + composeTestRule.onNodeWithTag("pinned_bubble_7").performTouchInput { click(center) } + assertEquals(2, opened) + assertEquals(emptyList(), uris.opened) + } + + @Test + fun `without the right to unpin, the bubble is the only click`() { + composeTestRule.setContent { + DesignSystem { + PinnedMessageRow(row = row, canUnpin = false, onOpen = {}, onUnpin = {}) + } + } + + val clicks = composeTestRule.onAllNodes(clickable).fetchSemanticsNodes() + assertEquals(1, clicks.size) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheetTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheetTest.kt new file mode 100644 index 0000000000..729f1148a4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/PinnedMessagesSheetTest.kt @@ -0,0 +1,114 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.click +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.pins.PinnedRow +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.SenderIdentity +import com.getcode.theme.DesignSystem +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Instant + +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "en") +class PinnedMessagesSheetTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val alex = listOf(1) + private val sentAt = Instant.fromEpochSeconds(1_000) + private val content = MessageContent.Text("see https://flipcash.com") + + private val row = PinnedRow( + pin = Pin( + messageId = 7, + pinnedBy = alex, + pinnedAt = sentAt, + message = ChatMessage( + messageId = 7, + senderId = alex, + content = listOf(content), + timestamp = sentAt, + unreadSeq = 0, + eventSequence = 7, + ), + ), + bubble = ChatListItem.ContentBubble( + messageId = 7, + contentIndex = 0, + content = content, + isFromSelf = false, + timestamp = sentAt, + ), + sender = SenderIdentity(userId = alex, displayName = "Alex", picture = null), + pinnedByName = "You", + spoken = "see https://flipcash.com", + ) + + private fun showSheet(canUnpin: Boolean, full: Boolean, onClose: () -> Unit) { + composeTestRule.setContent { + DesignSystem { + PinnedMessagesSheet( + rows = listOf(row), + canUnpin = canUnpin, + full = full, + fullQuote = null, + onOpen = {}, + onUnpin = {}, + onClose = onClose, + ) + } + } + } + + private fun closeWorks(canUnpin: Boolean, full: Boolean) { + var closed = 0 + showSheet(canUnpin = canUnpin, full = full, onClose = { closed++ }) + + val close = composeTestRule.onNodeWithContentDescription("Close") + // Off-centre, near the button's corner. + close.performTouchInput { click(Offset(width * 0.8f, height * 0.25f)) } + assertEquals(1, closed) + // 23dp out from its centre on each side, just inside a 48dp target, still closes. + close.performTouchInput { + val reach = 23.dp.toPx() + listOf(Offset(-reach, 0f), Offset(reach, 0f), Offset(0f, -reach), Offset(0f, reach)) + .forEach { click(center + it) } + } + assertEquals(5, closed) + + // The title stays centred with the close beside it. + val root = composeTestRule.onRoot().getBoundsInRoot() + val title = composeTestRule.onNodeWithText("Pinned Messages").getBoundsInRoot() + assertEquals( + ((root.left + root.right) / 2).value, + ((title.left + title.right) / 2).value, + 1f, + ) + } + + @Test + fun `the close pops the sheet in the normal list`() = closeWorks(canUnpin = true, full = false) + + @Test + fun `the close pops the sheet in the read-only list`() = closeWorks(canUnpin = false, full = false) + + @Test + fun `the close pops the sheet when the chat is full`() = closeWorks(canUnpin = true, full = true) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 60e124fbe1..637bcdb566 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -131,11 +131,12 @@ private fun ChatSummary.formatPreview( /** * The row's line for one piece of message content, or null when there is nothing worth previewing. + * Also the pin bar's one-line quote, which passes no attribution. * * [depth] bounds the reply unwrap below. Nothing the app sends nests a reply inside a reply, but * this content arrives off the wire, so a malformed chain must not recurse forever. */ -private fun MessageContent.previewText( +fun MessageContent.previewText( sentBySelf: Boolean, senderName: String?, isGroup: Boolean, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt index 14a74bf3a3..1853c429c8 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt @@ -67,9 +67,6 @@ fun ComposerReplyStrip( modifier: Modifier = Modifier, hazeState: HazeState? = null, ) { - val rule = quote.accent ?: CodeTheme.colors.tertiary - val name = quote.nameAccent ?: rule - val shape = CodeTheme.shapes.medium // The composer's material, not a brighter one: a tint of the background itself rather than of a // grey lifted off it. `clip` must precede `hazeBlur` to bound the blur to the rounded rect. @@ -101,34 +98,12 @@ fun ComposerReplyStrip( horizontalArrangement = Arrangement.spacedBy(ComposerReplyStripDefaults.gap), verticalAlignment = Alignment.CenterVertically, ) { - // Square and flush to the card's leading edge; the card's own clip is what rounds it. - Box( - modifier = Modifier - .width(ComposerReplyStripDefaults.ruleWidth) - .fillMaxHeight() - .background(rule), + ChatQuoteContent( + quote = quote, + modifier = Modifier.weight(1f), + snippetMaxLines = ComposerReplyStripDefaults.textMaxLines, ) - Column( - modifier = Modifier - .weight(1f) - .padding(vertical = ComposerReplyStripDefaults.textPadding), - ) { - Text( - text = quote.authorName, - style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), - color = name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - QuoteLine(quote.snippet) - } - - // After the text and before the way out, centred on the row, as iOS lays it. Nothing for a - // redacted photo or any other kind of quote. - val photo = quote.snippet as? ChatQuoteSnippet.Photo - if (photo != null && !photo.redacted) QuotedPhotoThumbnail(photo) - // A disc rather than a bare โœ•, in a hit target wider than the disc. Box( modifier = Modifier @@ -157,20 +132,87 @@ fun ComposerReplyStrip( } } +/** + * A quote's body as the reply strip draws it: the rule in the author's colour flush to the leading + * edge, their name over the snippet, then a photo's thumbnail. The caller owns the ground and what + * trails it, so the reply strip and the pin bar can share it on their own surfaces. + * + * Lays out as a row filling the caller's height; give it a parent with a bounded or intrinsic + * height so [rule] can fill it. + * + * @param rule what runs down the leading edge, given the author's colour. The reply strip's is a + * single bar; the pin bar splits it into one segment per pin. + * @param gap the space between [rule] and the text. + * @param textVerticalPadding the space above and below the text. The pin bar pads itself, so it + * passes none. + */ +@Composable +fun ChatQuoteContent( + quote: ChatQuote, + modifier: Modifier = Modifier, + snippetMaxLines: Int = 1, + rule: @Composable (Color) -> Unit = { color -> ChatQuoteRule(color) }, + gap: Dp = ComposerReplyStripDefaults.gap, + textVerticalPadding: Dp = ComposerReplyStripDefaults.textPadding, +) { + val ruleColor = quote.accent ?: CodeTheme.colors.tertiary + val name = quote.nameAccent ?: ruleColor + Row( + modifier = modifier.fillMaxHeight(), + horizontalArrangement = Arrangement.spacedBy(gap), + verticalAlignment = Alignment.CenterVertically, + ) { + rule(ruleColor) + + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = textVerticalPadding), + ) { + if (quote.authorName.isNotEmpty()) { + Text( + text = quote.authorName, + style = CodeTheme.typography.caption.copy(fontWeight = FontWeight.Bold), + color = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + QuoteLine(quote.snippet, snippetMaxLines) + } + + // After the text, centred on the row, as iOS lays it. Nothing for a redacted photo or any + // other kind of quote. + val photo = quote.snippet as? ChatQuoteSnippet.Photo + if (photo != null && !photo.redacted) QuotedPhotoThumbnail(photo) + } +} + +/** The reply strip's rule: square, flush to the card's leading edge; the card's clip rounds it. */ +@Composable +fun ChatQuoteRule(color: Color, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .width(ComposerReplyStripDefaults.ruleWidth) + .fillMaxHeight() + .background(color), + ) +} + /** * The quoted original itself, one step under the bubble body's size and in the same weight: the * quote is the subject of the strip, so it is read rather than glanced at. Primary text for the same * reason โ€” dimming it makes it look like placeholder text for the field below. */ @Composable -private fun QuoteLine(snippet: ChatQuoteSnippet) { +private fun QuoteLine(snippet: ChatQuoteSnippet, maxLines: Int) { val style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium) when (snippet) { is ChatQuoteSnippet.Text -> Text( text = labelInviteLinks(snippet.body, stringResource(R.string.label_inviteLinkPreview)), style = style, color = CodeTheme.colors.textMain, - maxLines = ComposerReplyStripDefaults.textMaxLines, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) @@ -182,7 +224,7 @@ private fun QuoteLine(snippet: ChatQuoteSnippet) { ), style = style, color = CodeTheme.colors.textMain, - maxLines = ComposerReplyStripDefaults.textMaxLines, + maxLines = maxLines, overflow = TextOverflow.Ellipsis, ) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 1d98ed7504..ab4e9b71c5 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -135,6 +135,8 @@ private val EDITED_MARKER_GAP = 6.dp * @param attention how strongly this bubble is currently being pointed at, 0f to 1f โ€” the flash a * jump leaves on the message it landed on. A lambda because it is read while drawing: an animation * running through it repaints the bubble without recomposing it or the list carrying it. + * @param leading whether the bubble sits at the leading edge whoever sent it, as in a list of + * messages lifted out of the transcript; it keeps its own style either way. */ @Composable fun ContentBubble( @@ -142,6 +144,7 @@ fun ContentBubble( position: BubblePosition, modifier: Modifier = Modifier, interactive: Boolean = true, + leading: Boolean = false, onLongClick: (() -> Unit)? = null, onDoubleClick: (() -> Unit)? = null, attention: () -> Float = { 0f }, @@ -192,7 +195,7 @@ fun ContentBubble( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (item.isFromSelf) Arrangement.End else Arrangement.Start, + horizontalArrangement = if (item.isFromSelf && !leading) Arrangement.End else Arrangement.Start, ) { // The card row of a split message, bare whatever the message around it carries -- a // text or a reply alike. The rows either side of it hold the prose. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 88b0ae0976..6a5c54e960 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -10,6 +10,8 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.InviteParameters import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.chat.PinSummary import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.ChatType @@ -484,6 +486,25 @@ interface MessagingOperations { */ suspend fun peekMessage(chatId: ChatId, messageId: Long): Result + /** + * Every pin in [chatId], most recently pinned first. Each [Pin.message] is readable the way + * [readMessage] makes it: the stored copy when there is one, otherwise the server's copy, + * opened on this device if it is end-to-end encrypted. Nothing is stored; a pinned message can + * be far older than the stored transcript. + * + * Pins whose message was deleted come back as tombstones; callers hide them. + */ + suspend fun getPinnedMessages(chatId: ChatId): Result + + /** + * Pins [messageId] in [chatId] and returns the chat's updated pin summary. Re-pinning a pinned + * message is a no-op on the server. Fails with `PinMessageError.TooManyPins` at the server's cap. + */ + suspend fun pinMessage(chatId: ChatId, messageId: Long): Result + + /** Unpins [messageId] in [chatId] and returns the chat's updated pin summary. */ + suspend fun unpinMessage(chatId: ChatId, messageId: Long): Result + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt index 6079b6247e..6695a53960 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MessageCapability.kt @@ -3,6 +3,7 @@ package com.flipcash.shared.chat import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.WidgetContent import com.flipcash.shared.chat.reactions.SelfReaction import com.getcode.opencode.model.core.ID import com.flipcash.shared.chat.ui.linkableText @@ -48,6 +49,13 @@ enum class MessageCapability { /** Remove the message's sender from the group (a ban), under the same rules as [Silence] with `can_ban`. */ Remove, + + /** + * Pin or unpin the message, granted by `ViewerState.Permissions.can_pin` on a message that can + * be pinned ([isPinnable]). Whether the action pins or unpins is decided by the caller, which + * holds the chat's pinned ids. + */ + Pin, } /** @@ -164,8 +172,10 @@ fun resolveCapabilities( canSilence: Boolean = false, canBan: Boolean = false, creatorId: ID? = null, + canPin: Boolean = false, ): Set { val resolved = resolveForParticipant(message, policy, now, canSpeak) + .let { if (canPin && isPinnable(message)) it + MessageCapability.Pin else it } if (!canPost) return resolved.readOnly() // Report is granted on exactly the messages a participant other than the viewer sent, so it // stands in for "someone else's message" here. The creator is never offered either action. @@ -183,7 +193,23 @@ fun resolveCapabilities( * a set resolved while the viewer could post has to narrow at the menu if they have since left. */ fun Set.readOnly(): Set = filterTo(mutableSetOf()) { - it == MessageCapability.Copy || it == MessageCapability.Report || it == MessageCapability.Link + it == MessageCapability.Copy || it == MessageCapability.Report || it == MessageCapability.Link || + it == MessageCapability.Pin +} + +/** + * Whether [message] is something a pin can point at. The server refuses system messages and + * tombstones (`MESSAGE_NOT_ALLOWED`) and an unacknowledged message has no id to pin. Payment + * requests are refused here only: the request strip and pill already keep them at the top. + */ +fun isPinnable(message: ChatMessage): Boolean { + val contents = message.content + if (contents.isEmpty()) return false + if (message.eventSequence == 0L) return false + if (contents.any { it is MessageContent.Deleted }) return false + if (contents.all { it is MessageContent.System }) return false + if (contents.any { it is MessageContent.Widget && it.widget is WidgetContent.PaymentRequest }) return false + return true } private fun resolveForParticipant( @@ -274,7 +300,8 @@ fun Set.withinWindows( // server enforces them, and nothing in the contract limits how old a reportable message // may be. MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report, - MessageCapability.Link, MessageCapability.Silence, MessageCapability.Remove -> true + MessageCapability.Link, MessageCapability.Silence, MessageCapability.Remove, + MessageCapability.Pin -> true } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PinnedMessages.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PinnedMessages.kt new file mode 100644 index 0000000000..e1f9c2d072 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/PinnedMessages.kt @@ -0,0 +1,12 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.Pin + +/** + * Every pin in a chat, most recently pinned first, and the `PinSummary.version` they were read at. + * The version is what a later summary is compared against to know the list is out of date. + */ +data class PinnedMessages( + val pins: List, + val version: Long, +) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 33c9de193c..4480c866ef 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -33,6 +33,9 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.Pin +import com.flipcash.services.models.chat.PinSummary +import com.flipcash.services.models.PagingToken import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MuteState import com.flipcash.services.models.chat.PointerType @@ -49,6 +52,7 @@ import com.flipcash.shared.chat.internal.SenderResolver import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation +import com.flipcash.shared.chat.PinnedMessages import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.OutgoingEncryption @@ -419,6 +423,46 @@ class MessagingDelegate @Inject constructor( override suspend fun peekMessage(chatId: ChatId, messageId: Long): Result = messagingController.getMessage(chatId, messageId, ViewMode.FULL_OR_REDACTED) + override suspend fun getPinnedMessages(chatId: ChatId): Result = runCatching { + val pins = mutableListOf() + var token: PagingToken? = null + var version: Long? = null + do { + val page = chatController.getPinnedMessages( + chatId, + QueryOptions(token = token), + ViewMode.FULL_OR_REDACTED, + ).getOrThrow() + pins += page.pins + // The first page's: a later page read after another pin landed must not claim a version + // whose pins this list doesn't hold. + if (version == null) version = page.pinSummary.version + token = page.pagingToken + } while (page.hasMore && token != null) + PinnedMessages( + pins = pins.map { pin -> pin.copy(message = readablePinned(chatId, pin.message)) }, + version = version ?: 0, + ) + } + + /** The stored copy, opened on upsert, when there is one; otherwise [message] opened the way [readMessage] does. */ + private suspend fun readablePinned(chatId: ChatId, message: ChatMessage): ChatMessage { + messageDataSource.getMessage(chatId, message.messageId)?.let { return it } + val opener = incoming + val selfId = userManager.accountId + if (opener == null || selfId == null || message.content.singleOrNull() !is MessageContent.Encrypted) { + return message + } + val peerId = if (message.senderId == selfId) getOtherMember(chatId)?.userId else null + return opener.open(chatId, selfId, peerId, listOf(message), stored = { null }).single() + } + + override suspend fun pinMessage(chatId: ChatId, messageId: Long): Result = + chatController.pinMessage(chatId, messageId) + + override suspend fun unpinMessage(chatId: ChatId, messageId: Long): Result = + chatController.unpinMessage(chatId, messageId) + /** Text, photos and replies to either are what DMs encrypt; anything else keeps the server's body. */ private fun MessageContent.pushText(): String? = when (this) { is MessageContent.Text -> text diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt index 46cbc5e1b3..e55990667f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessageCapabilityTest.kt @@ -619,4 +619,51 @@ class MessageCapabilityTest { assertEquals(false, canBoost(text(isFromSelf = false), ChatType.GROUP, selfReactions = reacted, canSpeak = false)) assertEquals(false, canBoost(text(isFromSelf = false, eventSequence = 0), ChatType.GROUP, selfReactions = reacted)) } + + // can_pin is the whole grant: who may pin is the server's call, read from the viewer state. + @Test + fun `pin is offered on a message with can_pin and withheld without it`() { + val incoming = text(isFromSelf = false) + + assertEquals(true, MessageCapability.Pin in resolveCapabilities(incoming, now = sentAt, canPin = true)) + assertEquals(false, MessageCapability.Pin in resolveCapabilities(incoming, now = sentAt, canPin = false)) + } + + @Test + fun `pin survives losing the right to post`() { + val resolved = resolveCapabilities(text(), now = sentAt, canPost = false, canPin = true) + + assertEquals(true, MessageCapability.Pin in resolved) + } + + @Test + fun `pin is offered on cash`() { + assertEquals(true, MessageCapability.Pin in resolveCapabilities(cash(), now = sentAt, canPin = true)) + } + + // The server refuses these with MESSAGE_NOT_ALLOWED; the menu shouldn't offer what fails. + @Test + fun `pin is hidden on system messages and tombstones`() { + val system = message(listOf(MessageContent.System("Anna joined")), isFromSelf = false) + val tombstone = message(listOf(MessageContent.Deleted(sentAt, otherId)), isFromSelf = false) + + assertEquals(false, MessageCapability.Pin in resolveCapabilities(system, now = sentAt, canPin = true)) + assertEquals(false, MessageCapability.Pin in resolveCapabilities(tombstone, now = sentAt, canPin = true)) + } + + // Client-only: the request strip and pill already keep requests at the top. + @Test + fun `pin is hidden on payment requests`() { + val request = message(listOf(MessageContent.Widget(WidgetContent.PaymentRequest(listOf(7))))) + + assertEquals(false, MessageCapability.Pin in resolveCapabilities(request, now = sentAt, canPin = true)) + } + + @Test + fun `pin is hidden on a message the server hasn't acknowledged`() { + assertEquals( + false, + MessageCapability.Pin in resolveCapabilities(text(eventSequence = 0), now = sentAt, canPin = true), + ) + } } diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/51.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/51.json new file mode 100644 index 0000000000..a2e576a552 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/51.json @@ -0,0 +1,1231 @@ +{ + "formatVersion": 1, + "database": { + "version": 51, + "identityHash": "8d61a035bffcc3bafa80fd4ef21d035a", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `can_invite` INTEGER NOT NULL DEFAULT 0, `can_silence` INTEGER NOT NULL DEFAULT 0, `can_ban` INTEGER NOT NULL DEFAULT 0, `can_pin` INTEGER NOT NULL DEFAULT 0, `silence_until_epoch_ms` INTEGER, `silence_forever` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, `pin_count` INTEGER NOT NULL DEFAULT 0, `pin_version` INTEGER NOT NULL DEFAULT 0, `latest_pin` BLOB, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canInvite", + "columnName": "can_invite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canSilence", + "columnName": "can_silence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canBan", + "columnName": "can_ban", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canPin", + "columnName": "can_pin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "silenceUntilEpochMs", + "columnName": "silence_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "silenceForever", + "columnName": "silence_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "pinCount", + "columnName": "pin_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "pinVersion", + "columnName": "pin_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "latestPin", + "columnName": "latest_pin", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + }, + { + "tableName": "payment_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `version` INTEGER NOT NULL, `status` TEXT NOT NULL, `ends_at` INTEGER, `created_at` INTEGER NOT NULL, `request` BLOB NOT NULL, PRIMARY KEY(`id_hex`))", + "fields": [ + { + "fieldPath": "idHex", + "columnName": "id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endsAtEpochMs", + "columnName": "ends_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtEpochMs", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "request", + "columnName": "request", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id_hex" + ] + }, + "indices": [ + { + "name": "index_payment_requests_chat_id_hex", + "unique": false, + "columnNames": [ + "chat_id_hex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_payment_requests_chat_id_hex` ON `${TABLE_NAME}` (`chat_id_hex`)" + } + ] + }, + { + "tableName": "group_key_envelopes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `own_pk_hex` TEXT NOT NULL, `scheme` INTEGER NOT NULL, `nonce` BLOB NOT NULL, `ciphertext` BLOB NOT NULL, `wrapped_by` BLOB, `wrapped_by_public_key` BLOB, PRIMARY KEY(`chat_id_hex`, `own_pk_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownPkHex", + "columnName": "own_pk_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheme", + "columnName": "scheme", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ciphertext", + "columnName": "ciphertext", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "wrappedBy", + "columnName": "wrapped_by", + "affinity": "BLOB" + }, + { + "fieldPath": "wrappedByPublicKey", + "columnName": "wrapped_by_public_key", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "own_pk_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8d61a035bffcc3bafa80fd4ef21d035a')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index f19dce96a6..d71a84f82a 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -144,8 +144,9 @@ import kotlin.jvm.Synchronized // The group_key_envelopes table: wrapped chat keys, so a private group opens offline. An // AutoMigration for the same reason as payment_requests. AutoMigration(from = 49, to = 50), // group_key_envelopes table + AutoMigration(from = 50, to = 51, spec = FlipcashDatabase.Migration50To51::class), // chat_metadata.can_pin (default 0) ], - version = 50, + version = 51, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) @ConstructedBy(FlipcashDatabaseConstructor::class) @@ -327,6 +328,17 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Re-arms the viewer-state gate for the new `can_pin` column, for the reason [Migration36To37] + * gives for `can_edit`: without it Pin stays missing from the message menu on every chat stored + * before the upgrade until its viewer state version moves. + */ + class Migration50To51 : AutoMigrationSpec { + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL(Migration36To37.REARM_VIEWER_STATE) + } + } + /** * Adds `chat_messages.ciphertext_json` and `encryption_state`. A row stored before this version * with encrypted content was never opened, so it's marked for opening with its ciphertext diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index a3d86db4bc..7312a483ca 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -165,7 +165,7 @@ interface ChatMetadataDao { @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = :muteUntilEpochMs, " + "mute_forever = :muteForever, viewer_state_version = :version, " + - "can_edit = :canEdit, can_invite = :canInvite, can_silence = :canSilence, can_ban = :canBan, " + + "can_edit = :canEdit, can_invite = :canInvite, can_silence = :canSilence, can_ban = :canBan, can_pin = :canPin, " + "silence_until_epoch_ms = :silenceUntilEpochMs, silence_forever = :silenceForever " + "WHERE chat_id_hex = :chatIdHex AND (:version > viewer_state_version OR viewer_state_version = 0)" ) @@ -180,6 +180,7 @@ interface ChatMetadataDao { canBan: Boolean = false, silenceUntilEpochMs: Long? = null, silenceForever: Boolean = false, + canPin: Boolean = false, ) /** @@ -206,7 +207,7 @@ interface ChatMetadataDao { */ @Query( "UPDATE chat_metadata SET mute_until_epoch_ms = NULL, mute_forever = 0, " + - "viewer_state_version = 0, can_edit = 0, can_invite = 0, can_silence = 0, can_ban = 0, " + + "viewer_state_version = 0, can_edit = 0, can_invite = 0, can_silence = 0, can_ban = 0, can_pin = 0, " + "silence_until_epoch_ms = NULL, silence_forever = 0 WHERE chat_id_hex = :chatIdHex" ) suspend fun clearViewerState(chatIdHex: String) @@ -281,6 +282,7 @@ interface ChatMetadataDao { canBan = entity.canBan, silenceUntilEpochMs = entity.silenceUntilEpochMs, silenceForever = entity.silenceForever, + canPin = entity.canPin, ) mergePinSummary( chatIdHex = entity.chatIdHex, diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index cd15f4d4a2..04b67b1fb1 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -74,6 +74,9 @@ data class ChatMetadataEntity( val canSilence: Boolean = false, @ColumnInfo(name = "can_ban", defaultValue = "0") val canBan: Boolean = false, + // Whether the viewer may pin and unpin messages. Same storage rules as can_edit. + @ColumnInfo(name = "can_pin", defaultValue = "0") + val canPin: Boolean = false, // A moderator's silence on the viewer. Stored the way a mute is, as a deadline or a forever // flag and never both, because a timed silence lapses with no server signal and only the // deadline compared against the clock on read stays correct. diff --git a/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 366ce64668..9e4df3e034 100644 --- a/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/androidHostTest/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -642,6 +642,20 @@ class ChatEntityMapperTest { assertEquals(true, moderation?.canBan) } + @Test + fun `the pin grant survives the round trip`() { + val entity = mapper.toEntity( + groupMetadata().copy( + viewerState = ViewerState(version = 0, permissions = ViewerState.Permissions(canPin = true)) + ) + ) + assertEquals(true, entity.canPin) + + val restored = mapper.toMetadata(entity, emptyList(), null) + + assertEquals(true, restored.viewerState?.permissions?.canPin) + } + @Test fun `a timed silence survives the round trip as its deadline`() { val until = Instant.fromEpochMilliseconds(9_000) diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 2c0aaa2125..aeb2b2d1c3 100644 --- a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -138,6 +138,7 @@ class ChatMetadataDataSource @Inject constructor( canBan = viewerState.permissions.moderation.canBan, silenceUntilEpochMs = mapper.silenceUntilEpochMs(viewerState.restrictions?.silence), silenceForever = mapper.isSilenceForever(viewerState.restrictions?.silence), + canPin = viewerState.permissions.canPin, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index f414dd9ca9..36f6e829df 100644 --- a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -104,6 +104,7 @@ class ChatEntityMapper @Inject constructor() { canInvite = metadata.viewerState?.permissions?.canInvite ?: false, canSilence = metadata.viewerState?.permissions?.moderation?.canSilence ?: false, canBan = metadata.viewerState?.permissions?.moderation?.canBan ?: false, + canPin = metadata.viewerState?.permissions?.canPin ?: false, silenceUntilEpochMs = silenceUntilEpochMs(metadata.viewerState?.restrictions?.silence), silenceForever = isSilenceForever(metadata.viewerState?.restrictions?.silence), creatorHex = metadata.creator?.hexEncodedString(), @@ -195,7 +196,7 @@ class ChatEntityMapper @Inject constructor() { silenceUntil != null -> SilenceState.Until(Instant.fromEpochMilliseconds(silenceUntil)) else -> null } - if (mute == null && silence == null && viewerStateVersion == 0L && !canEdit && !canInvite && !canSilence && !canBan) { + if (mute == null && silence == null && viewerStateVersion == 0L && !canEdit && !canInvite && !canSilence && !canBan && !canPin) { return null } return ViewerState( @@ -204,6 +205,7 @@ class ChatEntityMapper @Inject constructor() { permissions = ViewerState.Permissions( canEdit = canEdit, canInvite = canInvite, + canPin = canPin, moderation = ViewerState.ModerationPermissions(canSilence = canSilence, canBan = canBan), ), restrictions = silence?.let { ViewerState.Restrictions(silence = it) }, diff --git a/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt index ba5feae264..bb64081428 100644 --- a/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/androidMain/kotlin/com/getcode/ui/components/TitleBar.kt @@ -61,6 +61,13 @@ object AppBarDefaults { internal val BarHeight = 56.dp internal val SlotPadding = 5.dp + /** + * How far the bar's leading and trailing controls sit from the screen edge, for content under + * the bar that should line up with them. + */ + val ControlInset: Dp + @Composable get() = CodeTheme.dimens.grid.x2 + SlotPadding + /** * How far a leading control's touch target can grow past it, for a start-aligned title: to the bar's * leading edge, top and bottom, and across the gap up to where the title starts. For use with