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
ac7ea8a
feat(router): route /chat/invite ahead of /chat/{uuid}
bmc08gt Oct 28, 2026
0af418a
fix(chat): keep dead-invite join results out of error reporting
bmc08gt Oct 28, 2026
ea32e78
feat(chat): word invite-link joins as "joined with {inviter}'s invite…
bmc08gt Oct 28, 2026
ffe74ca
feat(chat): join a private group with an invite link; test that invit…
bmc08gt Oct 28, 2026
d9f8e42
feat(messenger): invite landing screen for /chat/invite links
bmc08gt Oct 28, 2026
da7e435
feat(messenger): wrong-key banner and disabled composer for a group t…
bmc08gt Oct 28, 2026
1647cc5
test(messenger): cover the invite landing, its calls and the wrong-ke…
bmc08gt Oct 28, 2026
54096f8
test(messenger): render the invite landing and in-chat invite states
bmc08gt Oct 28, 2026
66e4c8f
fix(chat): match JoinChat results with an invite to the spec table
bmc08gt Oct 28, 2026
3525ef8
fix(chat): keep expected JoinChat outcomes out of error reporting
bmc08gt Oct 28, 2026
9e69dd0
fix(router): send an unclaimed invite-shaped link to the broken-invit…
bmc08gt Oct 28, 2026
03473e9
fix(messenger): seed the invite landing from the lobby and pop on dea…
bmc08gt Oct 28, 2026
440a05a
fix(chat-ui): derive the inviter link span from the string resource
bmc08gt Oct 28, 2026
00c0bc6
fix(scanner): share the invite route mapping and cover it
bmc08gt Oct 28, 2026
a51a764
fix(messenger): never unfurl an invite URL on the website host
bmc08gt Oct 28, 2026
f1ec1c5
fix(messenger): keep Join Group white while the join is in flight
bmc08gt Oct 28, 2026
6a400b1
test(messenger): render frames 4b and 4c and fix the 5c/5d file names
bmc08gt Oct 28, 2026
4d4acf4
fix(messenger): open a tapped invite link in the app
bmc08gt Oct 28, 2026
2bedabf
feat(messenger): draw an invite link as a group card from GetInvite
bmc08gt Oct 28, 2026
beca728
fix(chat): show an invite URL as "Invite link" in previews
bmc08gt Oct 28, 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 @@ -46,6 +46,7 @@ import com.flipcash.app.invite.InviteContactScreen
import com.flipcash.app.messenger.ChatFlowScreen
import com.flipcash.app.tokens.internal.CommunityChatsScreen
import com.flipcash.app.messenger.CurrencySendScreen
import com.flipcash.app.messenger.internal.screens.profile.GroupInviteScreen
import com.flipcash.app.messenger.E2eeLearnMoreScreen
import com.flipcash.app.messenger.MuteChatScreen
import com.flipcash.app.messenger.ProfileScreen
Expand Down Expand Up @@ -145,6 +146,7 @@ internal fun appEntryProvider(
ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
}
annotatedEntry<AppRoute.Messaging.GroupInvite> { key -> GroupInviteScreen(key.secret) }
annotatedEntry<AppRoute.Messaging.Profile> { key -> ProfileScreen(key.address, key.origin) }
annotatedEntry<AppRoute.Messaging.NewChat> { NewChatScreen() }
annotatedEntry<AppRoute.Messaging.ArchivedChats> { ArchivedChatsScreen() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,20 @@ sealed interface AppRoute : NavKey, Parcelable {
}
}

/**
* A group invite link opened by the joiner (nodes 12846:59 to 12846:259). [secret] is the
* link's key material, so it is redacted from [toString]; null is a link with no valid
* secret, which shows the broken-link state rather than a chat.
*/
@Serializable
@Parcelize
class GroupInvite(val secret: ByteArray?) : Messaging {
override fun equals(other: Any?): Boolean =
other is GroupInvite && secret.contentEquals(other.secret)
override fun hashCode(): Int = secret.contentHashCode()
override fun toString(): String = "GroupInvite(redacted)"
}

/**
* Node 10127:117987 — the two ways to start a chat, reached from the "+" on the Chats list.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ sealed interface DeeplinkType: Parcelable {
*/
@Serializable data class GroupChatInvite(val chatId: ChatId): DeeplinkType, Navigatable

/**
* A group invite link — `app.flipcash.com/chat/invite#s=<secret>`. The secret is the link's
* key material: it is held as bytes, never in [toString] or anything built from it, and no
* caller logs or reports it.
*/
@Serializable
class GroupInvite(val secret: ByteArray) : DeeplinkType, Navigatable {
override fun equals(other: Any?): Boolean = other is GroupInvite && secret.contentEquals(other.secret)
override fun hashCode(): Int = secret.contentHashCode()
override fun toString(): String = "GroupInvite(redacted)"
}

