diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 7047aac35..a1c88c455 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(project(":apps:flipcash:shared:menu")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":apps:flipcash:shared:userflags")) implementation(project(":libs:vibrator:bindings")) implementation(project(":libs:messaging")) implementation(project(":services:flipcash")) 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 499f6aeaa..df9e0ee70 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 @@ -19,6 +19,8 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.extensions.setText import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.shared.chat.MessageCapability +import com.flipcash.shared.chat.MessagePolicy +import com.flipcash.shared.chat.withinWindows import com.flipcash.shared.chat.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem @@ -26,6 +28,7 @@ import com.flipcash.shared.chat.models.ReceiptStatus import com.flipcash.shared.chat.models.SeparatorConfig import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile @@ -111,6 +114,7 @@ internal class ChatViewModel @Inject constructor( private val resources: ResourceHelper, private val analytics: FlipcashAnalyticsService, private val clipboardManager: ClipboardManager, + private val userFlags: UserFlagsCoordinator, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -170,6 +174,14 @@ internal class ChatViewModel @Inject constructor( * closes, rather than sitting sharp and half-clipped at the sheet's own edge. */ val confirmingDelete: Boolean = false, + /** + * The edit and delete windows the server publishes through `UserFlags`. + * + * Held in state rather than read straight off the coordinator because the reducer needs + * it: a selection has to be narrowed to what is still open at the moment it is made, and + * the reducer is where the selection is set. + */ + val messagePolicy: MessagePolicy = MessagePolicy.Default, ) { // Opening the participant's profile (the entry point to blocking) is only available for tip DMs. val canViewProfile: Boolean @@ -235,6 +247,7 @@ internal class ChatViewModel @Inject constructor( data class LimitsChanged(val limits: Limits?) : Event data class AdvanceReadPointer(val messageId: Long) : Event data class ChatDeactivated(val isReadOnly: Boolean) : Event + data class MessagePolicyChanged(val policy: MessagePolicy) : Event /** Selects [bubble], or leaves selection mode if it is already the selected one. */ data class ToggleMessageSelection(val bubble: ChatListItem.ContentBubble) : Event @@ -272,9 +285,15 @@ internal class ChatViewModel @Inject constructor( .flatMapLatest { chatCoordinator.observeOtherReadPointer(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + /** + * Re-runs the transcript mapping when the windows change, so a message resolved under the + * defaults (both open, before the flags arrive) narrows as soon as the server's answer lands. + */ + private val messagePolicy = stateFlow.map { it.messagePolicy }.distinctUntilChanged() + @OptIn(ExperimentalCoroutinesApi::class) val messages: Flow> = - combine(messageStream, pendingMutations) { pagingData, mutations -> + combine(messageStream, pendingMutations, messagePolicy) { pagingData, mutations, policy -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.mapIndexed { index, content -> @@ -308,7 +327,7 @@ internal class ChatViewModel @Inject constructor( // Resolved once, here, so no menu re-derives it: a later group-role // taxonomy becomes another input to the resolver rather than a branch at // each action site. - capabilities = resolveCapabilities(message), + capabilities = resolveCapabilities(message, policy), ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -439,6 +458,19 @@ internal class ChatViewModel @Inject constructor( } private fun initChatHandlers() { + // Ahead of the transcript, so the first mapping already has the real windows rather than + // the defaults, which leave both edit and delete open. + userFlags.resolvedFlags + .map { + MessagePolicy( + editWindow = it.messageEditWindow.effectiveValue, + deleteWindow = it.messageDeleteWindow.effectiveValue, + ) + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.MessagePolicyChanged(it)) } + .launchIn(viewModelScope) + // Unified chat open handler — resolves chatId and contact from the identifier eventFlow .filterIsInstance() @@ -1168,10 +1200,20 @@ internal class ChatViewModel @Inject constructor( is Event.LimitsChanged -> { state -> state.copy(limits = event.limits) } is Event.AdvanceReadPointer -> { state -> state } is Event.ChatDeactivated -> { state -> state.copy(isAnonymous = event.isReadOnly) } + is Event.MessagePolicyChanged -> { state -> state.copy(messagePolicy = event.policy) } is Event.ToggleMessageSelection -> { state -> val alreadySelected = state.selection?.itemKey == event.bubble.itemKey + // The transcript resolved this bubble when it was mapped, which may have been + // well inside a window that has since closed. Narrow it again here so the bar + // offers what is open now rather than what was open when the row was built. + val selected = event.bubble.takeUnless { alreadySelected }?.let { bubble -> + bubble.copy( + capabilities = bubble.capabilities + .withinWindows(bubble.timestamp, state.messagePolicy), + ) + } state.copy( - selection = event.bubble.takeUnless { alreadySelected }, + selection = selected, confirmingDelete = false, ) } diff --git a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt index 76e8c21a3..70ee49587 100644 --- a/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt +++ b/apps/flipcash/features/userflags/src/main/kotlin/com/flipcash/app/userflags/internal/UserFlagsViewModel.kt @@ -188,4 +188,6 @@ private fun ResolvedUserFlags.editableEntries(): List> = listOf EditableEntry(Field.PreferredUsdcOnRampLiquidityPool, usdcOnRampLiquidityPool), EditableEntry(Field.MinimumHolderAmountForLeaderboard, minimumHolderAmountForLeaderboard), EditableEntry(Field.RequireCoinbaseEmailVerification, requireCoinbaseEmailVerification), + EditableEntry(Field.MessageEditWindow, messageEditWindow), + EditableEntry(Field.MessageDeleteWindow, messageDeleteWindow), ) \ No newline at end of file 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 001741127..8265cd96f 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 @@ -29,12 +29,17 @@ enum class MessageCapability { /** * Client-side limits on what may be done to a message. * - * @param editWindow how long after sending a message stays editable, or `null` for no limit. The - * server does not publish a window today, so the default leaves edit open and lets `CANNOT_EDIT` - * be the authority. + * Both windows come from `UserFlags` (`message_edit_window`, `message_delete_window`), which sends + * them with explicit presence: an unset field is no limit rather than a zero-length one. The + * defaults are therefore both `null`, which leaves `CANNOT_EDIT` / `CANNOT_DELETE` as the + * authority for a build that has not seen the flags yet. + * + * @param editWindow how long after sending a message stays editable, or `null` for no limit. + * @param deleteWindow how long after sending a message stays deletable, or `null` for no limit. */ data class MessagePolicy( val editWindow: Duration? = null, + val deleteWindow: Duration? = null, ) { companion object { val Default = MessagePolicy() @@ -46,8 +51,9 @@ data class MessagePolicy( * * | Message | Capabilities | * |---|---| - * | Own text, confirmed, within the edit window | Copy, Reply, Edit, Delete | - * | Own text, confirmed, outside a configured window | Copy, Reply, Delete | + * | Own text, confirmed, inside both windows | Copy, Reply, Edit, Delete | + * | Own text, confirmed, past the edit window | Copy, Reply, Delete | + * | Own text, confirmed, past both windows | Copy, Reply | * | Own text, unconfirmed (`eventSequence == 0`) | none | * | Another participant's text | Copy, Reply | * | Any cash or tip message | Reply | @@ -85,14 +91,33 @@ fun resolveCapabilities( if (hasText) add(MessageCapability.Copy) add(MessageCapability.Reply) if (message.isFromSelf) { - if (hasText && policy.allowsEdit(message, now)) add(MessageCapability.Edit) + if (hasText) add(MessageCapability.Edit) add(MessageCapability.Delete) } - } + }.withinWindows(message.timestamp, policy, now) } -/** True while [message] is still inside the configured edit window, or always if there is none. */ -private fun MessagePolicy.allowsEdit(message: ChatMessage, now: Instant): Boolean { - val window = editWindow ?: return true - return now - message.timestamp <= window +/** + * Drops the capabilities of a message sent at [sentAt] whose window has since closed. + * + * Split out of [resolveCapabilities] because resolution happens once, when the transcript is + * mapped, and the windows keep running afterwards: a menu opened a minute later would otherwise + * still offer an edit the server is about to answer `CANNOT_EDIT`. A surface holding an + * already-resolved set re-applies this when it acts on it, and gets the same answer the resolver + * would give — the rule lives in one place either way. + */ +fun Set.withinWindows( + sentAt: Instant, + policy: MessagePolicy, + now: Instant = Clock.System.now(), +): Set = filterTo(mutableSetOf()) { capability -> + when (capability) { + MessageCapability.Edit -> policy.editWindow.stillOpen(sentAt, now) + MessageCapability.Delete -> policy.deleteWindow.stillOpen(sentAt, now) + MessageCapability.Copy, MessageCapability.Reply -> true + } } + +/** True while a message sent at [sentAt] is inside this window, or always if there is none. */ +private fun Duration?.stillOpen(sentAt: Instant, now: Instant): Boolean = + this == null || now - sentAt <= this 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 08d029064..1cc790b0e 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 @@ -8,6 +8,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant @@ -138,4 +139,72 @@ class MessageCapabilityTest { resolveCapabilities(text(), policy, now = sentAt + 16.minutes), ) } + + @Test + fun `a delete window drops Delete once it lapses and leaves Edit alone`() { + val policy = MessagePolicy(deleteWindow = 60.minutes) + + assertEquals( + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + resolveCapabilities(text(), policy, now = sentAt + 59.minutes), + ) + + // Edit survives: this policy sets no edit window, and an unset window is no limit. + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Edit), + resolveCapabilities(text(), policy, now = sentAt + 61.minutes), + ) + } + + @Test + fun `the windows run independently, so the shorter one lapses first`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 60.minutes) + + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + resolveCapabilities(text(), policy, now = sentAt + 30.minutes), + ) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + resolveCapabilities(text(), policy, now = sentAt + 90.minutes), + ) + } + + @Test + fun `an unset window leaves its capability open`() { + assertEquals( + setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ), + resolveCapabilities(text(), MessagePolicy.Default, now = sentAt + 365.days), + ) + } + + /** + * The transcript resolves once, when it is mapped; the menu re-applies the windows when it + * opens. Both go through the same rule, so a set narrowed after the fact matches what the + * resolver would have returned at that instant. + */ + @Test + fun `re-applying the windows to a resolved set matches resolving at that instant`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 60.minutes) + val atSend = resolveCapabilities(text(), policy, now = sentAt) + + assertEquals( + resolveCapabilities(text(), policy, now = sentAt + 30.minutes), + atSend.withinWindows(sentAt, policy, now = sentAt + 30.minutes), + ) + assertEquals( + resolveCapabilities(text(), policy, now = sentAt + 90.minutes), + atSend.withinWindows(sentAt, policy, now = sentAt + 90.minutes), + ) + } } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 6238920d9..0a36d103b 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -103,11 +103,18 @@ interface ChatMessageDao { @Query("DELETE FROM chat_messages WHERE chat_id_hex = :chatIdHex AND status = 'SENDING'") suspend fun deleteAllPending(chatIdHex: String) + /** + * The optimistic row is written before the server has stamped the message, so every + * server-assigned field is written here — `event_sequence` included. Leaving it at the pending + * row's 0 would keep a sent message looking unacknowledged until some later fetch of the chat + * overwrote the row, which is what the edit/delete guards and last-writer-wins read it for. + */ @Query(""" UPDATE chat_messages SET message_id = :newMessageId, timestamp_epoch_ms = :newTimestampMs, unread_seq = :newUnreadSeq, + event_sequence = :newEventSequence, status = 'SENT' WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex """) @@ -117,6 +124,7 @@ interface ChatMessageDao { newMessageId: Long, newTimestampMs: Long, newUnreadSeq: Long, + newEventSequence: Long, ) @Transaction @@ -127,6 +135,7 @@ interface ChatMessageDao { newMessageId = serverMessage.messageId, newTimestampMs = serverMessage.timestampEpochMs, newUnreadSeq = serverMessage.unreadSeq, + newEventSequence = serverMessage.eventSequence, ) } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 20ac4e4fe..601b4ca95 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -49,6 +50,17 @@ class ChatMessageDaoTest { unreadSeq = messageId, ) + private fun pending(body: String) = ChatMessageEntity( + chatIdHex = CHAT_HEX, + messageId = -1, + senderIdHex = SENDER_HEX, + contentJson = listOf(MessageContentSerialized.Text(body)), + timestampEpochMs = 1, + unreadSeq = 0, + status = MessageStatus.SENDING, + pendingClientIdHex = CLIENT_HEX, + ) + private fun tombstone(messageId: Long) = text(messageId, "gone").copy( contentJson = listOf(MessageContentSerialized.Deleted(deletedAt = 1, deletedBy = SENDER_HEX)), isDeleted = true, @@ -103,9 +115,32 @@ class ChatMessageDaoTest { assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId) } + /** + * The optimistic row is written with no event sequence, because the client has none to write: + * the server stamps it. Confirming has to carry the echo's stamp onto the row, or the message + * stays at sequence 0 until something else refetches the chat — and everything keyed on the + * stamp (edit, delete, last-writer-wins) treats it as unacknowledged in the meantime. + */ + @Test + fun `confirming a pending message carries the server's event sequence`() = runTest { + dao.upsert(pending("hello")) + + dao.confirmPendingMessage( + CHAT_HEX, + CLIENT_HEX, + text(7, "hello").copy(eventSequence = 42, unreadSeq = 3), + ) + + val stored = dao.getMessage(CHAT_HEX, 7)!! + assertEquals(42L, stored.eventSequence) + assertEquals(MessageStatus.SENT, stored.status) + assertEquals(3L, stored.unreadSeq) + } + private companion object { const val CHAT_HEX = "aabb" const val OTHER_HEX = "ccdd" const val SENDER_HEX = "1122" + const val CLIENT_HEX = "eeff" } } diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt index e22258694..833a8bf16 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/Field.kt @@ -100,6 +100,45 @@ sealed class Field( }, ) + /** + * Both message windows are entered in seconds, like the bill timeout above, because the values + * worth testing are short: the point of overriding one is to watch a menu narrow while the + * transcript is open, not to reproduce the server's hour. + */ + data object MessageEditWindow : Field( + longPreferencesKey("override_message_edit_window"), + encode = { it?.inWholeMilliseconds ?: -1L }, + decode = { if (it < 0) null else it.milliseconds }, + label = R.string.label_flag_messageEditWindow, + hint = R.string.hint_flag_messageEditWindow, + format = { it?.let { "${it.inWholeSeconds}s" } ?: "None" }, + editFormat = { it?.inWholeSeconds?.toString() ?: "" }, + editor = FieldEditor.TextInput( + keyboard = KeyboardType.Number, + parse = { it.toLongOrNull()?.let { secs -> (secs * 1000).milliseconds } }, + ), + inputTransformation = InputTransformation { + if (!asCharSequence().all { it.isDigit() }) revertAllChanges() + }, + ) + + data object MessageDeleteWindow : Field( + longPreferencesKey("override_message_delete_window"), + encode = { it?.inWholeMilliseconds ?: -1L }, + decode = { if (it < 0) null else it.milliseconds }, + label = R.string.label_flag_messageDeleteWindow, + hint = R.string.hint_flag_messageDeleteWindow, + format = { it?.let { "${it.inWholeSeconds}s" } ?: "None" }, + editFormat = { it?.inWholeSeconds?.toString() ?: "" }, + editor = FieldEditor.TextInput( + keyboard = KeyboardType.Number, + parse = { it.toLongOrNull()?.let { secs -> (secs * 1000).milliseconds } }, + ), + inputTransformation = InputTransformation { + if (!asCharSequence().all { it.isDigit() }) revertAllChanges() + }, + ) + data object NewCurrencyPurchaseAmount : Field( longPreferencesKey("override_new_currency_amount"), encode = { it.quarks }, diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt index a56be7070..06145270b 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/ResolvedUserFlags.kt @@ -58,7 +58,6 @@ internal fun UserFlags.resolve(overrides: Overrides): ResolvedUserFlags = Resolv requireCoinbaseEmailVerification = ResolvedFlag(requireCoinbaseEmailVerification, overrides.requireCoinbaseEmailVerification), tipPresets = ResolvedFlag(tipPresets, FieldOverride.None), usernameMinBalance = ResolvedFlag(usernameMinBalance, FieldOverride.None), - // Read-only for now — no debug override support until the edit/delete UI lands. - messageEditWindow = ResolvedFlag(messageEditWindow, FieldOverride.None), - messageDeleteWindow = ResolvedFlag(messageDeleteWindow, FieldOverride.None), + messageEditWindow = ResolvedFlag(messageEditWindow, overrides.messageEditWindow), + messageDeleteWindow = ResolvedFlag(messageDeleteWindow, overrides.messageDeleteWindow), ) \ No newline at end of file diff --git a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt index 395b25986..04a5c91e0 100644 --- a/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt +++ b/apps/flipcash/shared/userflags/src/main/kotlin/com/flipcash/app/userflags/UserFlagsCoordinator.kt @@ -53,6 +53,8 @@ class UserFlagsCoordinator @Inject constructor( val preferredUsdcOnRampLiquidityPool: FieldOverride, val minimumHolderAmountForLeaderboard: FieldOverride, val requireCoinbaseEmailVerification: FieldOverride, + val messageEditWindow: FieldOverride, + val messageDeleteWindow: FieldOverride, ) { companion object { val None = Overrides( @@ -66,6 +68,8 @@ class UserFlagsCoordinator @Inject constructor( preferredUsdcOnRampLiquidityPool = FieldOverride.None, minimumHolderAmountForLeaderboard = FieldOverride.None, requireCoinbaseEmailVerification = FieldOverride.None, + messageEditWindow = FieldOverride.None, + messageDeleteWindow = FieldOverride.None, ) } } @@ -110,6 +114,8 @@ class UserFlagsCoordinator @Inject constructor( preferredUsdcOnRampLiquidityPool = prefs.readOverride(Field.PreferredUsdcOnRampLiquidityPool), minimumHolderAmountForLeaderboard = prefs.readOverride(Field.MinimumHolderAmountForLeaderboard), requireCoinbaseEmailVerification = prefs.readOverride(Field.RequireCoinbaseEmailVerification), + messageEditWindow = prefs.readOverride(Field.MessageEditWindow), + messageDeleteWindow = prefs.readOverride(Field.MessageDeleteWindow), ) }.stateIn(scope, SharingStarted.Eagerly, Overrides.None) diff --git a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml index d1966630a..6cb59d453 100644 --- a/apps/flipcash/shared/userflags/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/userflags/src/main/res/values/strings.xml @@ -23,4 +23,8 @@ Enter amount Tip Presets Username Minimum Balance + Message Edit Window + Enter time in seconds + Message Delete Window + Enter time in seconds \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index 0ceb1c435..979d5001b 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material.Icon import androidx.compose.material.LocalContentColor @@ -157,8 +158,13 @@ fun BottomBarContainer( } AnimatedContent( + // The bar rides the keyboard. Every screen is drawn edge to edge, so the window is not + // resized when the IME opens and a bottom-aligned bar would otherwise slide up underneath + // it — a chat error, raised while the composer still has focus, arrived invisible. The + // scrim above deliberately keeps covering the whole window, keyboard included. modifier = Modifier .fillMaxSize() + .imePadding() .clipToBounds(), targetState = bottomBarVisibleState.targetState, transitionSpec = {