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