/** `/chat/invite` whose fragment holds no valid secret: a broken link, shown as one. */
@Serializable data object InvalidGroupInvite : DeeplinkType, Navigatable

/**
* One message in a chat — `app.flipcash.com/chat/{chatId}/m/{seq}`.
*
Expand Down Expand Up @@ -91,6 +106,8 @@ sealed interface DeeplinkType: Parcelable {
is Profile,
is GroupChatInvite,
is ChatMessage,
is GroupInvite,
is InvalidGroupInvite,
-> true

is Login,
Expand Down
30 changes: 29 additions & 1 deletion apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,7 @@
<string name="action_leaveChat">Leave Chat</string>
<string name="prompt_title_leaveChat">Leave %1$s?</string>
<!-- A private group: coming back means a new request, not an invite link (node 11206:98). -->
<string name="prompt_description_leavePrivateChat">You’ll lose access to the conversation. To rejoin, you’ll need to request again and wait for the host to approve</string>
<string name="prompt_description_leavePrivateChat">You’ll lose access to the conversation. To rejoin, you’ll need an invite link or the host’s approval</string>
<string name="prompt_description_leaveChat">You won’t receive messages from this group any more. You can join again with an invite link</string>
<string name="error_title_failedToLeave">Something Went Wrong</string>
<string name="error_description_failedToLeave">We were unable to leave this group. Please try again</string>
Expand Down Expand Up @@ -1008,6 +1008,9 @@
<string name="label_chat_system_added">%1$s added %2$s</string>
<string name="label_chat_system_addedSelf">%1$s added you</string>
<string name="label_chat_system_selfAdded">You added %1$s</string>
<string name="label_chat_system_joinedViaInvite">%1$s joined with %2$s’s invite link</string>
<string name="label_chat_system_joinedSelfViaInvite">You joined with %1$s’s invite link</string>
<string name="label_chat_system_joinedViaYourInvite">%1$s joined with your invite link</string>
<string name="label_chat_preview_sentCash_bySender">%1$s sent %2$s</string>
<string name="label_chat_preview_tippedCash_bySender">%1$s tipped %2$s</string>
<string name="label_chat_preview_sharedProfile">Shared a profile</string>
Expand Down Expand Up @@ -1211,6 +1214,9 @@
<string name="label_linkCard_untitledGroup">Group Chat</string>
<string name="action_linkCard_view">View</string>
<string name="label_linkCard_groupUnavailable">Group Unavailable</string>
<string name="label_linkCard_privateGroupInvite">Private group · Invite link</string>
<!-- Stands in for an invite URL in a chat-list preview, a push and a reply citation: its fragment is the group's key. -->
<string name="label_inviteLinkPreview">Invite link</string>
<string name="label_messageLink_kindPrivateGroup">Private group</string>
<string name="label_messageLink_kindDirect">Direct message</string>
<string name="label_messageLink_you">You</string>
Expand Down Expand Up @@ -1312,6 +1318,28 @@
<string name="action_exploreCurrencies">Explore Currencies</string>
<string name="title_newPrivateGroup">New Private Group</string>
<string name="action_requestToJoin">Request to Join</string>
<!-- Opening a group invite link (nodes 12846:59, 12846:126, 12846:148, 12846:160, 12846:220, 12846:259). -->
<string name="subtitle_privateGroupInvited">Private group · You’ve been invited to join</string>
<string name="subtitle_inviteExhausted">This invite link has reached its limit.</string>
<string name="subtitle_inviteExpired">This invite link has expired.</string>
<string name="subtitle_inviteRevoked">This invite link was turned off by the host.</string>
<string name="action_joinGroup">Join Group</string>
<string name="action_leaveGroup">Leave Group</string>
<string name="title_inviteLinkNotFound">Link Not Found</string>
<string name="description_inviteLinkNotFound">This invite link isn’t valid. Ask whoever sent it for a new one.</string>
<string name="description_inviteGroupGone">This group no longer exists</string>
<string name="title_inviteBanned">You Can’t Join This Group</string>
<string name="description_inviteBanned">The host has removed you from this group.</string>
<string name="error_title_failedToJoinGroup">Couldn’t Join Group</string>
<string name="error_description_failedToJoinGroup">Check your connection and try again.</string>
<!-- Alert-bar copy: modals take no final period. The full-screen states above keep theirs. -->
<string name="description_inviteLinkNotFoundAlert">This invite link isn’t valid. Ask whoever sent it for a new one</string>
<string name="description_inviteBannedAlert">The host has removed you from this group</string>
<string name="error_description_failedToJoinGroupAlert">Check your connection and try again</string>
<string name="error_description_failedToJoinAlert">Something went wrong. Please try again</string>
<string name="title_cantReadGroup">Can’t read this group</string>
<string name="description_cantReadGroup">The invite link you used didn’t include a working key. Leave and ask the host to approve you.</string>
<string name="hint_cantSendHere">You can’t send messages here</string>
<string name="action_requestSent">Request Sent</string>
<string name="action_cancelRequest">Cancel Request</string>
<string name="subtitle_privateGroupMembers">Private group · %1$s</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import com.flipcash.app.messenger.internal.screens.profile.privateGroupMembers
import com.flipcash.services.models.TipAction
import com.flipcash.services.models.TipOrigin
import com.flipcash.services.models.UserProfile
import com.flipcash.services.chat.WrongKeyChats
import com.flipcash.services.models.chat.ChatId
import com.flipcash.services.models.chat.LobbyMember
import com.flipcash.services.models.chat.ChatMember
Expand Down Expand Up @@ -275,6 +276,7 @@ internal class ChatViewModel @AssistedInject constructor(
*/
@Assisted private val linkedAsGroup: Boolean = false,
private val chatCoordinator: ChatCoordinator,
private val wrongKeyChats: WrongKeyChats,
private val mediaUploads: ChatMediaUploads,
private val e2eePolicy: E2eePolicy,
private val contactCoordinator: ContactCoordinator,
Expand Down Expand Up @@ -355,6 +357,11 @@ internal class ChatViewModel @AssistedInject constructor(
}

