Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
470b60e
test: sync chat-reactions fixtures from flipcash-client-orchestrator#17
bmc08gt Sep 25, 2026
39aff8a
refactor(chat): name the reaction version field version, as the proto…
bmc08gt Sep 25, 2026
9243134
feat(chat): add the reaction reducer, pill order, strip, recents and …
bmc08gt Sep 25, 2026
bf86c42
feat(chat): page reactors and batch-fetch reaction summaries by id
bmc08gt Sep 25, 2026
be071d4
feat(chat): merge reactions by version on message upsert
bmc08gt Sep 25, 2026
3183abd
feat(chat): add reactions-only DAO writes and a JSON-level merge
bmc08gt Sep 25, 2026
0e7d648
feat(chat): route stream reaction updates through the reducer and per…
bmc08gt Sep 25, 2026
d5b5744
feat(chat): add ReactionOperations — toggle, refresh, and page reactors
bmc08gt Sep 25, 2026
f497337
test(chat): cover ReactionsDelegate toggle, rollback, coalescing, and…
bmc08gt Sep 25, 2026
351a297
fix(chat): tombstone reactions a Room merge omits, require strictly n…
bmc08gt Sep 25, 2026
9f90d75
fix(chat): seed ReactionsDelegate.snapshot from Room, not just the ov…
bmc08gt Sep 25, 2026
e6f936c
fix(chat): add MessageReactions.from for messages outside live overlays
bmc08gt Sep 25, 2026
f96f2e5
feat(chat): add canReact predicate for reaction eligibility
bmc08gt Sep 25, 2026
a6121e0
feat(chat-ui): add ReactionPillRow with N-more collapse layout
bmc08gt Sep 25, 2026
bdd7abf
feat(chat): add ReactionRefreshPlanner for paging-driven reaction ref…
bmc08gt Sep 25, 2026
9c28dcd
feat(chat): add ReactionPicker/Reactors ChatStep and RecentReactionsS…
bmc08gt Sep 25, 2026
0cd0ca2
feat(chat-ui): add reactionPills and canReact to ContentBubble
bmc08gt Sep 25, 2026
98b0ea5
feat(chat): add ReactionPicker/Reactors nav stubs and ToggleReaction …
bmc08gt Sep 25, 2026
09c403a
feat(chat): wire reaction pills into mappedMessages and ToggleReactio…
bmc08gt Sep 25, 2026
c4625d4
test(chat): cover ToggleReaction's selection-clearing reducer branch
bmc08gt Sep 25, 2026
9ab590f
feat(chat): wire reaction pills, quick strip, and picker/reactors nav…
bmc08gt Sep 25, 2026
597a776
feat(chat): wire reaction refresh hooks into the transcript's paging …
bmc08gt Sep 25, 2026
7818bb8
fix(chat): record reaction recents once, at tap time
bmc08gt Sep 25, 2026
a5f0744
fix(chat): show reaction errors as one-line toasts with the iOS copy
bmc08gt Sep 25, 2026
3986f71
feat(emojis): add EmojiPickerModel search/section grouping
bmc08gt Sep 25, 2026
f0e93a6
feat(chat): default getReactorsPage to a 50-row page
bmc08gt Sep 25, 2026
3b831c7
feat(chat): merge reactors sheet pages into one row per person
bmc08gt Sep 25, 2026
dd3f145
feat(chat): add reactor name resolution and reactors/picker sheet det…
bmc08gt Sep 25, 2026
d0508d0
feat(chat): add ReactorsPrefetchCache for OpenReactors-triggered loading
bmc08gt Sep 25, 2026
b59ddee
feat(chat): start reactors fetch on OpenReactors, resolve reactor names
bmc08gt Sep 25, 2026
b1d5e6c
feat(chat): resolve reactor avatar alongside name
bmc08gt Sep 25, 2026
e4bf235
fix(chat): key ReactorsPrefetchCache flows so subscribers survive a race
bmc08gt Sep 25, 2026
f0b5676
feat(chat): build the reactors sheet UI
bmc08gt Sep 25, 2026
6c0cbf5
feat(chat): build the reaction picker sheet UI
bmc08gt Sep 25, 2026
e410572
feat(chat): wire the picker and reactors sheets into ChatFlowScreen
bmc08gt Sep 25, 2026
26480e0
test(messenger): pass userProfileDataSource mock to ChatViewModel in …
bmc08gt Sep 25, 2026
faecd86
test(emojis): run the drawability vectors on a device
bmc08gt Sep 25, 2026
5f24d1f
fix(emojis): run the Hilt processor so the app sees ReactionsModule
bmc08gt Sep 25, 2026
a7320fb
fix(emojis): keep reaction recents per account
bmc08gt Sep 25, 2026
dfd201a
fix(persistence): read reaction rows stored under the old sequence key
bmc08gt Sep 25, 2026
d56a72e
fix(chat): fade overflowing reactor emoji and open profiles from the …
bmc08gt Sep 25, 2026
3252c9d
feat(messenger): match reaction picker sheet to iOS
bmc08gt Sep 25, 2026
f8ef0e2
test(chat): cover ReactionPillRow and QuickReactionStrip
bmc08gt Sep 25, 2026
7995276
fix(chat): draw no pill row until a message has a reaction, and size …
bmc08gt Sep 25, 2026
3218bc3
fix(chat): place the quick reaction strip beside the bubble, sized as…
bmc08gt Sep 25, 2026
aaf51f0
fix(chat): leave selection when the strip's + opens the picker
bmc08gt Sep 25, 2026
0f761d0
fix(chat): size reaction pills and their + as on iOS
bmc08gt Sep 25, 2026
8e7f4d5
fix(messenger): slide the picker's category indicator and let it drag…
bmc08gt Sep 25, 2026
b421eac
fix(messenger): keep the picker's category bar on screen once the she…
bmc08gt Sep 25, 2026
d079fec
fix(messenger): move the picker's category bar in step with the sheet
bmc08gt Sep 26, 2026
0613fe5
fix(chat): grow the quick reaction strip in as iOS does
bmc08gt Sep 26, 2026
bb0bb07
fix(chat): keep pills under the backdrop and enter the strip with the…
bmc08gt Sep 26, 2026
71d112f
fix(chat): place the reaction strip below the measured top bar
bmc08gt Sep 26, 2026
acec931
test(reactions): read reactions.json from test-vectors instead of thr…
bmc08gt Sep 26, 2026
d797fe3
fix(chat): line the reaction strip up with the bubble, not its row
bmc08gt Sep 26, 2026
3e1fd22
fix(messenger): lift only the bubble, leaving its reaction pills behi…
bmc08gt Sep 26, 2026
57bd256
fix(chat): seed the stream's reaction state from Room before writing …
bmc08gt Sep 26, 2026
f93a20d
fix(chat): animate reaction pills in and out, clip their ripple, and …
bmc08gt Sep 26, 2026
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
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.flipcash.app.core.chat

