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
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupInviteResult>,
Sheet,
com.getcode.navigation.FullscreenSheet

@Parcelize
@Serializable
data class Profile(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,7 @@
<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_clipboardLabelMessageLink">Message Link</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>
Expand Down Expand Up @@ -1509,6 +1510,8 @@
<string name="action_claimUsername">Claim your username</string>
<string name="placeholder_minimumToChat">Not set</string>
<string name="title_shareProfileToChats">Share Profile</string>
<string name="title_shareMessageToChats">Share Message</string>
<string name="action_forwardMessage">Forward</string>
<!-- Who the share sheet is for: display name, then @handle -->
<string name="action_showProfileCard">Show Profile Card</string>
<string name="action_copyLink">Copy Link</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -136,6 +137,9 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.InviteToGroup> {
FlowGroupInviteSheet()
}
annotatedEntry<ChatStep.ShareMessage> { step ->
FlowShareMessageSheet(step.messageId)
}

annotatedEntry<ChatStep.Profile> { step ->
FlowChatProfileScreen(step.contact, step.origin)
Expand Down Expand Up @@ -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<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val inviteViewModel = hiltViewModel<GroupInviteViewModel>()
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<GroupInviteResult>(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<ChatProfileViewModel>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2654,6 +2661,16 @@ internal class ChatViewModel @AssistedInject constructor(
}
.launchIn(viewModelScope)

eventFlow.filterIsInstance<Event.CopyMessageLink>()
.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<Event.CopyMessage>()
.onEach { event ->
clipboardManager.setText(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupInviteResult>(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 {
Expand All @@ -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
}

/**
Expand Down Expand Up @@ -262,6 +277,7 @@ internal fun GroupInviteSheet(
username = subject.username,
preview = subject.preview,
)
ShareToChatsSubject.Message -> Shareable.MessageLink(inviteUrl)
}
)
}
Expand All @@ -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
}
),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +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.filled.Forward
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -113,6 +115,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(
Expand Down Expand Up @@ -265,53 +272,52 @@ private fun MessageSelectionBar(
selection: ChatListItem.ContentBubble,
keyboard: KeyboardController,
dispatch: (ChatViewModel.Event) -> Unit,
onShareMessage: () -> Unit,
onReport: () -> Unit,
) {
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.
// 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.Filled.ContentCopy,
testTag = "action_copy_message",
onClick = { dispatch(ChatViewModel.Event.CopyMessage(body)) },
)
)
}
if (MessageCapability.Reply in capabilities) {
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)) },
)
)
}
if (MessageCapability.Delete in capabilities) {
if (MessageCapability.Link in capabilities) {
add(
MessageAction(
label = stringResource(R.string.action_delete),
icon = Icons.Outlined.Delete,
testTag = "action_delete_message",
label = stringResource(R.string.action_forwardMessage),
icon = Icons.AutoMirrored.Filled.Forward,
testTag = "action_share_message",
onClick = {
keyboard.hideIfVisible {
dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId))
}
dispatch(ChatViewModel.Event.ShareMessageRequested)
onShareMessage()
},
)
)
}
// 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) {
add(
MessageAction(
Expand All @@ -324,11 +330,25 @@ private fun MessageSelectionBar(
)
)
}
if (MessageCapability.Delete in capabilities) {
add(
MessageAction(
label = stringResource(R.string.action_delete),
icon = Icons.Filled.Delete,
testTag = "action_delete_message",
onClick = {
keyboard.hideIfVisible {
dispatch(ChatViewModel.Event.DeleteMessage(selection.messageId))
}
},
)
)
}
if (MessageCapability.Report in capabilities) {
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
Expand Down
Loading
Loading