Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0b4b618
feat(chat): list, create and revoke a host's invite links
bmc08gt Oct 28, 2026
99f8f75
feat(chat): remember a host's can_invite from GetChat
bmc08gt Oct 28, 2026
a1e62f9
refactor(chat): end a refused invite expiry instead of retrying it sh…
bmc08gt Oct 28, 2026
72cf07c
fix(shareable): keep a group invite's link out of its toString
bmc08gt Oct 28, 2026
548a3bc
feat(chat): make a host link on the first share tap
bmc08gt Oct 28, 2026
04048e2
feat(ui): let a choice row take a content colour
bmc08gt Oct 28, 2026
9b653aa
feat(messenger): host surfaces for group invite links
bmc08gt Oct 28, 2026
9423a1c
test(messenger): invite flow view models, previews and frame renders
bmc08gt Oct 28, 2026
eed2722
fix(messenger): title the invite-link share sheet Invite People
bmc08gt Oct 28, 2026
73e337a
fix(messenger): use the spec's use-limit presets on New Link
bmc08gt Oct 28, 2026
ebcadea
fix(chat): clamp a refused invite expiry to the next shorter preset
bmc08gt Oct 28, 2026
e9058e9
fix(chat): rethrow cancellation and serialize invite create against c…
bmc08gt Oct 28, 2026
adcbf4a
fix(core): mark copied invite links as sensitive on the clipboard
bmc08gt Oct 28, 2026
5115a70
fix(messenger): create the invite link on the Share Invite Link tap
bmc08gt Oct 28, 2026
43f6bd2
test(messenger): cover invite creation on open, errors, copy, and Man…
bmc08gt Oct 28, 2026
63e334c
fix(messenger): present the invite link row sheet on the app's sheet …
bmc08gt Oct 28, 2026
abc935d
refactor(ui): move the request-time preset chips into SlidingChipRow
bmc08gt Oct 28, 2026
b021d19
feat(ui): give each SlidingChipRow chip an equal share of the row
bmc08gt Oct 29, 2026
1f75812
fix(messenger): present New Invite Link on the app's sheet scene
bmc08gt Oct 28, 2026
b1f9b3e
test(messenger): render Time To Pay's preset chips at both font scales
bmc08gt Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ data object ChatSendResult : Parcelable
@Serializable
data class GroupInviteResult(val chatId: ChatId) : Parcelable

/** What the host picked on Manage Invites' row sheet (frame 2b). */
@Parcelize
@Serializable
enum class InviteLinkAction : Parcelable { Share, Revoke }

/**
* What the host picked on New Invite Link (frame 2d): the preset names, not the presets, because
* this module cannot see `shared:chat`. Presets only; never a key or link.
*/
@Parcelize
@Serializable
data class NewInviteLinkChoice(val expiry: String, val uses: String) : Parcelable

/** A custom paid-reaction amount, in whole US cents. */
@Parcelize
@Serializable
Expand Down Expand Up @@ -87,6 +100,55 @@ sealed interface ChatStep : FlowStep, Parcelable {
Sheet,
com.getcode.navigation.FullscreenSheet

/**
* Frames 1b, 1f: a private group's invite link, in the share-to-chats sheet. [inviteIdHex] null
* is the sheet's current link, which the first Share or Copy tap makes when there is none;
* otherwise it is one invite from Manage Invites (2b's Share Link).
*
* Carries the invite's id and never its link: a step is logged and saved, and the link's
* fragment is the secret. The sheet rebuilds the link from the host's own invites.
*/
@Parcelize
@Serializable
data class ShareInviteLink(val inviteIdHex: String? = null) :
ChatStep,
NavigationRetVal<GroupInviteResult>,
Sheet,
com.getcode.navigation.FullscreenSheet

/**
* Frame 2b: Share Link and Revoke Link for one invite on Manage Invites. Returns the pick and
* leaves the work to Manage Invites, which is still on screen beneath it and owns the revoke
* confirm (2c).
*
* Carries the invite's id and never its link, as [ShareInviteLink] does.
*/
@Parcelize
@Serializable
data class InviteLinkActions(val inviteIdHex: String) :
ChatStep,
NavigationRetVal<InviteLinkAction>,
Sheet,
WrapContentSheet

/**
* Frame 2d: expiry and use-limit presets for a new invite link. Returns the chosen
* [NewInviteLinkChoice] and closes; Manage Invites, still beneath it, makes the link and shows
* the creating state. The result is presets only, never a key or link.
*/
@Parcelize
@Serializable
data object NewInviteLink :
ChatStep,
NavigationRetVal<NewInviteLinkChoice>,
Sheet,
WrapContentSheet

/** Frame 2a: the invite links this host has made, Active then Inactive. Pushed, like [EditGroup]. */
@Parcelize
@Serializable
data object ManageInvites : ChatStep

/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
package com.flipcash.app.core.extensions

import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.os.Build
import android.os.PersistableBundle

fun ClipboardManager.setText(text: CharSequence, label: String) {
setPrimaryClip(ClipData.newPlainText(label, text))
}
}