data class State(
/**
* Whether the key this group's invite link handed the viewer failed its checks, so the
* transcript cannot be read. The only repair is to leave and be admitted from the lobby.
*/
val wrongKey: Boolean = false,
val separatorConfig: SeparatorConfig = SeparatorConfig.Continuous(),
val chatId: ChatId? = null,
val subject: ChatSubject? = null,
Expand Down Expand Up @@ -868,6 +875,9 @@ internal class ChatViewModel @AssistedInject constructor(
/** The chat this screen is showing turned out to be a group, with this metadata. */
data class OnGroupResolved(val membership: ChatMembership) : Event

/** The wrong-key mark for this chat changed (see `WrongKeyChats`). */
data class OnWrongKey(val wrongKey: Boolean) : Event

/** The token cache learned the currency behind the group's balance requirement. */
data class OnRuleCurrencyResolved(val currency: RuleCurrency?) : Event

Expand Down Expand Up @@ -1353,9 +1363,14 @@ internal class ChatViewModel @AssistedInject constructor(
/**
* Follows a tapped message link inside the app. True when [uri] was one: a message in this
* chat is jumped to in place, and one in another chat is pushed over this one with a chip back
* here. False leaves [uri] to the ordinary link handling.
* here. An invite link opens its landing over this chat, so its secret stays in the app. False
* leaves [uri] to the ordinary link handling.
*/
fun followMessageLink(uri: String): Boolean {
linkCardClassifier.inviteRoute(uri)?.let { route ->
dispatchEvent(Event.OpenScreen(route))
return true
}
val link = linkCardClassifier.messageLink(uri) ?: return false
val state = stateFlow.value
if (link.chatId == state.chatId) {
Expand Down Expand Up @@ -1916,6 +1931,7 @@ internal class ChatViewModel @AssistedInject constructor(
initMentionHandlers()
initDraftHandlers()
initGroupAnalytics()
initWrongKey()

viewModelScope.launch {
// Yield to let the first frame render before setting up remaining collectors
Expand Down Expand Up @@ -2709,6 +2725,14 @@ internal class ChatViewModel @AssistedInject constructor(
.launchIn(viewModelScope)
}

/** Whether the key this account holds for the open chat is the wrong one (an invite join gone bad). */
private fun initWrongKey() {
stateFlow.map { it.chatId }.filterNotNull().distinctUntilChanged()
.flatMapLatest { wrongKeyChats.observe(it) }
.onEach { dispatchEvent(Event.OnWrongKey(it)) }
.launchIn(viewModelScope)
}

/**
* The group events that are not an RPC's answer: the gate appearing, and the taps on the
* invite sheet, an invite card, the gate's funding button and the group profile. The screens report the taps
Expand Down Expand Up @@ -4140,6 +4164,7 @@ internal class ChatViewModel @AssistedInject constructor(
if (state.subject is ChatSubject.Group) state
else state.copy(chatType = ChatType.TIP_DM)
}
is Event.OnWrongKey -> { state -> state.copy(wrongKey = event.wrongKey) }
is Event.OnGroupResolved -> { state ->
val metadata = event.membership.metadata
val previous = state.subject as? ChatSubject.Group
Expand Down
Loading
Loading