import android.os.Parcelable
import com.getcode.navigation.HalfSheet
import com.getcode.navigation.Sheet
import com.getcode.navigation.WrapContentSheet
import com.getcode.navigation.flow.FlowStep
Expand Down Expand Up @@ -96,4 +97,26 @@ sealed interface ChatStep : FlowStep, Parcelable {
@Parcelize
@Serializable
data object EditGroupPicture : ChatStep

/**
* The full emoji picker for a message's reaction, opened from the pill row's "+" or the
* quick strip's "+". A plain [Sheet] rather than [WrapContentSheet]: the picker's catalog
* needs the screen's height, unlike the short, fixed-content sheets above. [HalfSheet]
* matches iOS's `EmojiPickerSheet.presentationDetents([.medium, .large])` — it rests at half
* height and only grows to the expanded detent once the grid reports it has more than that to
* show (see `AllowSheetExpansionWhenScrollable`).
*/
@Parcelize
@Serializable
data class ReactionPicker(val messageId: Long) : ChatStep, Sheet, HalfSheet

/**
* Who reacted, and with what — opened by long-pressing a pill. A plain [Sheet] for the same
* reason as [ReactionPicker]: a per-emoji reactor list can run long enough to need the full
* sheet height rather than wrapping its content. [HalfSheet] matches iOS's
* `ReactorsSheet.presentationDetents([.medium, .large])`.
*/
@Parcelize
@Serializable
data class Reactors(val messageId: Long) : ChatStep, Sheet, HalfSheet
}
7 changes: 7 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1004,6 +1004,13 @@
<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="title_clipboardLabelMessage">Message</string>
<string name="title_reactionNotAdded">Couldn\'t add reaction</string>
<string name="title_reactionLimitReached">This message has the maximum number of reactions</string>
<string name="title_reactionsCount">%1$d Reactions</string>
<string name="title_reactorsSheet">Reactions</string>
<string name="hint_searchEmoji">Search</string>
<string name="action_clearEmojiSearch">Clear search</string>
<string name="action_addReaction">Add reaction</string>

