From 13a68df09bb6772de588a94635962f0e99b978d7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 14 Oct 2026 15:53:50 -0400 Subject: [PATCH 1/3] feat(messenger): share and copy a link to a message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting a message now offers Share to Chat… and Copy Message Link (overflow only). Both build app.flipcash.com/chat/{chatId}/m/{seq} with Linkify.message: a UUID id for a group, base64url for a DM, which the router reads back the same way. Share to Chat opens the invite sheet with a Message subject; its Share tile hands the bare URL to the system sheet. A new Link capability gates both, kept for readers outside a group like Report. --- .../com/flipcash/app/core/chat/ChatStep.kt | 14 ++++ .../com/flipcash/app/core/util/Linkify.kt | 14 ++++ .../core/src/main/res/values/strings.xml | 4 + .../flipcash/app/messenger/ChatFlowScreen.kt | 43 +++++++++++ .../app/messenger/internal/ChatViewModel.kt | 23 ++++++ .../internal/screens/GroupInviteSheet.kt | 30 ++++++-- .../internal/screens/components/ChatTopBar.kt | 49 +++++++++++- .../internal/ChatMessageActionReducerTest.kt | 25 +++++++ .../flipcash/shared/chat/MessageCapability.kt | 34 ++++++--- .../shared/chat/MessageCapabilityTest.kt | 74 +++++++++++-------- .../app/router/internal/AppRouterTest.kt | 15 ++++ .../app/shareable/ShareSheetController.kt | 12 +++ .../InternalShareConfirmationController.kt | 1 + .../internal/InternalShareSheetController.kt | 17 +++++ 14 files changed, 306 insertions(+), 49 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 5b37ae5731..249f3694e5 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -66,6 +66,20 @@ sealed interface ChatStep : FlowStep, Parcelable { Sheet, com.getcode.navigation.FullscreenSheet + /** + * Sharing one message into the viewer's other chats — [InviteToGroup]'s sheet with the + * message's link as what it hands out. Returns the chat picked first, as that one does. + * + * @param messageId the message's per-chat sequence, the `{seq}` in its link. + */ + @Parcelize + @Serializable + data class ShareMessage(val messageId: Long) : + ChatStep, + NavigationRetVal, + Sheet, + com.getcode.navigation.FullscreenSheet + @Parcelize @Serializable data class Profile( diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt index a0390d3c27..29fc7bb8df 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt @@ -59,4 +59,18 @@ object Linkify { */ fun groupChatInvite(chatId: ChatId): String? = chatId.bytes.toList().uuid?.let { "https://app.flipcash.com/chat/$it" } + + /** + * A link to one message — `app.flipcash.com/chat/{chatId}/m/{seq}`, where [sequence] is the + * message's per-chat id. + * + * The chat id takes the form the router reads back: a group's dashed UUID, as in + * [groupChatInvite], and a DM's base64url, as in [tipChatById]. The id's form is how the router + * tells a group link from a DM one, so it has to be written that way rather than as either alone. + */ + fun message(chatId: ChatId, sequence: Long): String { + val id = chatId.bytes.toList().uuid?.toString() + ?: chatId.bytes.encodeBase64(urlSafe = true) + return "https://app.flipcash.com/chat/$id/m/$sequence" + } } \ No newline at end of file diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index eea1f81da7..9f0e61cabe 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1097,6 +1097,7 @@ Message Not Deleted The message couldn\'t be deleted, so it is still visible to everyone. Message + Message Link Couldn\'t add reaction This message has the maximum number of reactions %1$d Reactions @@ -1509,6 +1510,9 @@ Claim your username Not set Share Profile + Share Message + Share to Chat… + Copy Message Link Show Profile Card Copy Link diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 651024279f..73742c7a1b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -20,6 +20,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.chat.ChatSendResult import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.util.Linkify import com.flipcash.app.core.chat.ProfileOrigin import com.flipcash.app.core.chat.GroupInviteResult import com.flipcash.features.messenger.R @@ -136,6 +137,9 @@ private fun chatEntryProvider( annotatedEntry { FlowGroupInviteSheet() } + annotatedEntry { step -> + FlowShareMessageSheet(step.messageId) + } annotatedEntry { step -> FlowChatProfileScreen(step.contact, step.origin) @@ -302,6 +306,45 @@ private fun FlowGroupInviteSheet() { ) } +/** + * [ChatStep.ShareMessage]: the invite sheet handing out one message's link. Same send path and + * result as [FlowGroupInviteSheet]; the link is the whole subject. + */ +@Composable +private fun FlowShareMessageSheet(messageId: Long) { + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val inviteViewModel = hiltViewModel() + val inviteState by inviteViewModel.state.collectAsStateWithLifecycle() + val dismissSheet = LocalBottomSheetDismissDispatcher.current + val link = remember(state.chatId, messageId) { + state.chatId?.let { Linkify.message(it, messageId) } + } + + // The chat the message is in stays in the list: sending a link to a message back into its own + // chat is how you point at something further up, and the card draws there like anywhere else. + + val keyboard = rememberKeyboardController() + val resultBack = resultBackNavigator(exit = dismissSheet) + LaunchedEffect(inviteViewModel) { + inviteViewModel.invited.collect { chatId -> + keyboard.hideIfVisible { resultBack.returnValue(GroupInviteResult(chatId)) } + } + } + + GroupInviteSheet( + inviteUrl = link, + subject = ShareToChatsSubject.Message, + state = inviteState, + onShare = {}, + onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyMessageLink(messageId)) }, + onToggle = inviteViewModel::toggle, + onMessageChanged = inviteViewModel::onMessageChanged, + onInvite = { link?.let(inviteViewModel::invite) }, + onDismiss = dismissSheet, + ) +} + @Composable private fun FlowChatProfileScreen(participant: ChatParticipant, origin: ProfileOrigin) { val viewModel = flowSharedViewModel() 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 bca3a96456..4403cde76f 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 @@ -1018,6 +1018,13 @@ internal class ChatViewModel @AssistedInject constructor( // flow, and the bar's caller has already captured the message to push the route with. data object ReportRequested : Event + /** Puts a link to [messageId] on the clipboard — `Linkify.message`. */ + data class CopyMessageLink(val messageId: Long) : Event + + // Dismisses the bar, as ReportRequested does: the share sheet is opened by the bar's caller, + // which has the navigator and has already captured the message. + data object ShareMessageRequested : Event + data object SubmitEdit : Event data object CancelEdit : Event data object EditingEnded : Event @@ -2654,6 +2661,16 @@ internal class ChatViewModel @AssistedInject constructor( } .launchIn(viewModelScope) + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + clipboardManager.setText( + text = Linkify.message(chatId, event.messageId), + label = resources.getString(R.string.title_clipboardLabelMessageLink), + ) + } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() .onEach { event -> clipboardManager.setText( @@ -3900,6 +3917,12 @@ internal class ChatViewModel @AssistedInject constructor( Event.ReportRequested -> { state -> state.copy(selection = null, confirmingDelete = false) } + is Event.CopyMessageLink -> { state -> + state.copy(selection = null, confirmingDelete = false) + } + Event.ShareMessageRequested -> { state -> + state.copy(selection = null, confirmingDelete = false) + } is Event.EditMessage -> { state -> state.copy( selection = null, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt index 432e90fe41..f3bd73dab2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/GroupInviteSheet.kt @@ -115,9 +115,18 @@ internal fun CodeNavigator.openGroupInvite() { } } +/** Opens the sheet on a message's link; the chat picked first opens once the sends are out. */ +internal fun CodeNavigator.openMessageShare(messageId: Long) { + navigateForResult(ChatStep.ShareMessage(messageId)) { result -> + if (result is NavResultOrCanceled.ReturnValue) { + navigate(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(result.value.chatId))) + } + } +} + /** - * What [GroupInviteSheet] hands out: a group's invite link, or a person's profile link. The two - * differ in their copy, in what the Share tile gives the system share sheet, and in whether the + * What [GroupInviteSheet] hands out: a group's invite link, a person's profile link, or a link to + * one message. They differ in their copy, in what the Share tile gives the system share sheet, and in whether the * group's analytics fire; the rest of the sheet is the same. */ internal sealed interface ShareToChatsSubject { @@ -136,6 +145,12 @@ internal sealed interface ShareToChatsSubject { val displayName: String?, val preview: TipCodePreview?, ) : ShareToChatsSubject + + /** + * A message, handed out as its link. The sheet's URL is the link itself, so nothing else is + * carried: the recipient's app draws the message card from it. + */ + data object Message : ShareToChatsSubject } /** @@ -262,6 +277,7 @@ internal fun GroupInviteSheet( username = subject.username, preview = subject.preview, ) + ShareToChatsSubject.Message -> Shareable.MessageLink(inviteUrl) } ) } @@ -273,7 +289,7 @@ internal fun GroupInviteSheet( label = stringResource( when { copied -> R.string.action_inviteLinkCopied - subject is ShareToChatsSubject.User -> R.string.action_copyLink + subject !is ShareToChatsSubject.Group -> R.string.action_copyLink else -> R.string.action_copyInviteLink } ), @@ -347,7 +363,11 @@ internal fun GroupInviteSheet( } .onSizeChanged { headerHeight = with(density) { it.height.toDp() } }, title = stringResource( - if (subject is ShareToChatsSubject.User) R.string.title_shareProfileToChats else R.string.title_invitePeople + when (subject) { + is ShareToChatsSubject.User -> R.string.title_shareProfileToChats + is ShareToChatsSubject.Message -> R.string.title_shareMessageToChats + is ShareToChatsSubject.Group -> R.string.title_invitePeople + } ), titleAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(HeaderPadding), @@ -375,7 +395,7 @@ internal fun GroupInviteSheet( hazeState = hazeState, sending = state.sending, sendLabel = stringResource( - if (subject is ShareToChatsSubject.User) R.string.action_send else R.string.action_invite + if (subject is ShareToChatsSubject.Group) R.string.action_invite else R.string.action_send ), onMessageChanged = onMessageChanged, onInvite = onInvite, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 06310e51c4..52fa174069 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -24,6 +24,9 @@ import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Feedback +import com.flipcash.app.messenger.internal.screens.openMessageShare +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.automirrored.outlined.Forward import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -113,6 +116,11 @@ internal fun ChatTopBar( // Resolved here rather than in the bar: reporting is its own top-level // flow now, so it needs the chat's id, and this is the nearest scope holding // both that and the navigator. + onShareMessage = { + keyboard.hideIfVisible { + navigator.openMessageShare(target.selection.messageId) + } + }, onReport = { state.chatId?.let { chatId -> navigator.push( @@ -265,6 +273,7 @@ private fun MessageSelectionBar( selection: ChatListItem.ContentBubble, keyboard: KeyboardController, dispatch: (ChatViewModel.Event) -> Unit, + onShareMessage: () -> Unit, onReport: () -> Unit, ) { val capabilities = selection.capabilities @@ -275,7 +284,8 @@ private fun MessageSelectionBar( // is the one choice that would leave that bubble's bar empty. Delete follows: putting the one // action with a confirmation behind a menu makes it a three-tap job. Report goes last: it is // the rarest of the five and the only one that is never about your own message, so it is the - // right one to collapse into the overflow on a narrow screen. + // right one to collapse into the overflow on a narrow screen. Share to Chat follows Reply: the + // card it sends is the point of a message link. Copy Message Link always sits in the overflow. val actions = buildList { if (MessageCapability.Reply in capabilities) { add( @@ -287,6 +297,19 @@ private fun MessageSelectionBar( ) ) } + if (MessageCapability.Link in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_shareMessageToChat), + icon = Icons.AutoMirrored.Outlined.Forward, + testTag = "action_share_message", + onClick = { + dispatch(ChatViewModel.Event.ShareMessageRequested) + onShareMessage() + }, + ) + ) + } if (MessageCapability.Delete in capabilities) { add( MessageAction( @@ -340,6 +363,19 @@ private fun MessageSelectionBar( ) ) } + if (MessageCapability.Link in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_copyMessageLink), + icon = Icons.Outlined.Link, + testTag = "action_copy_message_link", + onClick = { + dispatch(ChatViewModel.Event.CopyMessageLink(selection.messageId)) + }, + overflowOnly = true, + ) + ) + } } AppBarWithTitle( @@ -369,6 +405,8 @@ private data class MessageAction( val icon: ImageVector, val testTag: String, val onClick: () -> Unit, + /** Kept in the overflow at any width, rather than taking an icon when there is room. */ + val overflowOnly: Boolean = false, ) /** @@ -408,9 +446,14 @@ private fun MessageActions(actions: List) { BoxWithConstraints { val capacity = inlineActionCapacity(maxWidth, buttonSize, spacing) + val (candidates, pinnedToOverflow) = actions.partition { !it.overflowOnly } // The overflow needs a slot of its own, so it only pays for itself when it is holding // something — the last action is not displaced by a menu that would contain only it. - val inline = if (actions.size <= capacity) actions else actions.take(capacity - 1) + val inline = if (pinnedToOverflow.isEmpty() && candidates.size <= capacity) { + candidates + } else { + candidates.take(capacity - 1) + } Row( horizontalArrangement = Arrangement.spacedBy(spacing), @@ -426,7 +469,7 @@ private fun MessageActions(actions: List) { ) } } - MessageOverflow(actions.drop(inline.size)) + MessageOverflow(candidates.drop(inline.size) + pinnedToOverflow) } } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt index 8bc3cc2b72..f2bec47540 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMessageActionReducerTest.kt @@ -156,6 +156,31 @@ class ChatMessageActionReducerTest { assertNull(state.selection) } + @Test + fun `copying a message link clears the bar`() { + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + + val state = reduce(selected, ChatViewModel.Event.CopyMessageLink(1)) + + assertNull(state.selection) + } + + @Test + fun `sharing a message to chats clears the bar`() { + // The share sheet opens over the chat; the bar would otherwise still be up when it closes. + val selected = reduce( + ChatViewModel.State(messagePolicy = unbounded), + ChatViewModel.Event.ToggleMessageSelection(bubble(1)), + ) + + val state = reduce(selected, ChatViewModel.Event.ShareMessageRequested) + + assertNull(state.selection) + } + @Test fun `the delete confirmation holds the selection but drops the focus`() { // The bar still has its message, so the sheet's Cancel has something to return to. The 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 78863eda80..aa1db99ad4 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 @@ -27,6 +27,13 @@ enum class MessageCapability { Edit, Delete, Report, + + /** + * Share or copy a link to the message (`app.flipcash.com/chat/{chatId}/m/{seq}`). Any message a + * participant sent and the server confirmed has a sequence to point at; a tombstone, a system + * notice and a widget are left out because the card they would resolve to has nothing to show. + */ + Link, } /** @@ -94,15 +101,15 @@ data class MessagePolicy( * * | Message | Capabilities | * |---|---| - * | Own text, confirmed, inside 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, confirmed, inside both windows | Copy, Reply, Edit, Delete, Link | + * | Own text, confirmed, past the edit window | Copy, Reply, Delete, Link | + * | Own text, confirmed, past both windows | Copy, Reply, Link | * | Own text, unconfirmed (`eventSequence == 0`) | none | - * | Another participant's text | Copy, Reply, Report | - * | Own photo, confirmed, inside the delete window | Reply, Delete | - * | Another participant's photo | Reply, Report | - * | Own cash or tip message | Reply | - * | Another participant's cash or tip message | Reply, Report | + * | Another participant's text | Copy, Reply, Report, Link | + * | Own photo, confirmed, inside the delete window | Reply, Delete, Link | + * | Another participant's photo | Reply, Report, Link | + * | Own cash or tip message | Reply, Link | + * | Another participant's cash or tip message | Reply, Report, Link | * | A tombstone | none | * | A system notice | none | * | A widget (server-sent), viewer may speak | Reply | @@ -121,8 +128,8 @@ data class MessagePolicy( * wrote, or that no longer exists, cannot. Your own messages are left out because reporting one is * not a thing anyone does, and a row that is always present is a row people stop reading. * - * A viewer who cannot post in the chat — someone reading a group from outside it — keeps only Copy - * and Report. Reply, Edit and Delete all post into the chat, and the gate that stands where their + * A viewer who cannot post in the chat — someone reading a group from outside it — keeps only Copy, + * Report and Link. Reply, Edit and Delete all post into the chat, and the gate that stands where their * composer would is the only way in. * * @param canPost whether the viewer may post in this chat. False for a viewer outside a group, @@ -146,7 +153,7 @@ fun resolveCapabilities( * a set resolved while the viewer could post has to narrow at the menu if they have since left. */ fun Set.readOnly(): Set = filterTo(mutableSetOf()) { - it == MessageCapability.Copy || it == MessageCapability.Report + it == MessageCapability.Copy || it == MessageCapability.Report || it == MessageCapability.Link } private fun resolveForParticipant( @@ -173,6 +180,7 @@ private fun resolveForParticipant( return buildSet { if (canSpeak) add(MessageCapability.Reply) if (!message.isFromSelf) add(MessageCapability.Report) + add(MessageCapability.Link) } } @@ -200,6 +208,7 @@ private fun resolveForParticipant( } else { add(MessageCapability.Report) } + add(MessageCapability.Link) }.withinWindows(message.timestamp, policy, now) } @@ -229,7 +238,8 @@ fun Set.withinWindows( // Reporting has no window on purpose: the edit and delete windows exist because the // server enforces them, and nothing in the contract limits how old a reportable message // may be. - MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report -> true + MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report, + MessageCapability.Link -> true } } 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 327aef1015..513f860c14 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 @@ -65,6 +65,7 @@ class MessageCapabilityTest { MessageCapability.Reply, MessageCapability.Edit, MessageCapability.Delete, + MessageCapability.Link, ), resolveCapabilities(text(), now = sentAt), ) @@ -73,18 +74,18 @@ class MessageCapabilityTest { @Test fun `someone else's text message is copyable and reportable, not editable or deletable`() { assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(text(isFromSelf = false)), ) } @Test fun `cash is never editable or deletable, by either party`() { - assertEquals(setOf(MessageCapability.Reply), resolveCapabilities(cash())) + assertEquals(setOf(MessageCapability.Reply, MessageCapability.Link), resolveCapabilities(cash())) // Reportable though: a payment is something a participant sent, and it is one of the // things most worth reporting. assertEquals( - setOf(MessageCapability.Reply, MessageCapability.Report), + setOf(MessageCapability.Reply, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(cash(isFromSelf = false)), ) } @@ -103,7 +104,7 @@ class MessageCapabilityTest { it == MessageCapability.Report }, ) - assertEquals(emptySet(), resolveCapabilities(cash()) - MessageCapability.Reply) + assertEquals(emptySet(), resolveCapabilities(cash()) - MessageCapability.Reply - MessageCapability.Link) } @Test @@ -146,11 +147,11 @@ class MessageCapabilityTest { @Test fun `where the viewer may speak, text and cash keep Reply`() { assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(text(isFromSelf = false), canSpeak = true), ) assertEquals( - setOf(MessageCapability.Reply, MessageCapability.Report), + setOf(MessageCapability.Reply, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(cash(isFromSelf = false), canSpeak = true), ) } @@ -158,7 +159,7 @@ class MessageCapabilityTest { @Test fun `where the viewer may not speak, another's text loses Reply and keeps Copy and Report`() { assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Report), + setOf(MessageCapability.Copy, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(text(isFromSelf = false), canSpeak = false), ) } @@ -166,7 +167,7 @@ class MessageCapabilityTest { @Test fun `where the viewer may not speak, own text keeps Copy, Edit and Delete but not Reply`() { assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Edit, MessageCapability.Delete), + setOf(MessageCapability.Copy, MessageCapability.Edit, MessageCapability.Delete, MessageCapability.Link), resolveCapabilities(text(), now = sentAt, canSpeak = false), ) } @@ -174,21 +175,21 @@ class MessageCapabilityTest { @Test fun `where the viewer may not speak, cash loses Reply and keeps Report`() { assertEquals( - setOf(MessageCapability.Report), + setOf(MessageCapability.Report, MessageCapability.Link), resolveCapabilities(cash(isFromSelf = false), canSpeak = false), ) - assertEquals(emptySet(), resolveCapabilities(cash(), canSpeak = false)) + assertEquals(setOf(MessageCapability.Link), resolveCapabilities(cash(), canSpeak = false)) } @Test fun `a creator-gated viewer loses Reply but keeps Copy, Report and own Edit and Delete`() { // The panel passes canSpeak = false for any unmet speaker rule, creator included. assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Report), + setOf(MessageCapability.Copy, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(text(isFromSelf = false), canSpeak = false), ) assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Edit, MessageCapability.Delete), + setOf(MessageCapability.Copy, MessageCapability.Edit, MessageCapability.Delete, MessageCapability.Link), resolveCapabilities(text(), now = sentAt, canSpeak = false), ) } @@ -205,7 +206,7 @@ class MessageCapabilityTest { fun `a viewer with no speaker rules, as in a direct message, is unaffected`() { // canSpeak defaults true: a DM has no rules to fail. assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(text(isFromSelf = false)), ) assertEquals(true, canReact(text(isFromSelf = false))) @@ -228,7 +229,7 @@ class MessageCapabilityTest { @Test fun `reporting has no window, so an old message stays reportable`() { assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Report, MessageCapability.Link), resolveCapabilities( text(isFromSelf = false), policy = MessagePolicy.Default, @@ -272,6 +273,7 @@ class MessageCapabilityTest { MessageCapability.Reply, MessageCapability.Edit, MessageCapability.Delete, + MessageCapability.Link, ), resolveCapabilities(reply, now = sentAt), ) @@ -287,12 +289,13 @@ class MessageCapabilityTest { MessageCapability.Reply, MessageCapability.Edit, MessageCapability.Delete, + MessageCapability.Link, ), resolveCapabilities(text(), policy, now = sentAt + 14.minutes), ) assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete, MessageCapability.Link), resolveCapabilities(text(), policy, now = sentAt + 16.minutes), ) } @@ -307,13 +310,14 @@ class MessageCapabilityTest { MessageCapability.Reply, MessageCapability.Edit, MessageCapability.Delete, + MessageCapability.Link, ), resolveCapabilities(text(), policy, now = sentAt + 59.minutes), ) // Edit survives: this policy sets no edit window, and an unset window is no limit. assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Edit), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Edit, MessageCapability.Link), resolveCapabilities(text(), policy, now = sentAt + 61.minutes), ) } @@ -323,11 +327,11 @@ class MessageCapabilityTest { val policy = MessagePolicy(editWindow = 15.minutes, deleteWindow = 60.minutes) assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete, MessageCapability.Link), resolveCapabilities(text(), policy, now = sentAt + 30.minutes), ) assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Link), resolveCapabilities(text(), policy, now = sentAt + 90.minutes), ) } @@ -342,6 +346,7 @@ class MessageCapabilityTest { MessageCapability.Reply, MessageCapability.Edit, MessageCapability.Delete, + MessageCapability.Link, ), resolveCapabilities(text(), unbounded, now = sentAt + 365.days), ) @@ -356,11 +361,11 @@ class MessageCapabilityTest { assertEquals(policy, MessagePolicy.Default) assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Delete, MessageCapability.Link), resolveCapabilities(text(), policy, now = sentAt + 30.minutes), ) assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Reply), + setOf(MessageCapability.Copy, MessageCapability.Reply, MessageCapability.Link), resolveCapabilities(text(), policy, now = sentAt + 365.days), ) } @@ -383,6 +388,7 @@ class MessageCapabilityTest { MessageCapability.Reply, MessageCapability.Edit, MessageCapability.Delete, + MessageCapability.Link, ), resolveCapabilities(text(), policy, now = sentAt + 60.minutes), ) @@ -425,21 +431,21 @@ class MessageCapabilityTest { * all post into the chat, and there is no composer for them to land in until the join does. */ @Test - fun `a viewer who cannot post keeps only copy and report`() { + fun `a viewer who cannot post keeps only copy, report and link`() { assertEquals( - setOf(MessageCapability.Copy, MessageCapability.Report), + setOf(MessageCapability.Copy, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(text(isFromSelf = false), canPost = false), ) assertEquals( - setOf(MessageCapability.Report), + setOf(MessageCapability.Report, MessageCapability.Link), resolveCapabilities(cash(isFromSelf = false), canPost = false), ) // Someone who left the group still has their own messages in its transcript. assertEquals( - setOf(MessageCapability.Copy), + setOf(MessageCapability.Copy, MessageCapability.Link), resolveCapabilities(text(), now = sentAt, canPost = false), ) - assertEquals(emptySet(), resolveCapabilities(cash(), canPost = false)) + assertEquals(setOf(MessageCapability.Link), resolveCapabilities(cash(), canPost = false)) } @Test @@ -513,11 +519,11 @@ class MessageCapabilityTest { @Test fun `own confirmed photo can be replied to and deleted but not copied or edited`() { assertEquals( - setOf(MessageCapability.Reply, MessageCapability.Delete), + setOf(MessageCapability.Reply, MessageCapability.Delete, MessageCapability.Link), resolveCapabilities(photo(), now = sentAt), ) assertEquals( - setOf(MessageCapability.Reply, MessageCapability.Delete), + setOf(MessageCapability.Reply, MessageCapability.Delete, MessageCapability.Link), resolveCapabilities(photo(reply = true), now = sentAt), ) } @@ -525,7 +531,7 @@ class MessageCapabilityTest { @Test fun `own photo past the delete window can only be replied to`() { assertEquals( - setOf(MessageCapability.Reply), + setOf(MessageCapability.Reply, MessageCapability.Link), resolveCapabilities(photo(), now = sentAt + 49.hours), ) } @@ -533,7 +539,7 @@ class MessageCapabilityTest { @Test fun `another participant's photo can be replied to and reported`() { assertEquals( - setOf(MessageCapability.Reply, MessageCapability.Report), + setOf(MessageCapability.Reply, MessageCapability.Report, MessageCapability.Link), resolveCapabilities(photo(isFromSelf = false), now = sentAt), ) } @@ -548,4 +554,14 @@ class MessageCapabilityTest { assertEquals(true, canReact(photo(isFromSelf = false))) assertEquals(false, canReact(photo(isFromSelf = false, redacted = true))) } + + @Test + fun `a link is offered on anything a participant sent and the server confirmed`() { + assertEquals(true, MessageCapability.Link in resolveCapabilities(text(isFromSelf = false))) + assertEquals(true, MessageCapability.Link in resolveCapabilities(cash(isFromSelf = false), canSpeak = false)) + // A reader outside a public group may hand the link on; that is how most of them will travel. + assertEquals(true, MessageCapability.Link in resolveCapabilities(text(isFromSelf = false), canPost = false)) + assertEquals(false, MessageCapability.Link in resolveCapabilities(text(eventSequence = 0))) + assertEquals(false, MessageCapability.Link in resolveCapabilities(widgetMessage)) + } } diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index ff638ec98e..832ac6f1ac 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -526,6 +526,21 @@ class AppRouterTest { assertEquals(false, type.isGroup) } + @Test + fun `a message link built by Linkify routes back to the same chat and message`() { + val group = router.classify(DeepLink(Linkify.message(groupChatId, 1180))) + assertIs(group) + assertEquals(groupChatId, group.chatId) + assertEquals(1180L, group.messageId) + assertTrue(group.isGroup) + + val dm = router.classify(DeepLink(Linkify.message(sampleChatId, 9))) + assertIs(dm) + assertEquals(sampleChatId, dm.chatId) + assertEquals(9L, dm.messageId) + assertEquals(false, dm.isGroup) + } + @Test fun `a message link with a non-numeric or negative seq is not claimed as a message`() { // A DM's tail has nothing else to fall to; a group's falls through to the invite, which diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index 46629fd14c..7827617c05 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -87,6 +87,18 @@ sealed interface Shareable { override val pendingData: ShareablePendingData? = null } + /** + * A link to one chat message — `app.flipcash.com/chat/{chatId}/m/{seq}`. Shared as the bare URL: + * the app on the other end draws the message card from it, and anything written around it would + * sit beside a card that already says what the message is. + * + * Carries the built [url] for the same reason as [GroupInvite]: building it is + * [com.flipcash.app.core.util.Linkify.message]'s job. + */ + data class MessageLink(val url: String) : Shareable { + override val pendingData: ShareablePendingData? = null + } + /** * The tip code itself, as a file (see [TipCodeExport]) — what the "Download" action produces. * diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt index ed10feba52..b2d986c9a9 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareConfirmationController.kt @@ -31,6 +31,7 @@ internal class InternalShareConfirmationController( // Nothing to take back if the link never leaves — the chat exists either way, and // the same link can be shared again. Only a cash link needs confirming. is Shareable.GroupInvite -> ShareConfirmationResult.Confirmed(shareResult) + is Shareable.MessageLink -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.Profile -> ShareConfirmationResult.Confirmed(shareResult) is Shareable.TipCodeImage -> ShareConfirmationResult.Confirmed(shareResult) } diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt index 882121ba32..bf01938dac 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt @@ -116,6 +116,7 @@ internal class InternalShareSheetController( is Shareable.TokenInfo -> Unit is Shareable.Invite -> Unit is Shareable.GroupInvite -> Unit + is Shareable.MessageLink -> Unit is Shareable.Profile -> Unit is Shareable.TipCodeImage -> Unit } @@ -219,6 +220,8 @@ internal class InternalShareSheetController( is Shareable.Profile -> shareProfile(shareable) + is Shareable.MessageLink -> shareMessageLink(shareable) + is Shareable.TipCodeImage -> shareTipCodeImage(shareable) } } @@ -272,6 +275,20 @@ internal class InternalShareSheetController( context.startActivity(share) } + private fun shareMessageLink(shareable: Shareable.MessageLink) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareable.url) + type = "text/plain" + } + + val share = Intent.createChooser(intent, null).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + context.startActivity(share) + } + private fun shareDownloadLink() { val shareRef = resources.getString(R.string.app_download_link_share_ref) val url = Linkify.download(shareRef) From 5d65f03c96fa468ed2b882eac41fbe38f749c3fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 09:36:26 -0400 Subject: [PATCH 2/3] fix(messenger): name the message share action Forward and match iOS's menu order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selection bar's Share to Chat… becomes Forward, and the separate Copy Message Link row goes: the sheet Forward opens already has a Copy Link tile. Actions now run Copy, Reply, Forward, Edit, Delete, Report, the order iOS #1076 uses. --- .../core/src/main/res/values/strings.xml | 3 +- .../internal/screens/components/ChatTopBar.kt | 85 +++++++------------ 2 files changed, 32 insertions(+), 56 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 9f0e61cabe..2c2d1f69f0 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1511,8 +1511,7 @@ Not set Share Profile Share Message - Share to Chat… - Copy Message Link + Forward Show Profile Card Copy Link diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 52fa174069..351e8371a0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -25,7 +25,6 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Feedback import com.flipcash.app.messenger.internal.screens.openMessageShare -import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.automirrored.outlined.Forward import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -279,14 +278,23 @@ private fun MessageSelectionBar( val capabilities = selection.capabilities val body = selection.plainText - // Order is priority: the first actions keep their icons when the bar runs out of room. Reply - // leads because it is the most common action and the only one a cash bubble offers — burying it - // is the one choice that would leave that bubble's bar empty. Delete follows: putting the one - // action with a confirmation behind a menu makes it a three-tap job. Report goes last: it is - // the rarest of the five and the only one that is never about your own message, so it is the - // right one to collapse into the overflow on a narrow screen. Share to Chat follows Reply: the - // card it sends is the point of a message link. Copy Message Link always sits in the overflow. + // Order is priority: the first actions keep their icons when the bar runs out of room, and it + // matches iOS's menu: Copy, Reply, Forward, Edit, Delete, Report. Report goes last: it is the + // rarest and the only one that is never about your own message, so it is the right one to + // collapse into the overflow on a narrow screen. Forward hands out the message's link; the + // sheet it opens has the Copy Link tile, so the bar has no copy-link row of its own. + // Copy and edit both act on the message's text, so a bubble without any is offered neither. val actions = buildList { + if (body != null && MessageCapability.Copy in capabilities) { + add( + MessageAction( + label = stringResource(R.string.action_copy), + icon = Icons.Outlined.ContentCopy, + testTag = "action_copy_message", + onClick = { dispatch(ChatViewModel.Event.CopyMessage(body)) }, + ) + ) + } if (MessageCapability.Reply in capabilities) { add( MessageAction( @@ -300,7 +308,7 @@ private fun MessageSelectionBar( if (MessageCapability.Link in capabilities) { add( MessageAction( - label = stringResource(R.string.action_shareMessageToChat), + label = stringResource(R.string.action_forwardMessage), icon = Icons.AutoMirrored.Outlined.Forward, testTag = "action_share_message", onClick = { @@ -310,39 +318,28 @@ private fun MessageSelectionBar( ) ) } - if (MessageCapability.Delete in capabilities) { + if (body != null && MessageCapability.Edit in capabilities) { add( MessageAction( - label = stringResource(R.string.action_delete), - icon = Icons.Outlined.Delete, - testTag = "action_delete_message", + label = stringResource(R.string.action_edit), + icon = Icons.Outlined.Edit, + testTag = "action_edit_message", onClick = { - keyboard.hideIfVisible { - dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId)) - } + dispatch(ChatViewModel.Event.EditMessage(selection.messageId, body)) }, ) ) } - // Copy and edit both act on the message's text, so a bubble without any is offered neither. - if (body != null && MessageCapability.Copy in capabilities) { - add( - MessageAction( - label = stringResource(R.string.action_copy), - icon = Icons.Outlined.ContentCopy, - testTag = "action_copy_message", - onClick = { dispatch(ChatViewModel.Event.CopyMessage(body)) }, - ) - ) - } - if (body != null && MessageCapability.Edit in capabilities) { + if (MessageCapability.Delete in capabilities) { add( MessageAction( - label = stringResource(R.string.action_edit), - icon = Icons.Outlined.Edit, - testTag = "action_edit_message", + label = stringResource(R.string.action_delete), + icon = Icons.Outlined.Delete, + testTag = "action_delete_message", onClick = { - dispatch(ChatViewModel.Event.EditMessage(selection.messageId, body)) + keyboard.hideIfVisible { + dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId)) + } }, ) ) @@ -363,19 +360,6 @@ private fun MessageSelectionBar( ) ) } - if (MessageCapability.Link in capabilities) { - add( - MessageAction( - label = stringResource(R.string.action_copyMessageLink), - icon = Icons.Outlined.Link, - testTag = "action_copy_message_link", - onClick = { - dispatch(ChatViewModel.Event.CopyMessageLink(selection.messageId)) - }, - overflowOnly = true, - ) - ) - } } AppBarWithTitle( @@ -405,8 +389,6 @@ private data class MessageAction( val icon: ImageVector, val testTag: String, val onClick: () -> Unit, - /** Kept in the overflow at any width, rather than taking an icon when there is room. */ - val overflowOnly: Boolean = false, ) /** @@ -446,14 +428,9 @@ private fun MessageActions(actions: List) { BoxWithConstraints { val capacity = inlineActionCapacity(maxWidth, buttonSize, spacing) - val (candidates, pinnedToOverflow) = actions.partition { !it.overflowOnly } // The overflow needs a slot of its own, so it only pays for itself when it is holding // something — the last action is not displaced by a menu that would contain only it. - val inline = if (pinnedToOverflow.isEmpty() && candidates.size <= capacity) { - candidates - } else { - candidates.take(capacity - 1) - } + val inline = if (actions.size <= capacity) actions else actions.take(capacity - 1) Row( horizontalArrangement = Arrangement.spacedBy(spacing), @@ -469,7 +446,7 @@ private fun MessageActions(actions: List) { ) } } - MessageOverflow(candidates.drop(inline.size) + pinnedToOverflow) + MessageOverflow(actions.drop(inline.size)) } } } From b08b6e6ccbcbbf3096d2c4e3f2768d8d2c35fe29 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 16 Oct 2026 15:06:18 -0400 Subject: [PATCH 3/3] fix(messenger): use filled icons in the message action bar iOS moved its long-press menu to filled symbols (Copy, Reply, Forward, Delete, Report). Edit stays the outline pencil on both, since SF Symbols has no filled pencil. --- .../internal/screens/components/ChatTopBar.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt index 351e8371a0..8e5036c217 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatTopBar.kt @@ -19,13 +19,13 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack -import androidx.compose.material.icons.automirrored.outlined.Reply -import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.automirrored.filled.Reply +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.outlined.Feedback +import androidx.compose.material.icons.filled.Feedback import com.flipcash.app.messenger.internal.screens.openMessageShare -import androidx.compose.material.icons.automirrored.outlined.Forward +import androidx.compose.material.icons.automirrored.filled.Forward import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -289,7 +289,7 @@ private fun MessageSelectionBar( add( MessageAction( label = stringResource(R.string.action_copy), - icon = Icons.Outlined.ContentCopy, + icon = Icons.Filled.ContentCopy, testTag = "action_copy_message", onClick = { dispatch(ChatViewModel.Event.CopyMessage(body)) }, ) @@ -299,7 +299,7 @@ private fun MessageSelectionBar( add( MessageAction( label = stringResource(R.string.action_reply), - icon = Icons.AutoMirrored.Outlined.Reply, + icon = Icons.AutoMirrored.Filled.Reply, testTag = "action_reply_message", onClick = { dispatch(ChatViewModel.Event.ReplyRequested(selection)) }, ) @@ -309,7 +309,7 @@ private fun MessageSelectionBar( add( MessageAction( label = stringResource(R.string.action_forwardMessage), - icon = Icons.AutoMirrored.Outlined.Forward, + icon = Icons.AutoMirrored.Filled.Forward, testTag = "action_share_message", onClick = { dispatch(ChatViewModel.Event.ShareMessageRequested) @@ -334,7 +334,7 @@ private fun MessageSelectionBar( add( MessageAction( label = stringResource(R.string.action_delete), - icon = Icons.Outlined.Delete, + icon = Icons.Filled.Delete, testTag = "action_delete_message", onClick = { keyboard.hideIfVisible { @@ -348,7 +348,7 @@ private fun MessageSelectionBar( add( MessageAction( label = stringResource(R.string.title_report), - icon = Icons.Outlined.Feedback, + icon = Icons.Filled.Feedback, testTag = "action_report_message", // Unlike the others this one leaves the chat, so nothing on the way back // dismisses the bar. Clearing it here means the transcript is at rest behind