Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import com.flipcash.app.currency.RegionSelectionScreen
import com.flipcash.app.deposit.DepositFlowScreen
import com.flipcash.app.invite.InviteContactScreen
import com.flipcash.app.messenger.ChatFlowScreen
import com.flipcash.app.messenger.ChatPhotoViewerScreen
import com.flipcash.app.messenger.E2eeLearnMoreScreen
import com.flipcash.app.messenger.MuteChatScreen
import com.flipcash.app.messenger.ProfileScreen
Expand Down Expand Up @@ -171,7 +170,6 @@ internal fun appEntryProvider(
annotatedEntry<AppRoute.Messaging.SharePicker> { SharePickerScreen() }
annotatedEntry<AppRoute.Messaging.ShareProfileToChats> { key -> ShareProfileToChatsScreen(key) }
annotatedEntry<AppRoute.Messaging.MuteChat> { key -> MuteChatScreen(key.chatId, key.chatType) }
annotatedEntry<AppRoute.Messaging.PhotoViewer> { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) }
annotatedEntry<AppRoute.Messaging.E2eeDmInfo> { E2eeLearnMoreScreen(forGroup = false) }
annotatedEntry<AppRoute.Messaging.E2eeGroupInfo> { E2eeLearnMoreScreen(forGroup = true) }
annotatedEntry<AppRoute.Messaging.E2eePrivateGroupInfo> {
Expand Down
2 changes: 0 additions & 2 deletions apps/flipcash/app/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,4 @@
<cache-path name="share_previews" path="share_previews/" />
<!-- Exported tip codes, PNG + SVG (cacheDir/share_exports/). -->
<cache-path name="share_exports" path="share_exports/" />
<!-- Chat photos handed to the share sheet (cacheDir/chat_media/). -->
<cache-path name="chat_media" path="chat_media/" />
</paths>
Original file line number Diff line number Diff line change
Expand Up @@ -563,15 +563,6 @@ sealed interface AppRoute : NavKey, Parcelable {
com.getcode.navigation.Sheet,
com.getcode.navigation.WrapContentSheet

/**
* The full-screen viewer for the photo in message [messageId] of [chatId]. Carries the
* message's address rather than the photo, which is not something a back stack can hold; the
* viewer reads the message back to learn how to fetch and open it.
*/
@Serializable
@Parcelize
data class PhotoViewer(val chatId: ChatId, val messageId: Long) : Messaging

/** The group counterpart of [E2eeDmInfo], opened from Group Info's footer. */
@Serializable
@Parcelize
Expand Down
6 changes: 4 additions & 2 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,7 @@
<string name="action_clearEmojiSearch">Clear search</string>
<string name="action_chooseSkinTone">Choose skin tone</string>
<string name="action_addReaction">Add reaction</string>
<string name="description_chatMediaReactWith">%1$s, your reactions: %2$s</string>

<string name="label_unknownContact">Unknown Contact</string>
<string name="title_allowFullContactAccess">Allow Full Contact Access</string>
Expand Down Expand Up @@ -1586,8 +1587,9 @@
<string name="label_marketCapChartAccessibility">Price curve for %1$s with %2$s purchased</string>
<!-- Chat photo messages -->
<string name="description_chatMediaClose">Close</string>
<string name="description_chatMediaShare">Share</string>
<string name="title_chatMediaShare">Share photo</string>
<string name="description_chatMediaSave">Save</string>
<string name="title_chatPhotoSaved">Saved to Photos</string>
<string name="error_chatPhotoSaveFailed">Couldn\'t save the photo</string>
<string name="description_chatPhotoRemove">Remove photo</string>
<string name="description_chatLinkPreviewRemove">Remove link preview</string>
<string name="description_chatLinkPreviewLoading">Loading preview for %1$s</string>
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,9 @@ internal class ChatViewModel @AssistedInject constructor(
// which has the navigator and has already captured the message.
data object ShareMessageRequested : Event

/** A photo the viewer was asked to save has, or has not, reached the gallery. */
data class PhotoSaveResult(val saved: Boolean) : Event

data object SubmitEdit : Event
data object CancelEdit : Event
data object EditingEnded : Event
Expand Down Expand Up @@ -2660,6 +2663,14 @@ internal class ChatViewModel @AssistedInject constructor(
.launchIn(viewModelScope)
}

/**
* The viewer's own reactions on [messageId] as they stand now, `null` while nothing is known
* beyond the bubble a caller already holds. For a surface that outlives the bubble it was
* opened with, like the photo viewer, whose strip would otherwise highlight stale picks.
*/
fun selfReactionsOf(messageId: Long): Flow<List<SelfReaction>?> =
reactionOverlay.map { it[messageId]?.selfReactions }.distinctUntilChanged()

private suspend fun loadQuickReactionInputs(): QuickReactionInputs {
val catalog = emojiCatalogLoader.load()
val fill = catalog.firstCategoryEntries.map { it.emoji }
Expand Down Expand Up @@ -2755,6 +2766,15 @@ internal class ChatViewModel @AssistedInject constructor(
}
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.PhotoSaveResult>()
.onEach { event ->
toastController.showToast(
if (event.saved) R.string.title_chatPhotoSaved else R.string.error_chatPhotoSaveFailed,
replacePrevious = true,
)
}
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.CopyMessage>()
.onEach { event ->
clipboardManager.setText(
Expand Down Expand Up @@ -4057,6 +4077,7 @@ internal class ChatViewModel @AssistedInject constructor(
Event.ShareMessageRequested -> { state ->
state.copy(selection = null, confirmingDelete = false)
}
is Event.PhotoSaveResult -> { state -> state }
is Event.EditMessage -> { state ->
state.copy(
selection = null,
Expand Down Expand Up @@ -4180,20 +4201,49 @@ private fun ChatListItem.ContentBubble.narrowedFor(state: ChatViewModel.State):
* [ChatViewModel.State.quickReactionStrip] rebuilt for the current selection: empty while nothing
* is selected, while the selected message can't be reacted to, or before the inputs have loaded.
*/
private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State {
val bubble = selection?.takeIf { it.canReact }
private fun ChatViewModel.State.withQuickReactionStrip(): ChatViewModel.State =
copy(quickReactionStrip = reactionStripFor(selection))

/** This bubble with the viewer's reactions replaced by [live], when the overlay knows better. */
internal fun ChatListItem.ContentBubble.withLiveSelfReactions(
live: List<SelfReaction>?,
): ChatListItem.ContentBubble = if (live == null) this else copy(selfReactions = live)

/**
* The emoji the viewer reacted to this bubble with, in the pill row's order where the row knows
* them, then newest first. Read from the same live [ChatListItem.ContentBubble.selfReactions] the
* strip's highlight uses, so the photo viewer's React button never has a second source.
*/
internal fun ChatListItem.ContentBubble.ownReactionEmoji(): List<String> {
val rank = reactionPills.withIndex().associate { it.value.emoji to it.index }
return selfReactions.withIndex()
.sortedWith(
compareBy<IndexedValue<SelfReaction>> { rank[it.value.emoji] ?: Int.MAX_VALUE }
.thenByDescending { it.value.reactedAt }
.thenBy { it.index },
)
.map { it.value.emoji }
}

/**
* The quick strip for [bubble]: empty when it can't be reacted to or the inputs haven't loaded.
* Shared by the context menu's strip and the photo viewer's.
*/
internal fun ChatViewModel.State.reactionStripFor(
bubble: ChatListItem.ContentBubble?,
): List<ReactionStrip.Entry> {
val reactable = bubble?.takeIf { it.canReact }
val inputs = quickReactionInputs
val strip = if (bubble == null || inputs == null) {
return if (reactable == null || inputs == null) {
emptyList()
} else {
ReactionStripComposer.compose(
recentStats = inputs.recentStats,
selfReactions = bubble.selfReactions,
selfReactions = reactable.selfReactions,
catalogFillSource = inputs.catalogFill,
undrawable = inputs.undrawable,
)
}
return copy(quickReactionStrip = strip)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import com.flipcash.app.core.links.LocalTrustedWebsites
import com.flipcash.app.core.tokens.TokenInfoEntry
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.ownReactionEmoji
import com.flipcash.app.messenger.internal.reactionStripFor
import com.flipcash.app.messenger.internal.withLiveSelfReactions
import com.flipcash.app.messenger.internal.MentionDestination
import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.app.messenger.internal.screens.components.ChatTopBar
Expand All @@ -73,7 +76,9 @@ import com.flipcash.app.messenger.internal.screens.components.MessageList
import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.BlurredContent
import com.flipcash.shared.chat.MessageCapability
import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.shareable.LocalShareController
import com.flipcash.app.shareable.Shareable
Expand Down Expand Up @@ -129,7 +134,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
// The photo bubbles on screen, and the photo opened over them. The overlay sits above the whole
// screen rather than being a route, so it can grow out of and shrink back into its bubble.
val photoSources = remember { ChatPhotoSources() }
var openPhotoId by remember { mutableStateOf<Long?>(null) }
var openPhoto by remember { mutableStateOf<ChatListItem.ContentBubble?>(null) }

// A private group the viewer is not in has no transcript to show: no key envelope, so nothing
// to decrypt. A share link or deep link only knows the chat id and lands here first, so once the
Expand Down Expand Up @@ -179,7 +184,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {

is ChatAction.OpenPhoto -> {
// After the keyboard is down, so the bubble the photo grows from has settled.
keyboard.hideIfVisible { openPhotoId = action.bubble.messageId }
keyboard.hideIfVisible { openPhoto = action.bubble }
}

is ChatAction.RetryMessage -> {
Expand Down Expand Up @@ -568,11 +573,35 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}

openPhotoId?.let { id ->
openPhoto?.let { opened ->
// The bubble the photo opened from is a snapshot; the strip highlights the live picks.
val live by remember(opened.messageId) { viewModel.selfReactionsOf(opened.messageId) }
.collectAsStateWithLifecycle(initialValue = null)
val bubble = opened.withLiveSelfReactions(live)
ChatPhotoOverlay(
messageId = id,
messageId = bubble.messageId,
sources = photoSources,
onClosed = { openPhotoId = null },
onClosed = { openPhoto = null },
onSaveResult = { saved -> viewModel.dispatchEvent(ChatViewModel.Event.PhotoSaveResult(saved)) },
// The same actions the message's context menu offers, gated by the same capabilities.
// React sends the event the menu's strip does, and the strip is built the same way.
onReact = if (bubble.canReact) {
{ emoji ->
viewModel.dispatchEvent(
ChatViewModel.Event.ToggleReaction(bubble.messageId, emoji, clearsSelection = true)
)
}
} else null,
reactionStrip = state.reactionStripFor(bubble),
ownReactions = bubble.ownReactionEmoji(),
onOpenPicker = { navigator.push(ChatStep.ReactionPicker(bubble.messageId)) },
// Reply sets the strip the way the menu's Reply does; the overlay closes itself.
onReply = if (MessageCapability.Reply in bubble.capabilities) {
{ viewModel.dispatchEvent(ChatViewModel.Event.ReplyRequested(bubble)) }
} else null,
onForward = if (MessageCapability.Link in bubble.capabilities) {
{ navigator.openMessageShare(bubble.messageId) }
} else null,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.filled.Feedback
import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import com.flipcash.app.messenger.internal.screens.openMessageShare
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
Expand Down Expand Up @@ -60,6 +58,7 @@ import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatActionHandler
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.ui.MutedIndicator
import com.flipcash.shared.chat.ui.media.ForwardArrow
import com.flipcash.shared.chat.ui.messageFullTime
import com.getcode.navigation.core.CodeNavigator
import com.getcode.theme.CodeTheme
Expand Down Expand Up @@ -504,20 +503,3 @@ private fun OverflowLabel(text: String) {
color = CodeTheme.colors.textMain,
)
}

// Material has no forward counterpart to Reply, and its Forward is a straight block arrow. iOS draws
// Forward as Reply's mirror (arrowshape.turn.up.right.fill), so this is Reply's path flipped across x = 12.
private val ForwardArrow: ImageVector by lazy {
materialIcon(name = "Flipcash.ForwardArrow", autoMirror = true) {
materialPath {
moveTo(14.0f, 9.0f)
verticalLineTo(5.0f)
lineToRelative(7.0f, 7.0f)
lineToRelative(-7.0f, 7.0f)
verticalLineToRelative(-4.1f)
curveToRelative(-5.0f, 0.0f, -8.5f, 1.6f, -11.0f, 5.1f)
curveToRelative(1.0f, -5.0f, 4.0f, -10.0f, 11.0f, -11.0f)
close()
}
}
}
Loading
Loading