diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 92f37fa94..648ae0b13 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -912,9 +912,13 @@ Delete message? This can\'t be undone Message Not Edited - Your change couldn\'t be saved, so the message is unchanged. + Your change couldn\'t be saved, so the message is unchanged Message Not Deleted - The message couldn\'t be deleted, so it is still visible to everyone. + The message couldn\'t be deleted, so it is still visible to everyone + Couldn\'t Edit Message + Messages can only be edited for a short time after they\'re sent + Couldn\'t Delete Message + Messages can only be deleted for a short time after they\'re sent Message Unknown Contact 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..15d72ef46 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,7 @@ 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.applying import com.flipcash.shared.chat.resolveCapabilities import com.flipcash.shared.chat.models.ChatListItem @@ -26,7 +27,10 @@ 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.DeleteMessageError +import com.flipcash.services.models.EditMessageError import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId @@ -71,6 +75,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -82,6 +87,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.math.min +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -108,6 +114,7 @@ internal class ChatViewModel @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, private val purchaseMethodController: PurchaseMethodController, private val userManager: UserManager, + userFlags: UserFlagsCoordinator, private val resources: ResourceHelper, private val analytics: FlipcashAnalyticsService, private val clipboardManager: ClipboardManager, @@ -272,9 +279,62 @@ internal class ChatViewModel @Inject constructor( .flatMapLatest { chatCoordinator.observeOtherReadPointer(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + /** + * The edit/delete windows the transcript gates on. + * + * `resolvedFlags` is a `StateFlow` seeded with `UserFlags.Default`, and falls back to it + * whenever the server flags are absent — a failed fetch, or the window before the first one + * lands. `UserFlags.Default` carries `null` for both windows, so the failed-fetch case arrives + * here as the same `null` an explicitly-unset server field would, and [MessagePolicy.from] + * substitutes the fallback window for both without a second branch. + */ + private val messagePolicy = userFlags.resolvedFlags + .map { + MessagePolicy.from( + editWindow = it.messageEditWindow.effectiveValue, + deleteWindow = it.messageDeleteWindow.effectiveValue, + ) + } + .distinctUntilChanged() + + /** + * Drives re-resolution of capabilities so a row loses Edit and Delete when its window closes. + * + * Capabilities are resolved once per mapping pass, so without this a message resolved at send + * time keeps Edit forever: nothing upstream re-emits when a window merely lapses. With a + * 15-minute default edit window that is an ordinary session, not a corner case — leave a chat + * open, scroll back, and the menu offers an edit the server will reject. + * + * A poll rather than a timer armed at each message's expiry: the transcript is paged, so the + * set of loaded messages (and therefore the next expiry) changes as the user scrolls, and + * tracking that is more machinery than the problem is worth. The cost of the poll is bounded — + * it re-runs the mapping, not the fetch, because [messageStream] is cached above it, and the + * token metadata the mapping enriches with is memory-cached. The cost of the interval is up to + * [CapabilityRefreshInterval] of staleness at each boundary, during which a lapsed row still + * offers its action and the server answers `CANNOT_EDIT` / `CANNOT_DELETE`. That is the same + * race the gating cannot close anyway: a menu resolved a moment before expiry is stale by the + * time it is tapped whatever the interval. + * + * This covers the transcript, not an already-open selection bar — `State.selection` holds the + * bubble captured at long-press, and keeps the capabilities it was captured with. Selection is + * a few seconds of user attention rather than a row parked on screen, so it is left to the + * server error. + */ + private val capabilityClock = flow { + while (true) { + emit(Clock.System.now()) + delay(CapabilityRefreshInterval) + } + } + @OptIn(ExperimentalCoroutinesApi::class) val messages: Flow> = - combine(messageStream, pendingMutations) { pagingData, mutations -> + combine( + messageStream, + pendingMutations, + messagePolicy, + capabilityClock, + ) { pagingData, mutations, policy, now -> pagingData.flatMap { stored -> val message = stored.applying(mutations[stored.messageId]) message.content.mapIndexed { index, content -> @@ -305,10 +365,11 @@ internal class ChatViewModel @Inject constructor( // A null author is a moderation removal, which reads as someone else's. deletedByViewer = (enriched as? MessageContent.Deleted)?.deletedBy ?.let { it == userManager.accountId } == true, - // 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), + // Resolved 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. Re-resolved on every pass rather than once per message, + // because `policy` and `now` both move — see `capabilityClock`. + capabilities = resolveCapabilities(message, policy, now), ) } }.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? -> @@ -726,9 +787,22 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.editMessage(chatId, editing.messageId, text) .onFailure { cause -> trace("failed to edit message - ${cause.localizedMessage}") + // The client gate hides Edit once the window closes, but it cannot + // close the gap between opening the composer inside the window and + // submitting outside it. `CANNOT_EDIT` is the server's answer for + // exactly that race, and it is the one failure here with a cause worth + // naming: nothing went wrong and retrying will not help, which the + // generic "couldn't be saved" copy does not convey. + val expired = cause is EditMessageError.CannotEdit BottomBarManager.showError( - title = resources.getString(R.string.title_messageNotEdited), - message = resources.getString(R.string.description_messageNotEdited), + title = resources.getString( + if (expired) R.string.title_messageEditExpired + else R.string.title_messageNotEdited, + ), + message = resources.getString( + if (expired) R.string.description_messageEditExpired + else R.string.description_messageNotEdited, + ), ) } } @@ -755,9 +829,18 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.deleteMessage(chatId, event.messageId) .onFailure { cause -> trace("failed to delete message - ${cause.localizedMessage}") + // Same race as the edit path: the sheet can be sitting open + // when the delete window closes. `CANNOT_DELETE` names it. + val expired = cause is DeleteMessageError.CannotDelete BottomBarManager.showError( - title = resources.getString(R.string.title_messageNotDeleted), - message = resources.getString(R.string.description_messageNotDeleted), + title = resources.getString( + if (expired) R.string.title_messageDeleteExpired + else R.string.title_messageNotDeleted, + ), + message = resources.getString( + if (expired) R.string.description_messageDeleteExpired + else R.string.description_messageNotDeleted, + ), ) } } @@ -1082,6 +1165,15 @@ internal class ChatViewModel @Inject constructor( } companion object { + /** + * How often the transcript re-resolves capabilities so lapsed windows drop their actions. + * + * Coarse on purpose. It bounds how long a lapsed row keeps offering an action, and the + * shortest window it has to bound is the 15-minute edit default, so seconds of slack cost + * nothing a user can act on faster than the server can answer. + */ + private val CapabilityRefreshInterval = 30.seconds + val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { is Event.OnChatOpened -> { state -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index d9647319d..dbdaac831 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -200,16 +200,23 @@ internal fun UserControlBottomBar( state = state.chatInputState, // One read of the edit state decides both the glyph and what the tap // does, so the composer cannot show a checkmark and send a new message. + // + // Confirming ends the edit, so the composer stops being an open field: + // hiding the keyboard clears focus too, so it does not come straight + // back onto the draft `finishEditing` restores into the same input. submit = if (state.editing != null) { - ChatInputSubmit.ConfirmEdit { - dispatch(ChatViewModel.Event.SubmitEdit) - keyboard.restartInput() - } + ChatInputSubmit( + mode = ChatInputSubmit.Mode.ConfirmEdit, + perform = { keyboard.hideIfVisible { dispatch(ChatViewModel.Event.SubmitEdit) } }, + ) } else { - ChatInputSubmit.Send { - dispatch(ChatViewModel.Event.SendMessage) - keyboard.restartInput() - } + ChatInputSubmit( + mode = ChatInputSubmit.Mode.Send, + perform = { + dispatch(ChatViewModel.Event.SendMessage) + keyboard.restartInput() + }, + ) }, ) 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..0510bffe2 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 @@ -4,6 +4,8 @@ import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessageContent import kotlin.time.Clock import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes import kotlin.time.Instant /** @@ -29,15 +31,57 @@ 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. + * The server publishes both windows in `account.v1.UserFlags` with explicit presence, so an unset + * field reaches the client as `null` meaning "the server said nothing" — not "no limit". [from] + * turns that silence into [FallbackEditWindow] / [FallbackDeleteWindow] rather than leaving the + * action open. The same substitution covers a failed flags fetch without a second code path: + * `UserFlagsCoordinator.resolvedFlags` falls back to `UserFlags.Default` whenever the server flags + * are absent, and `UserFlags.Default` carries `null` for both windows. + * + * This inverts the rule this class shipped with, which left edit open and made the server's + * `CANNOT_EDIT` the sole authority. What that bought was never offering less than the server would + * allow; what it cost was a menu that confidently offers Edit on a week-old message and fails on + * tap. The fallback trades one for the other: with it in force a server that sends nothing has the + * client's window imposed on it, so an edit the server would have accepted 20 minutes after sending + * is hidden at 15. That is the cheaper failure — a missing row is legible, a row that errors on tap + * is not — and the inversion is only in what the client *offers*. The server remains the authority + * for everything the client does offer: `CANNOT_EDIT` and `CANNOT_DELETE` still backstop the gap + * between resolving a menu and the request landing. + * + * A `null` window still means no limit, and is now reachable only by naming it — [from] never + * produces one, and both defaults are windows rather than `null`, so a call site that forgets to + * pass a policy is gated rather than unbounded. + * + * @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 editWindow: Duration? = FallbackEditWindow, + val deleteWindow: Duration? = FallbackDeleteWindow, ) { companion object { + /** + * Applied when `message_edit_window` is unset or the flags fetch failed. + * + * Named rather than inlined so the value is greppable from both platforms: iOS applies the + * same 15 minutes, and the two have to agree for the same message to resolve the same + * capability set. + */ + val FallbackEditWindow: Duration = 15.minutes + + /** Applied when `message_delete_window` is unset or the flags fetch failed. iOS: 48 hours. */ + val FallbackDeleteWindow: Duration = 48.hours + val Default = MessagePolicy() + + /** + * Builds a policy from the server's windows, substituting the fallback for either one the + * server left unset. + */ + fun from(editWindow: Duration?, deleteWindow: Duration?) = MessagePolicy( + editWindow = editWindow ?: FallbackEditWindow, + deleteWindow = deleteWindow ?: FallbackDeleteWindow, + ) } } @@ -46,12 +90,16 @@ 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, within 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 | * | A tombstone | none | + * + * [now] is a parameter rather than read inline so a caller can re-resolve a message it is already + * showing: the set is a function of the clock, and a row that resolved Edit does not keep it. */ fun resolveCapabilities( message: ChatMessage, @@ -86,13 +134,24 @@ fun resolveCapabilities( add(MessageCapability.Reply) if (message.isFromSelf) { if (hasText && policy.allowsEdit(message, now)) add(MessageCapability.Edit) - add(MessageCapability.Delete) + if (policy.allowsDelete(message, now)) add(MessageCapability.Delete) } } } /** 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 +private fun MessagePolicy.allowsEdit(message: ChatMessage, now: Instant): Boolean = + editWindow.admits(message, now) + +/** True while [message] is still inside the configured delete window, or always if there is none. */ +private fun MessagePolicy.allowsDelete(message: ChatMessage, now: Instant): Boolean = + deleteWindow.admits(message, now) + +/** + * Both windows are measured from the send time, not from the last edit, and both are inclusive: + * a message at exactly the window length is still actionable. iOS matches on both counts. + */ +private fun Duration?.admits(message: ChatMessage, now: Instant): Boolean { + val window = this ?: return true return now - message.timestamp <= window } 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..6a8fba8f7 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,7 +8,10 @@ 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.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant /** @@ -24,6 +27,13 @@ class MessageCapabilityTest { private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") private val sentAt = Instant.fromEpochSeconds(1_000) + private val everything = setOf( + MessageCapability.Copy, + MessageCapability.Reply, + MessageCapability.Edit, + MessageCapability.Delete, + ) + private fun message( content: List, isFromSelf: Boolean = true, @@ -52,55 +62,58 @@ class MessageCapabilityTest { isFromSelf, ) + /** + * Resolves at send time by default. Every window is open at that instant, so the table tests + * below assert content and authorship without the clock entering into it; the window tests + * pass their own [now]. + */ + private fun capabilities( + message: ChatMessage, + policy: MessagePolicy = MessagePolicy.Default, + now: Instant = sentAt, + ) = resolveCapabilities(message, policy, now) + @Test fun `own text message is copyable, editable and deletable`() { - assertEquals( - setOf( - MessageCapability.Copy, - MessageCapability.Reply, - MessageCapability.Edit, - MessageCapability.Delete, - ), - resolveCapabilities(text()), - ) + assertEquals(everything, capabilities(text())) } @Test fun `someone else's text message is copyable but not editable or deletable`() { assertEquals( setOf(MessageCapability.Copy, MessageCapability.Reply), - resolveCapabilities(text(isFromSelf = false)), + capabilities(text(isFromSelf = false)), ) } @Test fun `cash is never editable or deletable, by either party`() { - assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash())) - assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash(isFromSelf = false))) + assertEquals(setOf(MessageCapability.Reply), capabilities(cash())) + assertEquals(setOf(MessageCapability.Reply), capabilities(cash(isFromSelf = false))) } @Test fun `a tombstone offers nothing`() { val deleted = message(listOf(MessageContent.Deleted(sentAt, selfId))) - assertEquals(emptySet(), resolveCapabilities(deleted)) + assertEquals(emptySet(), capabilities(deleted)) } @Test fun `an unconfirmed message offers nothing`() { // `expected_event_sequence` is validated `>= 1`, so no valid edit or delete request exists // for a message the server has not acknowledged yet. - assertEquals(emptySet(), resolveCapabilities(text(eventSequence = 0))) + assertEquals(emptySet(), capabilities(text(eventSequence = 0))) } @Test fun `system notices are not a participant's message`() { val system = message(listOf(MessageContent.System("Anna joined"))) - assertEquals(emptySet(), resolveCapabilities(system)) + assertEquals(emptySet(), capabilities(system)) } @Test fun `empty content offers nothing`() { - assertEquals(emptySet(), resolveCapabilities(message(emptyList()))) + assertEquals(emptySet(), capabilities(message(emptyList()))) } @Test @@ -108,34 +121,139 @@ class MessageCapabilityTest { val reply = message( listOf(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("hi")))), ) + assertEquals(everything, capabilities(reply)) + } + + // --- Windows --- + + @Test + fun `an edit window drops Edit once it lapses and leaves Delete alone`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = null) + + assertEquals(everything, capabilities(text(), policy, now = sentAt + 14.minutes)) + assertEquals( - setOf( - MessageCapability.Copy, - MessageCapability.Reply, - MessageCapability.Edit, - MessageCapability.Delete, - ), - resolveCapabilities(reply), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + capabilities(text(), policy, now = sentAt + 16.minutes), ) } @Test - fun `an edit window drops Edit once it lapses and leaves Delete alone`() { - val policy = MessagePolicy(editWindow = 15.minutes) + fun `a delete window drops Delete once it lapses`() { + val policy = MessagePolicy(editWindow = null, deleteWindow = 48.hours) + + assertEquals(everything, capabilities(text(), policy, now = sentAt + 47.hours)) assertEquals( - setOf( - MessageCapability.Copy, - MessageCapability.Reply, - MessageCapability.Edit, - MessageCapability.Delete, - ), - resolveCapabilities(text(), policy, now = sentAt + 14.minutes), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Edit), + capabilities(text(), policy, now = sentAt + 49.hours), ) + } + @Test + fun `both windows lapsing leaves only what anyone may do`() { + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 48.hours) + + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), policy, now = sentAt + 49.hours), + ) + } + + @Test + fun `a message at exactly the window length is still actionable`() { + // Inclusive on both windows, and iOS matches. A message resolved at the boundary instant + // keeps its actions; it loses them one tick later. + val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 48.hours) + + assertEquals(everything, capabilities(text(), policy, now = sentAt + 15.minutes)) assertEquals( setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), - resolveCapabilities(text(), policy, now = sentAt + 16.minutes), + capabilities(text(), policy, now = sentAt + 15.minutes + 1.seconds), + ) + + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + capabilities(text(), policy, now = sentAt + 48.hours), + ) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), policy, now = sentAt + 48.hours + 1.seconds), + ) + } + + @Test + fun `a null window means no limit`() { + // Only reachable by naming it. `from` never produces one, so this is the shape a test or a + // deliberate opt-out gets, not anything the server can hand us. + val unlimited = MessagePolicy(editWindow = null, deleteWindow = null) + + assertEquals(everything, capabilities(text(), unlimited, now = sentAt + 365.days)) + } + + @Test + fun `the fallback windows are what iOS applies`() { + // Guards the half of parity that is a value rather than a branch: the two platforms have to + // substitute the same durations for the same message to resolve the same capability set. + assertEquals(15.minutes, MessagePolicy.FallbackEditWindow) + assertEquals(48.hours, MessagePolicy.FallbackDeleteWindow) + } + + @Test + fun `unset server windows fall back rather than leaving the action open`() { + // What `UserFlags.Default` produces: the fetch failed, or the server sent neither field. + // Both arrive as null and both get the client's window, so the flags being absent gates + // the menu instead of opening it. + val fallback = MessagePolicy.from(editWindow = null, deleteWindow = null) + + assertEquals(everything, capabilities(text(), fallback, now = sentAt + 15.minutes)) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + capabilities(text(), fallback, now = sentAt + 15.minutes + 1.seconds), + ) + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), fallback, now = sentAt + 48.hours + 1.seconds), + ) + } + + @Test + fun `a window the server does publish wins over the fallback`() { + val policy = MessagePolicy.from(editWindow = 1.hours, deleteWindow = 2.hours) + + // Past the 15-minute fallback but inside the server's hour. + assertEquals(everything, capabilities(text(), policy, now = sentAt + 30.minutes)) + // Past the server's windows, well short of the 48-hour delete fallback. + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(), policy, now = sentAt + 3.hours), + ) + } + + @Test + fun `an unset window falls back independently of one the server did publish`() { + val policy = MessagePolicy.from(editWindow = 1.hours, deleteWindow = null) + + assertEquals(1.hours, policy.editWindow) + assertEquals(MessagePolicy.FallbackDeleteWindow, policy.deleteWindow) + } + + @Test + fun `the default policy is the fallback policy`() { + // A call site that forgets to pass a policy is gated, not unbounded. + assertEquals(MessagePolicy.FallbackEditWindow, MessagePolicy.Default.editWindow) + assertEquals(MessagePolicy.FallbackDeleteWindow, MessagePolicy.Default.deleteWindow) + } + + @Test + fun `windows never grant a capability the message does not have`() { + val policy = MessagePolicy(editWindow = null, deleteWindow = null) + + // Someone else's message stays theirs however open the windows are, and cash stays cash. + assertEquals( + setOf(MessageCapability.Copy, MessageCapability.Reply), + capabilities(text(isFromSelf = false), policy), ) + assertEquals(setOf(MessageCapability.Reply), capabilities(cash(), policy)) } } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 7a086384a..a669a3f05 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -55,14 +55,17 @@ import com.getcode.ui.components.TextInput * means a caller cannot put up a checkmark that sends a new message, and leaves it reading its own * edit state once rather than once per parameter. */ -sealed interface ChatInputSubmit { - val perform: () -> Unit - - /** Sends the field's text as a new message. */ - data class Send(override val perform: () -> Unit) : ChatInputSubmit +data class ChatInputSubmit( + val mode: Mode, + val perform: () -> Unit, +) { + enum class Mode { + /** Sends the field's text as a new message. */ + Send, - /** Confirms an edit of a message already in the transcript. */ - data class ConfirmEdit(override val perform: () -> Unit) : ChatInputSubmit + /** Confirms an edit of a message already in the transcript. */ + ConfirmEdit, + } } @Composable @@ -146,18 +149,17 @@ fun ChatInput( // mode reads as the same control changing meaning rather than two controls // swapping places. AnimatedContent( - targetState = submit, - // Keyed by kind, not by value: each submit carries a fresh lambda, so - // equality alone would restart the crossfade on every recomposition. - contentKey = { it::class }, + // The mode, not the whole submit: each one carries a fresh lambda, so + // animating on the value would restart the crossfade every recomposition. + targetState = submit.mode, transitionSpec = { (fadeIn(sendSpec) + scaleIn(sendSpec, initialScale = 0.6f)) togetherWith (fadeOut(sendSpec) + scaleOut(sendSpec, targetScale = 0.6f)) }, label = "send glyph", ) { target -> - if (target is ChatInputSubmit.ConfirmEdit) { - Icon( + when (target) { + ChatInputSubmit.Mode.ConfirmEdit -> Icon( modifier = Modifier .testTag("chat_confirm_edit_icon") .size(CodeTheme.dimens.staticGrid.x5), @@ -165,8 +167,8 @@ fun ChatInput( tint = Color.Black, contentDescription = "Confirm edit", ) - } else { - Icon( + + ChatInputSubmit.Mode.Send -> Icon( modifier = Modifier .testTag("chat_send_icon") .size(CodeTheme.dimens.staticGrid.x5), @@ -189,7 +191,7 @@ private fun Preview_ChatInput_Empty() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - submit = ChatInputSubmit.Send {}, + submit = ChatInputSubmit(ChatInputSubmit.Mode.Send) {}, ) } } @@ -202,7 +204,7 @@ private fun Preview_ChatInput_Typing() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - submit = ChatInputSubmit.Send {}, + submit = ChatInputSubmit(ChatInputSubmit.Mode.Send) {}, state = TextFieldState("That’s very kind of you. I ha") ) } @@ -216,7 +218,7 @@ private fun Preview_ChatInput_Editing() { Box(modifier = Modifier.background(Color(0xFF19191A))) { ChatInput( modifier = Modifier.padding(15.dp), - submit = ChatInputSubmit.ConfirmEdit {}, + submit = ChatInputSubmit(ChatInputSubmit.Mode.ConfirmEdit) {}, state = TextFieldState("That’s very kind of you. I have") ) }