/**
* Copies [text] and marks the clip sensitive, so Android 13+ hides it from the clipboard preview
* and older Android keeps it out of keyboard suggestions that honour the flag. For a value that
* is a secret, such as an invite link, which is as good as the chat key.
*/
fun ClipboardManager.setSensitiveText(text: CharSequence, label: String) {
val clip = ClipData.newPlainText(label, text)
clip.description.extras = PersistableBundle().apply { putBoolean(SensitiveClipKey, true) }
setPrimaryClip(clip)
}

/** [ClipDescription.EXTRA_IS_SENSITIVE], which the platform only declares from API 33. */
val SensitiveClipKey: String =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ClipDescription.EXTRA_IS_SENSITIVE
} else {
"android.content.extra.IS_SENSITIVE"
}
9 changes: 9 additions & 0 deletions apps/flipcash/core/src/main/res/drawable/ic_link_outlined.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4V7H7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9H7c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2H8v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4V17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"
android:fillColor="#ffffff"/>
</vector>
57 changes: 57 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1479,6 +1479,63 @@
<string name="title_invitePeople">Invite People</string>
<string name="action_shareInviteLink">Share</string>
<string name="label_recentChats">Recent Chats</string>
<!-- Invite links for a private group's host (spec group-invites.md, nodes 12844:60 to 12844:395). -->
<!-- %1$s is the group title. -->
<string name="title_inviteToGroup">Invite to %1$s</string>
<string name="action_shareInviteLinkLong">Share Invite Link</string>
<string name="subtitle_shareInviteLink">Anyone with this link joins right away</string>
<string name="action_shareJoinLink">Share Join Link</string>
<string name="subtitle_shareJoinLink">People ask to join and you approve each one</string>
<string name="action_manageInvites">Manage Invites</string>
<string name="subtitle_manageInvites">See and revoke the invite links you\'ve made</string>
<string name="label_creatingInviteLink">Creating link…</string>
<string name="action_shareLink">Share Link</string>
<string name="footnote_inviteLink">Anyone with this link can join and see who\'s in the group. Share it only with people you want in the group</string>
<!-- A link's lines, joined with " · ". %1$s is a date such as "Nov 3". -->
<string name="label_inviteCreated">Created %1$s</string>
<string name="label_inviteExpires">Expires %1$s</string>
<string name="label_inviteNeverExpires">Never expires</string>
<string name="label_inviteJoined">%1$d joined</string>
<string name="label_inviteJoinedOf">%1$d of %2$d joined</string>
<string name="label_inviteCantShare">Can\'t share from this device</string>
<string name="label_inviteExpired">Expired %1$s</string>
<string name="label_inviteRevoked">Revoked</string>
<string name="label_inviteReachedLimit">Reached its limit</string>
<string name="label_inviteUnavailable">No longer available</string>
<string name="label_inviteCurrent">Current</string>
<string name="title_inviteLinks">Invite Links</string>
<string name="title_inviteLink">Invite Link</string>
<string name="header_inviteLinksActive">ACTIVE</string>
<string name="header_inviteLinksInactive">INACTIVE</string>
<string name="action_newInviteLink">New Link</string>
<string name="title_noInviteLinks">No invite links</string>
<string name="subtitle_noInviteLinks">Make a link to let people join without waiting for approval.</string>
<string name="action_revokeLink">Revoke Link</string>
<string name="title_revokeInviteLink">Revoke this link?</string>
<string name="description_revokeInviteLink">No one new can join with it. People who already joined stay in the group</string>
<string name="title_newInviteLink">New Invite Link</string>
<string name="label_inviteExpiresHeader">Expires</string>
<string name="label_inviteWhoCanJoin">Who can join</string>
<string name="label_inviteExpiry_oneDay">1 day</string>
<string name="label_inviteExpiry_sevenDays">7 days</string>
<string name="label_inviteExpiry_thirtyDays">30 days</string>
<string name="label_inviteExpiry_never">Never</string>
<string name="label_inviteUses_one">1</string>
<string name="label_inviteUses_five">5</string>
<string name="label_inviteUses_ten">10</string>
<string name="label_inviteUses_twentyFive">25</string>
<string name="label_inviteUses_noLimit">No limit</string>
<string name="action_createInviteLink">Create Link</string>
<string name="title_tooManyInviteLinks">Too Many Active Links</string>
<string name="description_tooManyInviteLinks">Revoke a link in Manage Invites to make another</string>
<string name="title_inviteLinksNotAvailable">Invite Links Aren\'t Available Yet</string>
<string name="description_inviteLinksNotAvailable">You can still share the group link. People who open it can ask to join, and you approve them</string>
<string name="action_shareGroupLink">Share Group Link</string>
<string name="title_inviteLinkNotCreated">Couldn\'t Create Link</string>
<string name="description_inviteLinkNotCreated">Something went wrong. Please try again</string>
<string name="title_inviteLinkNotRevoked">Couldn\'t Revoke Link</string>
<string name="title_inviteLinksNotLoaded">Couldn\'t Load Invite Links</string>
<string name="description_inviteLinksNotLoaded">Something went wrong. Please try again.</string>
<string name="title_shareToChat">Send to</string>
<string name="label_chats">Chats</string>
<string name="label_suggested">Suggested</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,22 @@ import com.flipcash.app.core.util.Linkify
import com.flipcash.app.messenger.internal.ChatSubject
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.GroupInviteViewModel
import com.flipcash.app.core.chat.InviteLinkAction
import com.flipcash.app.core.chat.NewInviteLinkChoice
import com.flipcash.app.messenger.internal.invites.InviteLinkActions
import com.flipcash.app.messenger.internal.invites.NewInviteLinkContent
import com.flipcash.app.messenger.internal.invites.toChoice
import com.flipcash.app.messenger.internal.invites.InviteLinkActionsViewModel
import com.flipcash.app.messenger.internal.invites.InviteLinkViewModel
import com.flipcash.app.messenger.internal.invites.ManageInvitesScreen
import com.flipcash.app.messenger.internal.screens.EmojiPickerViewModel
import com.flipcash.app.messenger.internal.screens.GroupInviteSheet
import com.flipcash.app.messenger.internal.screens.MessengerScreen
import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet
import com.flipcash.app.messenger.internal.screens.ReactorsSheet
import com.flipcash.app.messenger.internal.screens.ReactorsViewModel
import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject
import com.flipcash.app.messenger.internal.screens.openGroupInvite
import com.flipcash.app.messenger.internal.request.ChatRequestsViewModel
import com.flipcash.app.messenger.internal.request.RequestDetailsScreen
import com.flipcash.app.messenger.internal.request.RequestMoneyScreen
Expand Down Expand Up @@ -170,6 +179,18 @@ private fun chatEntryProvider(
annotatedEntry<ChatStep.InviteToGroup> {
FlowGroupInviteSheet()
}
annotatedEntry<ChatStep.ShareInviteLink> { step ->
FlowInviteLinkSheet(step.inviteIdHex)
}
annotatedEntry<ChatStep.NewInviteLink> {
FlowNewInviteLinkSheet()
}
annotatedEntry<ChatStep.InviteLinkActions> { step ->
FlowInviteLinkActionsSheet(step.inviteIdHex)
}
annotatedEntry<ChatStep.ManageInvites> {
ManageInvitesScreen(flowSharedViewModel<ChatViewModel>().stateFlow.collectAsStateWithLifecycle().value.chatId)
}
annotatedEntry<ChatStep.ShareMessage> { step ->
FlowShareMessageSheet(step.messageId)
}
Expand Down Expand Up @@ -514,6 +535,109 @@ private fun FlowGroupInviteSheet() {
)
}

