Skip to content

Commit 7547fea

Browse files
committed
feat(chat): offer the group invite from inside the group, in a sheet
Creating a public group ended on a full-screen invite step, so the link was pushed at the creator once and never offered again. The two rows it carries — node 10127:118315 — become a wrap-content sheet on the chat flow, reached from the empty transcript's CTA and from a new "Invite People To Join" row on the group's profile. Creating the chat now ends the create flow and drops into the conversation. The transcript's CTA used to open the system share picker straight away, which buried the other way to hand the link out. It opens the sheet instead, and the copy moves to the chat view model, which already holds the url both entry points are gated on. `ChoiceRow` moves to `:ui:components` so the messenger can draw the same row the new-chat screen does.
1 parent 963cfaa commit 7547fea

14 files changed

Lines changed: 212 additions & 186 deletions

File tree

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,18 @@ sealed interface ChatStep : FlowStep, Parcelable {
4242
data object InitPayment :
4343
ChatStep, NavigationRetVal<ChatSendResult>, Sheet, WrapContentSheet
4444

45+
/**
46+
* Node 10127:118315 — the two ways to hand out a group's invite link.
47+
*
48+
* A [WrapContentSheet] over the conversation rather than a screen of its own: it is two rows,
49+
* and inviting is something you do *from* the group, not a place you go. Reached from the empty
50+
* transcript's CTA and from the group's profile, which is why it hangs off the chat flow rather
51+
* than the create flow that used to end on it.
52+
*/
53+
@Parcelize
54+
@Serializable
55+
data object InviteToGroup : ChatStep, Sheet, WrapContentSheet
56+
4557
@Parcelize
4658
@Serializable
4759
data class Profile(val contact: ChatParticipant): ChatStep

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/NewGroupStep.kt‎

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import kotlinx.serialization.Serializable
1010
* The steps of creating a public group — section 10153:22901.
1111
*
1212
* A flow rather than three pushed routes because all three read and write one draft: the title, the
13-
* picture, the mint and the amount are entered on [Form], the mint is changed on [SelectCurrency],
14-
* and [Invite] shares the chat the draft became. A shared view model
13+
* picture, the mint and the amount are entered on [Form], and the mint and the amount are changed
14+
* on [SelectCurrency] and [CustomAmount]. A shared view model
1515
* ([com.getcode.navigation.flow.flowSharedViewModel]) holds that draft for the flow's lifetime,
1616
* which is also what keeps one create attempt's idempotency key alive across a retry.
17+
*
18+
* Creating the chat ends the flow. The invite link is handed out from inside the group
19+
* ([ChatStep.InviteToGroup]), so it is not a step here.
1720
*/
1821
@Serializable
1922
sealed interface NewGroupStep : FlowStep, Parcelable {
@@ -44,14 +47,4 @@ sealed interface NewGroupStep : FlowStep, Parcelable {
4447
@Parcelize
4548
@Serializable
4649
data object CustomAmount : NewGroupStep, Sheet
47-
48-
/**
49-
* Node 10127:118315 — the invite link for the chat that was just created.
50-
*
51-
* Carries no chat id: the shared view model holds the [com.flipcash.services.models.chat.ChatMetadata]
52-
* `StartChat` returned, and this step is only ever reached by that call succeeding.
53-
*/
54-
@Parcelize
55-
@Serializable
56-
data object Invite : NewGroupStep
5750
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.flipcash.app.core.chat.ChatSendResult
1919
import com.flipcash.app.core.chat.ChatStep
2020
import com.flipcash.app.core.extensions.openAsSheet
2121
import com.flipcash.app.messenger.internal.ChatViewModel
22+
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
2223
import com.flipcash.app.messenger.internal.screens.MessengerScreen
2324
import com.flipcash.app.messenger.internal.screens.cash.ChatAmountEntryContent
2425
import com.flipcash.app.messenger.internal.screens.cash.ChatInitPaymentSheet
@@ -60,9 +61,9 @@ fun ChatFlowScreen(
6061
// Popping with the IME still up drags the screen behind it out from under the keyboard.
6162
onExit = { _, _ -> keyboard.hideIfVisible { navigator.pop() } },
6263
entryProvider = chatEntryProvider(route.identifier, route.openKeyboard),
63-
// ChatStep.AmountEntry and ChatStep.InitPayment are Sheets, so the flow needs the sheet
64-
// strategy to draw them as such;
65-
// without it the step would fall through to SinglePane and cover the thread. Amount entry
64+
// ChatStep.AmountEntry, ChatStep.InitPayment and ChatStep.InviteToGroup are Sheets, so the
65+
// flow needs the sheet strategy to draw them as such; without it the step would fall
66+
// through to SinglePane and cover the thread. Amount entry
6667
// returns its result inside the flow (resultBackNavigator), so the strategy's own
6768
// dismiss-delivers-Canceled path has nothing to address here — hence the null key. A
6869
// swipe-dismiss just leaves the pending callback unclaimed, which is what a cancel means.
@@ -87,6 +88,9 @@ private fun chatEntryProvider(
8788
annotatedEntry<ChatStep.InitPayment> {
8889
FlowInitPaymentScreen()
8990
}
91+
annotatedEntry<ChatStep.InviteToGroup> {
92+
FlowGroupInviteSheet()
93+
}
9094
annotatedEntry<ChatStep.Profile> { step ->
9195
FlowChatProfileScreen(step.contact)
9296
}
@@ -213,6 +217,28 @@ private fun FlowInitPaymentScreen() {
213217
)
214218
}
215219

220+
/**
221+
* The invite sheet, on the conversation's own view model.
222+
*
223+
* Reached from the empty transcript and from the group's profile, and the same step either way —
224+
* the link is the chat's, so there is nothing for a second entry point to hold differently.
225+
*/
226+
@Composable
227+
private fun FlowGroupInviteSheet() {
228+
val viewModel = flowSharedViewModel<ChatViewModel>()
229+
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
230+
// Same dismissal rule as the other sheets in this flow: exit through the sheet so it animates
231+
// down rather than having its scene deleted mid-frame.
232+
val dismissSheet = LocalBottomSheetDismissDispatcher.current
233+
234+
GroupInviteSheet(
235+
inviteUrl = state.groupInviteUrl,
236+
groupTitle = state.subject?.title,
237+
onCopy = { viewModel.dispatchEvent(ChatViewModel.Event.CopyInviteLink) },
238+
onDismiss = dismissSheet,
239+
)
240+
}
241+
216242
@Composable
217243
private fun FlowChatProfileScreen(participant: ChatParticipant) {
218244
val viewModel = flowSharedViewModel<ChatProfileViewModel>()

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ internal class ChatViewModel @Inject constructor(
311311
/** The gate's "Join Chat" button. */
312312
data object JoinChat : Event
313313

314+
/** The invite sheet's "Copy Invite Link" row. */
315+
data object CopyInviteLink : Event
316+
314317
/** The group profile's "Leave Chat" row, which puts the confirmation up. */
315318
data object LeaveChat : Event
316319

@@ -1046,6 +1049,18 @@ internal class ChatViewModel @Inject constructor(
10461049
}
10471050

10481051
private fun initMessageActionHandlers() {
1052+
// Read off the state rather than carried on the event, so the row that copies the link and
1053+
// the row that shares it are handing out the one url [State.groupInviteUrl] builds.
1054+
eventFlow.filterIsInstance<Event.CopyInviteLink>()
1055+
.mapNotNull { stateFlow.value.groupInviteUrl }
1056+
.onEach { url ->
1057+
clipboardManager.setText(
1058+
text = url,
1059+
label = resources.getString(R.string.title_clipboardLabelGroupInviteLink),
1060+
)
1061+
}
1062+
.launchIn(viewModelScope)
1063+
10491064
eventFlow.filterIsInstance<Event.CopyMessage>()
10501065
.onEach { event ->
10511066
clipboardManager.setText(
@@ -1581,6 +1596,7 @@ internal class ChatViewModel @Inject constructor(
15811596
}
15821597
is Event.OnRuleTickerResolved -> { state -> state.copy(ruleTicker = event.ticker) }
15831598
is Event.OnGroupAccessResolved -> { state -> state.copy(groupAccess = event.access) }
1599+
Event.CopyInviteLink,
15841600
Event.JoinChat,
15851601
Event.LeaveChat,
15861602
Event.LeaveConfirmed,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.flipcash.app.messenger.internal.screens
2+
3+
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Column
5+
import androidx.compose.foundation.layout.fillMaxWidth
6+
import androidx.compose.foundation.layout.navigationBarsPadding
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.runtime.Composable
9+
import androidx.compose.runtime.rememberCoroutineScope
10+
import androidx.compose.ui.Alignment
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.res.stringResource
13+
import com.flipcash.app.shareable.LocalShareController
14+
import com.flipcash.app.shareable.Shareable
15+
import com.flipcash.features.messenger.R
16+
import com.getcode.theme.CodeTheme
17+
import com.getcode.ui.components.AppBarDefaults
18+
import com.getcode.ui.components.AppBarWithTitle
19+
import com.getcode.ui.components.ChoiceRow
20+
import kotlinx.coroutines.launch
21+
22+
/**
23+
* Node 10127:118315 — the two ways to hand out the link to a group.
24+
*
25+
* Both actions carry the same URL, built once by
26+
* [com.flipcash.app.core.util.Linkify.groupChatInvite] from the chat's id: there is no invite RPC,
27+
* the id *is* the invite, and one builder is what keeps the shared link and the copied link
28+
* identical.
29+
*
30+
* @param inviteUrl null for anyone who has nobody to invite — a DM, or a group this viewer has not
31+
* joined — in which case the sheet is the title bar alone. Both entry points are gated on the same
32+
* value, so this is a race with a leave rather than a state to design for.
33+
*/
34+
@Composable
35+
internal fun GroupInviteSheet(
36+
inviteUrl: String?,
37+
groupTitle: String?,
38+
onCopy: () -> Unit,
39+
onDismiss: () -> Unit,
40+
) {
41+
val shareController = LocalShareController.current
42+
val scope = rememberCoroutineScope()
43+
44+
Column(
45+
modifier = Modifier
46+
.fillMaxWidth()
47+
.navigationBarsPadding(),
48+
) {
49+
AppBarWithTitle(
50+
title = stringResource(R.string.title_inviteToJoinGroup),
51+
titleAlignment = Alignment.CenterHorizontally,
52+
endContent = { AppBarDefaults.Close(onClick = onDismiss) },
53+
)
54+
55+
if (inviteUrl != null) {
56+
Column(
57+
modifier = Modifier
58+
.fillMaxWidth()
59+
.padding(horizontal = CodeTheme.dimens.inset)
60+
.padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x4),
61+
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
62+
) {
63+
ChoiceRow(
64+
label = stringResource(R.string.action_sendInviteLink),
65+
icon = R.drawable.ic_at,
66+
onClick = {
67+
scope.launch {
68+
shareController.present(
69+
Shareable.GroupInvite(url = inviteUrl, title = groupTitle)
70+
)
71+
}
72+
},
73+
)
74+
ChoiceRow(
75+
label = stringResource(R.string.action_copyInviteLink),
76+
icon = R.drawable.ic_copy,
77+
onClick = onCopy,
78+
)
79+
}
80+
}
81+
}
82+
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt‎

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import androidx.compose.runtime.Composable
77
import androidx.compose.runtime.getValue
88
import androidx.compose.runtime.mutableStateOf
99
import androidx.compose.runtime.remember
10-
import androidx.compose.runtime.rememberCoroutineScope
1110
import androidx.compose.runtime.setValue
1211
import androidx.compose.ui.Modifier
1312
import androidx.compose.ui.platform.testTag
@@ -23,8 +22,6 @@ import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge
2322
import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge
2423
import com.flipcash.app.messenger.internal.screens.components.MessageList
2524
import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar
26-
import com.flipcash.app.shareable.LocalShareController
27-
import com.flipcash.app.shareable.Shareable
2825
import com.getcode.ui.components.BlurredContent
2926
import com.flipcash.shared.chat.models.ChatAction
3027
import com.getcode.navigation.core.LocalCodeNavigator
@@ -33,7 +30,6 @@ import com.getcode.ui.theme.ScaffoldBarPlacement
3330
import com.getcode.ui.utils.rememberKeyboardController
3431
import dev.chrisbanes.haze.hazeSource
3532
import dev.chrisbanes.haze.rememberHazeState
36-
import kotlinx.coroutines.launch
3733

3834
@Composable
3935
internal fun MessengerScreen(viewModel: ChatViewModel) {
@@ -47,8 +43,6 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
4743
// height, which the selection and editing modes change.
4844
var barHeight by remember { mutableStateOf(0.dp) }
4945
val keyboard = rememberKeyboardController()
50-
val shareController = LocalShareController.current
51-
val scope = rememberCoroutineScope()
5246

5347
val chatActionHandler = { action: ChatAction ->
5448
when (action) {
@@ -115,16 +109,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
115109
ChatAction.JoinChat -> viewModel.dispatchEvent(ChatViewModel.Event.JoinChat)
116110

117111
ChatAction.InviteToGroup -> {
118-
// The url is null for anyone who cannot invite — a DM, or a group this viewer has
119-
// not joined — and the CTA only renders when it is set, so there is nothing to
120-
// report when it is missing here.
121-
state.groupInviteUrl?.let { url ->
122-
scope.launch {
123-
shareController.present(
124-
Shareable.GroupInvite(url = url, title = state.subject?.title)
125-
)
126-
}
127-
}
112+
// The sheet, not the share sheet: copying the link is the other way to hand it out,
113+
// and going straight to the system share picker would bury it. The sheet reads the
114+
// url off the same state the CTA that got here is gated on.
115+
keyboard.hideIfVisible { navigator.push(ChatStep.InviteToGroup) }
128116
}
129117

130118
is ChatAction.ViewProfile -> {

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,45 @@ import androidx.compose.material.icons.automirrored.outlined.Logout
55
import androidx.compose.runtime.Composable
66
import androidx.compose.ui.graphics.painter.Painter
77
import androidx.compose.ui.graphics.vector.rememberVectorPainter
8+
import androidx.compose.ui.res.painterResource
89
import androidx.compose.ui.res.stringResource
910
import com.flipcash.app.menu.FullMenuItem
10-
import com.flipcash.app.messenger.internal.ChatViewModel
1111
import com.flipcash.features.messenger.R
1212

1313
/**
14-
* The one thing a group's profile can act on.
14+
* What the group's profile can act on.
1515
*
16-
* Its action is a [ChatViewModel.Event] rather than an event of the profile's own, because the
17-
* group is the conversation's — the leave has to clear the membership the transcript is reading,
18-
* and there is one view model holding it.
16+
* Its own type rather than [com.flipcash.app.messenger.internal.ChatViewModel.Event], because the
17+
* two rows do different kinds of thing: leaving is a request to the conversation's view model,
18+
* inviting is a navigation. Naming both here lets one [com.flipcash.app.menu.MenuList] carry them
19+
* and the screen decide which is which.
1920
*/
20-
internal data object LeaveChat : FullMenuItem<ChatViewModel.Event>() {
21+
internal sealed interface GroupProfileAction {
22+
data object Invite : GroupProfileAction
23+
data object Leave : GroupProfileAction
24+
}
25+
26+
/**
27+
* The same invite the empty transcript offers, for a group that already has messages in it.
28+
*
29+
* Shown only while there is a link to hand out, which is the same condition as being a member.
30+
*/
31+
internal data object InviteToGroup : FullMenuItem<GroupProfileAction>() {
32+
override val icon: Painter
33+
@Composable get() = painterResource(R.drawable.ic_at)
34+
35+
override val name: String
36+
@Composable get() = stringResource(R.string.action_invitePeopleToJoin)
37+
38+
override val action: GroupProfileAction = GroupProfileAction.Invite
39+
}
40+
41+
internal data object LeaveChat : FullMenuItem<GroupProfileAction>() {
2142
override val icon: Painter
2243
@Composable get() = rememberVectorPainter(Icons.AutoMirrored.Outlined.Logout)
2344

2445
override val name: String
2546
@Composable get() = stringResource(R.string.title_leaveChat)
2647

27-
override val action: ChatViewModel.Event = ChatViewModel.Event.LeaveChat
48+
override val action: GroupProfileAction = GroupProfileAction.Leave
2849
}

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,12 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) {
7171
modifier = Modifier
7272
.fillMaxSize()
7373
.padding(innerPadding),
74-
// Leaving is the only thing on offer, and only to someone who is in the group — but
75-
// it is held back for now, so this is the identity header on its own. The leave
76-
// itself is wired end to end behind [SHOW_LEAVE_ACTION]; flipping it puts the row back.
77-
items = if (SHOW_LEAVE_ACTION && group?.isMember == true) {
78-
listOf<MenuItem<ChatViewModel.Event>>(LeaveChat)
79-
} else {
80-
emptyList()
74+
// Both rows are for members only: a non-member has no link to hand out and nothing to
75+
// leave. The leave is wired end to end behind [SHOW_LEAVE_ACTION] and is held back for
76+
// now; flipping that constant puts the row back.
77+
items = buildList<MenuItem<GroupProfileAction>> {
78+
if (state.groupInviteUrl != null) add(InviteToGroup)
79+
if (SHOW_LEAVE_ACTION && group?.isMember == true) add(LeaveChat)
8180
},
8281
header = {
8382
GroupProfileHeader(
@@ -87,7 +86,15 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) {
8786
.padding(top = CodeTheme.dimens.grid.x7),
8887
)
8988
},
90-
onItemClick = { viewModel.dispatchEvent(it.action) },
89+
onItemClick = { item ->
90+
when (item.action) {
91+
// The same sheet the transcript's own invite CTA opens, pushed on this flow so
92+
// it sits over the profile the user asked from.
93+
GroupProfileAction.Invite -> flowNavigator.navigateTo(ChatStep.InviteToGroup)
94+
GroupProfileAction.Leave ->
95+
viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat)
96+
}
97+
},
9198
)
9299
}
93100
}

‎apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import androidx.compose.ui.Alignment
99
import androidx.compose.ui.Modifier
1010
import androidx.compose.ui.res.stringResource
1111
import com.flipcash.app.core.AppRoute
12-
import com.flipcash.app.tipping.internal.components.ChoiceRow
12+
import com.getcode.ui.components.ChoiceRow
1313
import com.flipcash.features.tipping.R
1414
import com.getcode.navigation.core.LocalCodeNavigator
1515
import com.getcode.theme.CodeTheme

0 commit comments

Comments
 (0)