<string name="label_unknownContact">Unknown Contact</string>
<string name="title_allowFullContactAccess">Allow Full Contact Access</string>
Expand Down
3 changes: 3 additions & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ dependencies {
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:funding"))
implementation(project(":libs:emojis"))
implementation(project(":apps:flipcash:shared:menu"))
implementation(project(":apps:flipcash:shared:payments"))
// UserProfileDataSource, for the reactors sheet's cached-profile name resolution (decision 4).
implementation(project(":apps:flipcash:shared:persistence:sources"))
implementation(project(":apps:flipcash:shared:router"))
implementation(project(":apps:flipcash:shared:session"))
implementation(project(":apps:flipcash:shared:shareable"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
Expand All @@ -24,6 +25,8 @@ import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.StartSendCashOnceReady
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
import com.flipcash.app.messenger.internal.screens.MessengerScreen
import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet
import com.flipcash.app.messenger.internal.screens.ReactorsSheet
import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent
import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet
import com.flipcash.app.messenger.internal.screens.profile.ChatProfileScreen
Expand Down Expand Up @@ -51,6 +54,7 @@ import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

@Composable
fun ChatFlowScreen(
Expand Down Expand Up @@ -116,6 +120,13 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.EditGroupPicture> {
FlowEditGroupPictureScreen()
}

annotatedEntry<ChatStep.ReactionPicker> { step ->
FlowReactionPickerScreen(step.messageId)
}
annotatedEntry<ChatStep.Reactors> { step ->
FlowReactorsScreen(step.messageId)
}
}

@Composable
Expand Down Expand Up @@ -318,3 +329,63 @@ private fun FlowEditGroupNameScreen() {
private fun FlowEditGroupPictureScreen() {
EditGroupPictureScreen(flowSharedViewModel<ChatViewModel>())
}

/** The full emoji picker for [messageId] — a tap on any emoji toggles it and dismisses. */
@Composable
private fun FlowReactionPickerScreen(messageId: Long) {
val viewModel = flowSharedViewModel<ChatViewModel>()
val dismissSheet = LocalBottomSheetDismissDispatcher.current

ReactionPickerSheet(
loadSections = { query -> viewModel.emojiPickerSections(query) },
onSelected = { emoji ->
viewModel.dispatchEvent(
ChatViewModel.Event.ToggleReaction(messageId, emoji, clearsSelection = true)
)
dismissSheet()
},
onDismiss = dismissSheet,
)
}

/**
* Who reacted to [messageId], and with what. Tapping a row dismisses this sheet first, then opens
* the reactor's profile — [ChatViewModel.reactorParticipant] resolves a [ChatParticipant] even for
* a reactor absent from `senderProfiles`.
*/
@Composable
private fun FlowReactorsScreen(messageId: Long) {
val viewModel = flowSharedViewModel<ChatViewModel>()
val navigator = LocalCodeNavigator.current
val dismissSheet = LocalBottomSheetDismissDispatcher.current
val coroutineScope = rememberCoroutineScope()

val pills by remember(viewModel, messageId) { viewModel.reactionPills(messageId) }
.collectAsStateWithLifecycle(initialValue = emptyList())
val rows by remember(viewModel, messageId) { viewModel.reactorsRows(messageId) }
.collectAsStateWithLifecycle(initialValue = emptyList())
val loading by remember(viewModel, messageId) { viewModel.reactorsLoading(messageId) }
.collectAsStateWithLifecycle(initialValue = true)

ReactorsSheet(
pills = pills,
rows = rows,
loading = loading,
hasMore = { viewModel.reactorsHasMore(messageId) },
resolveDisplay = { userId -> viewModel.reactorDisplay(userId) },
onLoadMore = { viewModel.loadMoreReactors(messageId) },
onOpenProfile = { userId ->
// Dismiss first (decision: profile opens after the sheet closes), then resolve the
// participant — a row is only tappable once reactorDisplay has already resolved it, so
// this is a re-read of state already on screen rather than a fresh network round trip
// in the common case; reactorParticipant only reaches further (member roster, cached
// profile store) for a reactor absent from senderProfiles.
dismissSheet()
coroutineScope.launch {
val participant = viewModel.reactorParticipant(userId) ?: return@launch
navigator.push(ChatStep.Profile(contact = participant))
}
},
onDismiss = dismissSheet,
)
}
Loading
Loading