/**
* [ChatStep.ShareInviteLink]: the invite sheet's invite variant (frames 1b, 1f). The tap on Share Invite
* Link that opened it has the link made when the host had none; sending goes through the same view model as the
* join variant, limited to chats an invite link may be sent into.
*/
@Composable
private fun FlowInviteLinkSheet(inviteIdHex: String?) {
val viewModel = flowSharedViewModel<ChatViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val inviteViewModel = hiltViewModel<GroupInviteViewModel>()
val inviteState by inviteViewModel.state.collectAsStateWithLifecycle()
val linkViewModel = hiltViewModel<InviteLinkViewModel>()
val link by linkViewModel.state.collectAsStateWithLifecycle()
val dismissSheet = LocalBottomSheetDismissDispatcher.current
val keyboard = rememberKeyboardController()
val navigator = LocalCodeNavigator.current
val resultBack = resultBackNavigator<GroupInviteResult>(exit = dismissSheet)
val flowNavigator = rememberFlowNavigator<ChatStep, Parcelable>()

LaunchedEffect(inviteViewModel, state.chatId) {
state.chatId?.let(inviteViewModel::exclude)
inviteViewModel.restrictToEncrypted(true)
}
LaunchedEffect(linkViewModel, state.chatId, inviteIdHex) {
state.chatId?.let { linkViewModel.open(it, inviteIdHex) }
}
LaunchedEffect(inviteViewModel) {
inviteViewModel.invited.collect { chatId ->
keyboard.hideIfVisible { resultBack.returnValue(GroupInviteResult(chatId)) }
}
}

GroupInviteSheet(
inviteUrl = null,
subject = ShareToChatsSubject.GroupInviteLink(state.subject as? ChatSubject.Group, link.link),
state = inviteState,
onShare = linkViewModel::onShare,
onCopy = linkViewModel::onCopy,
onToggle = inviteViewModel::toggle,
onMessageChanged = inviteViewModel::onMessageChanged,
onInvite = linkViewModel::onSend,
onDismiss = dismissSheet,
inviteEffects = linkViewModel.effects,
onInviteEffect = { effect ->
when (effect) {
is InviteLinkViewModel.Effect.Send -> inviteViewModel.invite(effect.url)
// 1e's offer: the plain join link, in place of this sheet.
InviteLinkViewModel.Effect.OpenJoinLink -> {
dismissSheet()
navigator.openGroupInvite()
}
// 1c's offer: revoke a link there to make room for another.
InviteLinkViewModel.Effect.OpenManageInvites -> {
dismissSheet()
flowNavigator.navigateTo(ChatStep.ManageInvites)
}
InviteLinkViewModel.Effect.Close -> dismissSheet()
else -> Unit
}
},
)
}

