Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -912,9 +912,13 @@
<string name="title_deleteMessage">Delete message?</string>
<string name="description_deleteMessage">This can\'t be undone</string>
<string name="title_messageNotEdited">Message Not Edited</string>
<string name="description_messageNotEdited">Your change couldn\'t be saved, so the message is unchanged.</string>
<string name="description_messageNotEdited">Your change couldn\'t be saved, so the message is unchanged</string>
<string name="title_messageNotDeleted">Message Not Deleted</string>
<string name="description_messageNotDeleted">The message couldn\'t be deleted, so it is still visible to everyone.</string>
<string name="description_messageNotDeleted">The message couldn\'t be deleted, so it is still visible to everyone</string>
<string name="title_messageEditExpired">Couldn\'t Edit Message</string>
<string name="description_messageEditExpired">Messages can only be edited for a short time after they\'re sent</string>
<string name="title_messageDeleteExpired">Couldn\'t Delete Message</string>
<string name="description_messageDeleteExpired">Messages can only be deleted for a short time after they\'re sent</string>
<string name="title_clipboardLabelMessage">Message</string>

<string name="label_unknownContact">Unknown Contact</string>
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ 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
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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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<PagingData<ChatListItem>> =
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 ->
Expand Down Expand Up @@ -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? ->
Expand Down Expand Up @@ -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,
),
)
}
}
Expand All @@ -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,
),
)
}
}
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
)
},
)

Expand Down
Loading
Loading