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")
)
}