/**
* [ChatStep.InviteLinkActions]: frame 2b on the app's sheet scene, like the other choice sheets.
* It returns the pick and closes; Manage Invites, still beneath it, shares or confirms the revoke.
* An invite that is gone by the time the sheet draws closes it, as a missing share link does.
*/
@Composable
private fun FlowInviteLinkActionsSheet(inviteIdHex: String) {
val chatId = flowSharedViewModel<ChatViewModel>().stateFlow.collectAsStateWithLifecycle().value.chatId
val viewModel = hiltViewModel<InviteLinkActionsViewModel>()
val dismissSheet = LocalBottomSheetDismissDispatcher.current
val resultBack = resultBackNavigator<InviteLinkAction>(exit = dismissSheet)
val invite = chatId?.let { viewModel.invite(it, inviteIdHex) }

LaunchedEffect(invite == null && chatId != null) {
if (invite == null && chatId != null) dismissSheet()
}
invite ?: return

InviteLinkActions(
invite = invite,
onShare = { resultBack.returnValue(InviteLinkAction.Share) },
onRevoke = { resultBack.returnValue(InviteLinkAction.Revoke) },
onDismiss = dismissSheet,
)
}

/**
* [ChatStep.NewInviteLink]: frame 2d on the app's sheet scene. It returns the chosen settings and
* closes; Manage Invites, still beneath it, makes the link and shows the creating state.
*/
@Composable
private fun FlowNewInviteLinkSheet() {
val dismissSheet = LocalBottomSheetDismissDispatcher.current
val resultBack = resultBackNavigator<NewInviteLinkChoice>(exit = dismissSheet)
NewInviteLinkContent(
onCreate = { resultBack.returnValue(it.toChoice()) },
onDismiss = dismissSheet,
)
}

/**
* [ChatStep.ShareMessage]: the invite sheet handing out one message's link. Same send path and
* result as [FlowGroupInviteSheet]; the link is the whole subject.
Expand Down
Loading
Loading