diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 0342b8e95d..617fd298d0 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -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 @@ -145,6 +146,7 @@ internal fun appEntryProvider( ChatFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } } + annotatedEntry { key -> GroupInviteScreen(key.secret) } annotatedEntry { key -> ProfileScreen(key.address, key.origin) } annotatedEntry { NewChatScreen() } annotatedEntry { ArchivedChatsScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 20aaff45cc..5704c87c20 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -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. * diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index 0734335cd8..79ce50fbff 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -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=`. 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}`. * @@ -91,6 +106,8 @@ sealed interface DeeplinkType: Parcelable { is Profile, is GroupChatInvite, is ChatMessage, + is GroupInvite, + is InvalidGroupInvite, -> true is Login, diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 240941398b..be9f6e2903 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -945,7 +945,7 @@ Leave Chat Leave %1$s? - You’ll lose access to the conversation. To rejoin, you’ll need to request again and wait for the host to approve + You’ll lose access to the conversation. To rejoin, you’ll need an invite link or the host’s approval You won’t receive messages from this group any more. You can join again with an invite link Something Went Wrong We were unable to leave this group. Please try again @@ -1008,6 +1008,9 @@ %1$s added %2$s %1$s added you You added %1$s + %1$s joined with %2$s’s invite link + You joined with %1$s’s invite link + %1$s joined with your invite link %1$s sent %2$s %1$s tipped %2$s Shared a profile @@ -1211,6 +1214,9 @@ Group Chat View Group Unavailable + Private group · Invite link + + Invite link Private group Direct message You @@ -1312,6 +1318,28 @@ Explore Currencies New Private Group Request to Join + + Private group · You’ve been invited to join + This invite link has reached its limit. + This invite link has expired. + This invite link was turned off by the host. + Join Group + Leave Group + Link Not Found + This invite link isn’t valid. Ask whoever sent it for a new one. + This group no longer exists + You Can’t Join This Group + The host has removed you from this group. + Couldn’t Join Group + Check your connection and try again. + + This invite link isn’t valid. Ask whoever sent it for a new one + The host has removed you from this group + Check your connection and try again + Something went wrong. Please try again + Can’t read this group + The invite link you used didn’t include a working key. Leave and ask the host to approve you. + You can’t send messages here Request Sent Cancel Request Private group · %1$s 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 5d9bb97600..db91f4cd26 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 @@ -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 @@ -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, @@ -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, @@ -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 @@ -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) { @@ -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 @@ -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 @@ -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 diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/InviteLandingViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/InviteLandingViewModel.kt new file mode 100644 index 0000000000..190685b9ab --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/InviteLandingViewModel.kt @@ -0,0 +1,248 @@ +package com.flipcash.app.messenger.internal + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.features.messenger.R +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.shared.chat.LobbyRequests +import com.flipcash.shared.chat.invite.DeadInviteReason +import com.flipcash.shared.chat.invite.GroupInviteLanding +import com.flipcash.shared.chat.invite.InviteJoinResult +import com.flipcash.shared.chat.invite.InviteLookup +import com.flipcash.shared.chat.invite.InviteRosterResult +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Instant + +/** What the screen shows for an opened invite link (nodes 12846:59 to 12846:160). */ +internal data class InviteLandingState( + val phase: Phase = Phase.Loading, + val group: ChatSubject.Group? = null, + /** Rows loaded so far. Kept when a later page is refused, and never fetched for a dead link. */ + val members: List = emptyList(), + val memberCount: Long = 0, + /** The link no longer admits anyone. [deadReason] says why, when it is known. */ + val dead: Boolean = false, + val deadReason: DeadInviteReason? = null, + val joining: Boolean = false, + val requesting: Boolean = false, + val requestedAt: Instant? = null, +) { + enum class Phase { Loading, Preview, NotFound, Banned, LoadFailed } +} + +/** + * Opens an invite link: looks it up, shows the group, and joins on request. The secret is held + * in this view model's memory only, never in state a log or a trace could print. + */ +@HiltViewModel +internal class InviteLandingViewModel @Inject constructor( + private val landing: GroupInviteLanding, + private val lobbyRequests: LobbyRequests, + private val resources: ResourceHelper, +) : ViewModel() { + + sealed interface Effect { + /** Land in the chat: the viewer is a member, already or just now. */ + data class OpenChat(val chatId: ChatId) : Effect + + /** Leave the preview: a join found the link or the group gone, or the viewer banned. */ + data object Pop : Effect + } + + private val _state = MutableStateFlow(InviteLandingState()) + val state: StateFlow = _state.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + + private var secret: ByteArray? = null + private var token: InviteToken? = null + private var started = false + + /** Looks the link up once; the same secret again (a recomposition, a rotation) does nothing. */ + fun open(secret: ByteArray?) { + if (started) return + started = true + if (secret == null) { + _state.update { it.copy(phase = InviteLandingState.Phase.NotFound) } + return + } + this.secret = secret + viewModelScope.launch { lookup(secret) } + } + + private suspend fun lookup(secret: ByteArray) { + when (val result = landing.lookup(secret)) { + is InviteLookup.AlreadyMember -> _effects.send(Effect.OpenChat(result.chatId)) + is InviteLookup.NotFound -> _state.update { it.copy(phase = InviteLandingState.Phase.NotFound) } + is InviteLookup.Banned -> _state.update { it.copy(phase = InviteLandingState.Phase.Banned) } + is InviteLookup.Failed -> _state.update { it.copy(phase = InviteLandingState.Phase.LoadFailed) } + is InviteLookup.Found -> { + token = result.token + val chatId = result.chat.chatId + // A viewer waiting in the lobby still joins with a live link, and one who asked + // before the link died sees that request. + viewModelScope.launch { + lobbyRequests.observe() + .map { it[chatId] } + .distinctUntilChanged() + .collect { at -> _state.update { it.copy(requestedAt = at) } } + } + _state.update { + it.copy( + phase = InviteLandingState.Phase.Preview, + group = result.chat.toSubject(), + memberCount = result.chat.rosterSummary.memberCount, + dead = result.dead != null, + deadReason = result.dead, + ) + } + // A dead link's roster is refused, so only a live one is asked for. + if (result.dead == null) loadRoster(result.chat.chatId, result.token) + } + } + } + + private suspend fun loadRoster(chatId: ChatId, token: InviteToken) { + var page: PagingToken? = null + do { + when (val result = landing.roster(chatId, token, page)) { + is InviteRosterResult.Page -> { + _state.update { it.copy(members = it.members + result.members, memberCount = result.memberCount) } + page = result.next + } + // The link stopped admitting people while the roster was being read. + is InviteRosterResult.Denied -> { + markDead(reason = recheckReason()) + return + } + is InviteRosterResult.Failed -> return + } + } while (page != null && _state.value.members.size < RosterRowCap) + } + + /** The real status, asked for again; null when it can't be learned, which is not guessed. */ + private suspend fun recheckReason(): DeadInviteReason? = + (secret?.let { landing.lookup(it) } as? InviteLookup.Found)?.dead + + private fun markDead(reason: DeadInviteReason?) { + _state.update { it.copy(dead = true, deadReason = reason, joining = false) } + } + + fun join() { + val secret = secret ?: return + val token = token ?: return + val chatId = _state.value.group?.chatId ?: return + if (_state.value.joining) return + _state.update { it.copy(joining = true) } + viewModelScope.launch { + when (val result = landing.join(secret, chatId, token)) { + is InviteJoinResult.Joined -> { + // Joining takes the viewer out of the lobby, so the stored request is stale. + lobbyRequests.clear(chatId) + this@InviteLandingViewModel.secret = null + _effects.send(Effect.OpenChat(result.chatId)) + } + is InviteJoinResult.Dead -> markDead(result.reason) + is InviteJoinResult.NotFound -> popWithAlert( + R.string.title_inviteLinkNotFound, + R.string.description_inviteLinkNotFoundAlert, + ) + is InviteJoinResult.GroupGone -> popWithAlert( + R.string.error_title_groupNotFound, + R.string.description_inviteGroupGone, + ) + is InviteJoinResult.Banned -> popWithAlert( + R.string.title_inviteBanned, + R.string.description_inviteBannedAlert, + ) + is InviteJoinResult.Refused -> { + _state.update { it.copy(joining = false) } + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToJoinGroup), + message = resources.getString(R.string.error_description_failedToJoinAlert), + ) + } + is InviteJoinResult.Failed -> { + _state.update { it.copy(joining = false) } + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToJoinGroup), + message = resources.getString(R.string.error_description_failedToJoinGroupAlert), + ) + } + } + } + } + + /** The link, the group or the viewer's standing ended the join: say so, then leave the preview. */ + private fun popWithAlert(title: Int, body: Int) { + _state.update { it.copy(joining = false) } + BottomBarManager.showError( + title = resources.getString(title), + message = resources.getString(body), + onDismiss = { _effects.trySend(Effect.Pop) }, + ) + } + + fun requestToJoin() { + val chatId = _state.value.group?.chatId ?: return + if (_state.value.requesting) return + _state.update { it.copy(requesting = true) } + viewModelScope.launch { + lobbyRequests.request(chatId) + .onSuccess { at -> _state.update { it.copy(requestedAt = at) } } + .onFailure { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToRequestJoin), + message = resources.getString(R.string.error_description_failedToRequestJoin), + ) + } + _state.update { it.copy(requesting = false) } + } + } + + fun cancelRequest() { + val chatId = _state.value.group?.chatId ?: return + if (_state.value.requesting) return + _state.update { it.copy(requesting = true) } + viewModelScope.launch { + lobbyRequests.cancel(chatId).onSuccess { _state.update { it.copy(requestedAt = null) } } + _state.update { it.copy(requesting = false) } + } + } + + private companion object { + /** Enough rows for the grid and its count; the full list is the members' to open. */ + const val RosterRowCap = 200 + } +} + +private fun ChatMetadata.toSubject() = ChatSubject.Group( + chatId = chatId, + groupTitle = title, + picture = picture, + memberCount = rosterSummary.memberCount, + rules = rules, + isMember = false, + creator = creator, + description = description, + coverPicture = coverPicture, + isPrivate = true, +) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SystemMessageItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SystemMessageItems.kt index 1743cff758..24130eb471 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SystemMessageItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/SystemMessageItems.kt @@ -3,6 +3,7 @@ package com.flipcash.app.messenger.internal import com.flipcash.services.models.chat.MessageContent import com.flipcash.shared.chat.models.ChatListItem import com.flipcash.shared.chat.models.displayText +import com.flipcash.shared.chat.models.userLinks import com.getcode.opencode.model.core.ID import com.getcode.util.resources.ResourceHelper import kotlin.time.Instant @@ -27,5 +28,6 @@ internal fun systemMessageItem( contentIndex = contentIndex, timestamp = timestamp, text = text, + userLinks = content.userLinks(resources, text, selfId, nameOf), ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/InviteLinkLookup.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/InviteLinkLookup.kt new file mode 100644 index 0000000000..c84ac0aefd --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/InviteLinkLookup.kt @@ -0,0 +1,42 @@ +package com.flipcash.app.messenger.internal.link + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.shared.chat.models.LinkCard +import com.getcode.chatcipher.InviteCipher +import javax.inject.Inject + +/** + * Reads an invite link's group for its card (spec surface 6): the secret's token, then `GetInvite`, + * which answers whether or not the viewer is in the group. Only the title, picture and member + * count reach the card, as on a join link's card, and no member is named. + * + * A link the server does not know, a banned viewer and a failed call all fail the lookup, and the + * card draws its unavailable state; the landing a tap opens says which it was. + */ +internal class InviteLinkLookup @Inject constructor( + private val inviteCipher: InviteCipher, + private val chatController: ChatController, +) { + suspend operator fun invoke(secret: ByteArray): Result = + runCatching { + val token = InviteToken(inviteCipher.inviteToken(secret)) + val preview = chatController.getInvite(token).getOrThrow() + LinkCard.InviteLink.State.Resolved( + chatId = preview.chat.chatId, + title = preview.chat.title?.takeIf { it.isNotBlank() }, + picture = preview.chat.picture, + memberCount = preview.chat.rosterSummary.memberCount, + status = preview.status.toCardStatus(), + ) + } +} + +/** UNKNOWN reads as active, as it does on the landing, which lets the join answer for it. */ +private fun Invite.Status.toCardStatus(): LinkCard.InviteLink.Status = when (this) { + Invite.Status.ACTIVE, Invite.Status.UNKNOWN -> LinkCard.InviteLink.Status.Active + Invite.Status.EXHAUSTED -> LinkCard.InviteLink.Status.Exhausted + Invite.Status.EXPIRED -> LinkCard.InviteLink.Status.Expired + Invite.Status.REVOKED -> LinkCard.InviteLink.Status.Revoked +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 8133b5d9ac..39c58af1b9 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.link import android.net.Uri import androidx.core.net.toUri +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.links.FIRST_PARTY_HOSTS import com.flipcash.app.core.navigation.DeeplinkType @@ -77,7 +78,14 @@ internal class LinkCardClassifier @Inject constructor( private fun HttpUrl.isWebsitePage(): Boolean = host in WEBSITE_HOSTS && fragment == null && - pathSegments.first().lowercase() !in SECRET_SEGMENTS + pathSegments.first().lowercase() !in SECRET_SEGMENTS && + !isInvitePath() + + /** `/chat/invite` in any case: an invite URL is never unfurled, with its fragment or without. */ + private fun HttpUrl.isInvitePath(): Boolean = + pathSegments.size >= 2 && + pathSegments[0].equals("chat", ignoreCase = true) && + pathSegments[1].equals("invite", ignoreCase = true) /** * The message a tapped link points at, when it is a message link on our hosts (wrapped in a @@ -91,6 +99,33 @@ internal class LinkCardClassifier @Inject constructor( return router.classify(DeepLink(target)) as? DeeplinkType.ChatMessage } + /** + * The invite landing a tapped `/chat/invite` link on our hosts opens, or null for any other link. + * The transcript opens it in the app rather than handing the URL to the system, so the secret in + * the fragment never reaches a browser. A link with no valid secret opens the broken-link state. + */ + fun inviteRoute(url: String): AppRoute? { + val target = unwrapJumpTarget(url) ?: url + val host = runCatching { target.toUri().host }.getOrNull() ?: return null + if (host.lowercase() !in CARD_HOSTS) return null + return when (val type = router.classify(DeepLink(target))) { + is DeeplinkType.GroupInvite -> AppRoute.Messaging.GroupInvite(type.secret) + is DeeplinkType.InvalidGroupInvite -> AppRoute.Messaging.GroupInvite(null) + else -> null + } + } + + /** + * The secret of a `/chat/invite` link on our hosts, for its card's lookup, or null when the + * link is not one or its fragment carries no valid secret. + */ + fun inviteSecret(url: String): ByteArray? { + val target = unwrapJumpTarget(url) ?: url + val host = runCatching { target.toUri().host }.getOrNull() ?: return null + if (host.lowercase() !in CARD_HOSTS) return null + return (router.classify(DeepLink(target)) as? DeeplinkType.GroupInvite)?.secret + } + /** * The entropy of [text] when the whole message is one cash link, or null. A message link's card * draws such a message as the payment it carries rather than as a raw URL; a link with any @@ -138,6 +173,15 @@ internal class LinkCardClassifier @Inject constructor( chatId = type.chatId, state = LinkCard.GroupInvite.State.Loading, ).takeIf { target.toUri().pathSegments.size == GROUP_INVITE_SEGMENTS } + // A private group's invite link. Its card is drawn from GetInvite, never from the web + // page (Decision 6). A link with no valid secret gets no card; a tap still opens the + // broken-link state. + is DeeplinkType.GroupInvite -> LinkCard.InviteLink( + url = target, + start = link.start, + end = link.end, + state = LinkCard.InviteLink.State.Loading, + ) // A person's link by id, the legacy `/tip/`. Whether anyone owns the id is the // lookup's question. is DeeplinkType.Tipcard -> LinkCard.User( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt index c63f6d6817..10f6a9aa45 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardModule.kt @@ -36,6 +36,7 @@ internal object LinkCardModule { group: GroupLinkLookup, user: UserLinkLookup, message: MessageLinkLookup, + invite: InviteLinkLookup, memory: LinkCardMemory, classifier: LinkCardClassifier, ): LinkCardResolver = @@ -49,6 +50,10 @@ internal object LinkCardModule { group = { group(it) }, user = { user(it) }, message = message::invoke, + invite = { url -> + classifier.inviteSecret(url)?.let { invite(it) } + ?: Result.failure(IllegalArgumentException("not an invite link")) + }, memory = memory, wholeCashLink = classifier::wholeCashLink, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index 522bd66dff..1c114f5587 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -54,6 +54,10 @@ internal class LinkCardResolver( private val message: suspend (card: LinkCard.Message) -> Result = { Result.failure(IllegalStateException("no message lookup")) }, + /** An invite link's group and status, by its URL; see [InviteLinkLookup]. */ + private val invite: suspend (url: String) -> Result = { + Result.failure(IllegalStateException("no invite lookup")) + }, private val memory: LinkCardMemory = LinkCardMemory(), /** The entropy when a message's whole text is one cash link; see [LinkCardClassifier.wholeCashLink]. */ private val wholeCashLink: (text: String) -> String? = { null }, @@ -77,6 +81,7 @@ internal class LinkCardResolver( private val groupQueries = mutableMapOf>() private val userQueries = mutableMapOf>() private val messageQueries = mutableMapOf>() + private val inviteQueries = mutableMapOf>() /** * The answers that have landed, readable without the lock and without suspending — which is the @@ -102,6 +107,12 @@ internal class LinkCardResolver( * membership and the group's rules, both of which a join or a purchase moves. */ private val messageAnswers = ConcurrentHashMap() + + /** + * Invite answers are held per visit and never in [memory], which is written to disk: the key is + * the link, whose fragment is the group's key. + */ + private val inviteAnswers = ConcurrentHashMap() private val tokenAnswers get() = memory.tokens private val groupAnswers get() = memory.groups private val userAnswers get() = memory.users @@ -132,6 +143,7 @@ internal class LinkCardResolver( // A web card is drawn from the preview its sender built, which arrives on the card itself. is LinkCard.Web -> null is LinkCard.Message -> messageAnswers[card.key]?.let { card.copy(state = it) } + is LinkCard.InviteLink -> inviteAnswers[card.url]?.let { card.copy(state = it) } } override suspend fun resolve(card: LinkCard): LinkCard = when (card) { @@ -141,6 +153,7 @@ internal class LinkCardResolver( is LinkCard.User -> card.copy(state = userState(card.identity)) is LinkCard.Web -> card is LinkCard.Message -> card.copy(state = messageState(card)) + is LinkCard.InviteLink -> card.copy(state = inviteState(card.url)) } /** @@ -279,6 +292,18 @@ internal class LinkCardResolver( ) } + private suspend fun inviteState(url: String): LinkCard.InviteLink.State = + memoized(inviteQueries, url) { + invite(url).fold( + onSuccess = { resolved -> resolved.also { inviteAnswers[url] = it } }, + onFailure = { + forget(inviteQueries, url) + inviteAnswers.remove(url) + LinkCard.InviteLink.State.Unavailable + }, + ) + } + private suspend fun userState(identity: LinkCard.User.Identity): LinkCard.User.State = memoized(userQueries, identity) { user(identity).fold( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 97fdeddc67..98f9b32689 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -55,6 +55,7 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.SwapResult import com.flipcash.app.core.chat.ChatIdentifier +import com.getcode.opencode.model.core.ID import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep @@ -71,6 +72,7 @@ import com.flipcash.app.messenger.internal.reactionStripFor import com.flipcash.app.messenger.internal.withLiveSelfReactions import com.flipcash.app.messenger.internal.MentionDestination import com.flipcash.app.messenger.internal.link.CashCardTap +import com.flipcash.app.messenger.internal.screens.components.WrongKeyBanner import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.deckBackdropSpec import com.flipcash.app.messenger.internal.screens.components.chatBackdrop @@ -197,6 +199,29 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { else UpdateAppAction(label = updateLabel, onClick = { scope.launch { updater.startOptionalUpdate() } }) } + val openUser = { targetId: ID, targetIsOwn: Boolean -> + val counterpart = (state.participant as? ChatParticipant.TipUser)?.userId + when { + // Your own card lives on the You tab, where scanning it and its deep link + // already send you. There is no chat with yourself to open. + targetIsOwn -> keyboard.hideIfVisible { + navigator.rootNavigator.navigateAll(listOf(AppRoute.Tabs.Menu)) + } + // A link to the person this DM is with has nowhere to go. + targetId == counterpart -> Unit + else -> keyboard.hideIfVisible { + viewModel.dispatchEvent( + ChatViewModel.Event.OpenScreen( + // The profile their flipcash.com link opens, pushed as the group + // card pushes its chat, so Back returns here. Its Message shortcut + // goes on to the DM. + AppRoute.Messaging.Profile(ProfileAddress.ById(targetId)) + ) + ) + } + } + } + val chatActionHandler = { action: ChatAction -> when (action) { is ChatAction.AdvanceReadPointer -> { @@ -304,28 +329,8 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } - is ChatAction.OpenUser -> { - val counterpart = (state.participant as? ChatParticipant.TipUser)?.userId - when { - // Your own card lives on the You tab, where scanning it and its deep link - // already send you. There is no chat with yourself to open. - action.isOwn -> keyboard.hideIfVisible { - navigator.rootNavigator.navigateAll(listOf(AppRoute.Tabs.Menu)) - } - // A link to the person this DM is with has nowhere to go. - action.userId == counterpart -> Unit - else -> keyboard.hideIfVisible { - viewModel.dispatchEvent( - ChatViewModel.Event.OpenScreen( - // The profile their flipcash.com link opens, pushed as the group - // card pushes its chat, so Back returns here. Its Message shortcut - // goes on to the DM. - AppRoute.Messaging.Profile(ProfileAddress.ById(action.userId)) - ) - ) - } - } - } + is ChatAction.OpenUser -> openUser(action.userId, action.isOwn) + is ChatAction.OpenUserById -> openUser(action.userId, action.isOwn) is ChatAction.OpenMention -> { viewModel.dispatchEvent(ChatViewModel.Event.MentionTapped(action.username)) @@ -575,6 +580,14 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { .padding(top = CodeTheme.dimens.grid.x2), ) } + if (state.wrongKey) { + WrongKeyBanner( + onLeave = { viewModel.dispatchEvent(ChatViewModel.Event.LeaveChat) }, + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2), + ) + } // Held through the exit for the same reason as the requests above. var lastReturnTitle by remember { mutableStateOf(state.returnToTitle) } state.returnToTitle?.let { lastReturnTitle = it } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 0994651544..4af8da2783 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -167,6 +167,18 @@ internal fun UserControlBottomBar( return } + // A member whose invite key failed its checks cannot read the group, so there is nothing they + // could usefully send into. The banner above the transcript carries the way out. + if (state.wrongKey) { + SpeakerGateBar( + requirement = ChatRuleRequirement.Never, + hazeState = hazeState, + currencyName = null, + textOverride = stringResource(R.string.hint_cantSendHere), + ) + return + } + // A member the speaker rules keep from speaking: nothing to type into, so the panel stands // where the composer would. Checked after the join gate, which owns every viewer outside. state.speakerBlock?.takeIf { state.isReadOnlySpeaker }?.let { block -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index a3da262819..a6c6a53e9a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -287,7 +287,13 @@ internal fun MessageRow( } is ChatListItem.SystemMessage -> Box(insertionModifier) { - SystemMessageRow(item.text) + SystemMessageRow( + text = item.text, + userLinks = item.userLinks, + onUserClick = { userId -> + onAction(ChatAction.OpenUserById(userId, isOwn = false)) + }, + ) } is ChatListItem.ContentBubble -> { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt index f001a8d9a5..46d31807a0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SpeakerGateBar.kt @@ -54,10 +54,12 @@ internal fun SpeakerGateBar( modifier: Modifier = Modifier, /** Off only for a Robolectric render, whose native runtime cannot compile the blur shader. */ blurEnabled: Boolean = true, + /** Replaces the line a [requirement] would read, for a viewer blocked for another reason. */ + textOverride: String? = null, ) { val shape = CodeTheme.shapes.medium val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) - val text = when (requirement) { + val text = textOverride ?: when (requirement) { ChatRuleRequirement.Never -> stringResource(R.string.label_chatGate_speakerNever) ChatRuleRequirement.Staff -> stringResource(R.string.subtitle_chatGate_speakerStaffOnly) ChatRuleRequirement.Creator -> stringResource(R.string.subtitle_chatGate_speakerCreatorOnly) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SystemMessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SystemMessageRow.kt index 6f1fc4caa1..a3ce031742 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SystemMessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/SystemMessageRow.kt @@ -14,6 +14,18 @@ import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withLink +import com.flipcash.shared.chat.models.SystemUserLink +import com.getcode.opencode.model.core.ID import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity @@ -35,7 +47,14 @@ import com.getcode.theme.CodeTheme * and no trimming. The glyph stays beside the first line. */ @Composable -internal fun SystemMessageRow(text: String, modifier: Modifier = Modifier) { +internal fun SystemMessageRow( + text: String, + modifier: Modifier = Modifier, + userLinks: List = emptyList(), + onUserClick: (ID) -> Unit = {}, +) { + // The text is remembered across recompositions, so the click goes through the latest handler. + val currentOnUserClick by rememberUpdatedState(onUserClick) val lineHeight = with(LocalDensity.current) { CodeTheme.typography.caption.lineHeight.toDp() } Row( @@ -57,7 +76,7 @@ internal fun SystemMessageRow(text: String, modifier: Modifier = Modifier) { } Text( modifier = Modifier.widthIn(max = SYSTEM_TEXT_MAX_WIDTH), - text = text, + text = remember(text, userLinks) { linkedText(text, userLinks) { currentOnUserClick(it) } }, // Centred in each 18sp line box, as iOS does with its baseline offset. The default // proportional split sits the glyphs high, off the glyph's centre. style = CodeTheme.typography.caption.copy( @@ -72,6 +91,30 @@ internal fun SystemMessageRow(text: String, modifier: Modifier = Modifier) { } } +/** + * [text] with each of [userLinks] tappable, drawn heavier rather than recoloured: the row's grey is + * the notice's voice, and a coloured name would read as an action. + */ +private fun linkedText( + text: String, + userLinks: List, + onUserClick: (ID) -> Unit, +): AnnotatedString = buildAnnotatedString { + var cursor = 0 + userLinks.sortedBy { it.start }.forEach { link -> + if (link.start < cursor || link.end > text.length) return@forEach + append(text, cursor, link.start) + withLink( + LinkAnnotation.Clickable( + tag = "user", + styles = TextLinkStyles(style = SpanStyle(fontWeight = FontWeight.SemiBold)), + ) { onUserClick(link.userId) } + ) { append(text, link.start, link.end) } + cursor = link.end + } + append(text, cursor, text.length) +} + private val SYSTEM_GLYPH_SIZE = 14.dp private val SYSTEM_GLYPH_GAP = 6.dp private val SYSTEM_TEXT_MAX_WIDTH = 250.dp @@ -83,6 +126,17 @@ private val SYSTEM_ROW_VERTICAL_PADDING = 6.dp private fun Preview_SystemMessageRow() { Column(modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset)) { SystemMessageRow("Alex joined the group") + SystemMessageRow( + text = "Alex joined with Jeff Yanta’s invite link", + userLinks = listOf(SystemUserLink(start = 17, end = 27, userId = listOf(1))), + ) + SystemMessageRow( + text = "You joined with Jeff Yanta’s invite link", + userLinks = listOf(SystemUserLink(start = 16, end = 26, userId = listOf(1))), + ) + SystemMessageRow( + text = "Alex joined with your invite link", + ) SystemMessageRow("Alex added Sam, Jordan and Riley to the group, and changed the group name to Weekend plans") } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/WrongKeyBanner.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/WrongKeyBanner.kt new file mode 100644 index 0000000000..b40e28fcfa --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/WrongKeyBanner.kt @@ -0,0 +1,64 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.flipcash.app.theme.FlipcashPreview + +/** + * Node 12846:259 (4d). The invite link handed the viewer a key that failed its checks, so the + * group cannot be read. Leaving is the only repair the contract offers. + */ +@Composable +internal fun WrongKeyBanner( + onLeave: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = CodeTheme.shapes.medium + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .padding(CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_cantReadGroup), + style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + ) + Text( + text = stringResource(R.string.description_cantReadGroup), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + CodeButton( + modifier = Modifier.fillMaxWidth().padding(top = CodeTheme.dimens.grid.x2), + text = stringResource(R.string.action_leaveGroup), + buttonState = ButtonState.Bordered, + onClick = onLeave, + ) + } +} + +@Preview +@Composable +private fun WrongKeyBannerPreview() { + FlipcashPreview(showBackground = true) { + WrongKeyBanner(onLeave = {}, modifier = Modifier.padding(CodeTheme.dimens.inset)) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupInvitePreviews.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupInvitePreviews.kt new file mode 100644 index 0000000000..90c4ee3165 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupInvitePreviews.kt @@ -0,0 +1,95 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.InviteLandingState +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.chat.invite.DeadInviteReason +import kotlin.time.Instant + +/** The states of the invite landing (nodes 12846:59 to 12846:259), shared by the previews and their render test. */ +internal object InviteLandingSamples { + private val chatId = ChatId(ByteArray(16) { 5 }.toList()) + + private val names = listOf("Moonyversal", "Adam", "Jeff Yanta", "Alex", "Tokin", "Shebah", "Sam", "Ana", "Raj") + + private fun member(index: Int, name: String) = ChatMember( + userId = listOf(index.toByte()), + userProfile = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + userId = listOf(index.toByte()), + ), + pointers = emptyList(), + joinedAt = Instant.fromEpochSeconds(100L + index), + version = 1, + ) + + private val group = ChatSubject.Group( + chatId = chatId, + groupTitle = "Moony", + picture = null, + memberCount = 48, + rules = null, + isMember = false, + description = "A place for people who hold and use Moony to meet, tip each other, and share what they're building. Hosted by Moonyversal.", + isPrivate = true, + ) + + /** 3a: a live link. */ + val preview = InviteLandingState( + phase = InviteLandingState.Phase.Preview, + group = group, + members = names.mapIndexed { i, n -> member(i + 1, n) }, + memberCount = 48, + ) + + /** 4a: Join Group pressed, the call in flight. */ + val joining = preview.copy(joining = true) + + /** 3c: a link that no longer admits anyone, one state per reason. */ + fun dead(reason: DeadInviteReason) = preview.copy(members = emptyList(), dead = true, deadReason = reason) + + /** 3e. */ + val notFound = InviteLandingState(phase = InviteLandingState.Phase.NotFound) + + /** 3f. */ + val banned = InviteLandingState(phase = InviteLandingState.Phase.Banned) + + /** 4c, shown under the Couldn't Join Group alert. */ + val loadFailed = InviteLandingState(phase = InviteLandingState.Phase.LoadFailed) +} + +@Preview(name = "3a invite") +@Composable +private fun Preview_Invite() = FlipcashPreview(showBackground = true) { GroupInviteContent(InviteLandingSamples.preview, {}, {}, {}, {}) } + +@Preview(name = "4a joining") +@Composable +private fun Preview_Joining() = FlipcashPreview(showBackground = true) { GroupInviteContent(InviteLandingSamples.joining, {}, {}, {}, {}) } + +@Preview(name = "3c exhausted") +@Composable +private fun Preview_Exhausted() = FlipcashPreview(showBackground = true) { GroupInviteContent(InviteLandingSamples.dead(DeadInviteReason.EXHAUSTED), {}, {}, {}, {}) } + +@Preview(name = "3c expired") +@Composable +private fun Preview_Expired() = FlipcashPreview(showBackground = true) { GroupInviteContent(InviteLandingSamples.dead(DeadInviteReason.EXPIRED), {}, {}, {}, {}) } + +@Preview(name = "3c revoked") +@Composable +private fun Preview_Revoked() = FlipcashPreview(showBackground = true) { GroupInviteContent(InviteLandingSamples.dead(DeadInviteReason.REVOKED), {}, {}, {}, {}) } + +@Preview(name = "3e not found") +@Composable +private fun Preview_NotFound() = FlipcashPreview(showBackground = true) { GroupInviteContent(InviteLandingSamples.notFound, {}, {}, {}, {}) } + +@Preview(name = "3f banned") +@Composable +private fun Preview_Banned() = FlipcashPreview(showBackground = true) { GroupInviteContent(InviteLandingSamples.banned, {}, {}, {}, {}) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupInviteScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupInviteScreen.kt new file mode 100644 index 0000000000..4e078fe0d2 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupInviteScreen.kt @@ -0,0 +1,371 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ReportSubject +import com.flipcash.app.messenger.internal.InviteLandingState +import com.flipcash.app.messenger.internal.InviteLandingViewModel +import com.flipcash.app.messenger.internal.screens.components.ChatSubjectAvatar +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.ChatId +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.shared.chat.invite.DeadInviteReason +import com.flipcash.shared.common.ui.profile.ChattingGridItem +import com.flipcash.shared.common.ui.profile.GroupChattingGrid +import com.flipcash.shared.common.ui.profile.ProfileHeader +import com.flipcash.shared.common.ui.profile.ProfilePinnedActionBar +import com.flipcash.shared.common.ui.profile.ProfileTopScrollEdge +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.CircularIconButton +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState + +/** + * What `/chat/invite#s=…` opens. A member goes straight to the chat; anyone else sees the group + * and can join with the link, or, once it is dead, ask to join. + * + * @param secret the link's secret, or null for a link whose fragment holds none. + */ +@Composable +fun GroupInviteScreen(secret: ByteArray?) { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { viewModel.open(secret) } + LaunchedEffect(viewModel) { + viewModel.effects.collect { effect -> + when (effect) { + // Replaces this screen, so back from the chat does not land on the link again. + is InviteLandingViewModel.Effect.OpenChat -> + navigator.replace(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(effect.chatId))) + InviteLandingViewModel.Effect.Pop -> navigator.pop() + } + } + } + + GroupInviteContent( + state = state, + onBack = { navigator.pop() }, + onJoin = viewModel::join, + onRequest = viewModel::requestToJoin, + onCancelRequest = viewModel::cancelRequest, + onEncryption = { navigator.push(AppRoute.Messaging.E2eePrivateGroupInfo) }, + onReport = { chatId -> navigator.push(AppRoute.Messaging.Report(ReportSubject.Chat(chatId))) }, + ) +} + +@Composable +internal fun GroupInviteContent( + state: InviteLandingState, + onBack: () -> Unit, + onJoin: () -> Unit, + onRequest: () -> Unit, + onCancelRequest: () -> Unit, + onEncryption: () -> Unit = {}, + onReport: (ChatId) -> Unit = {}, +) { + when (state.phase) { + InviteLandingState.Phase.Loading -> InviteLoading(onBack) + InviteLandingState.Phase.NotFound -> InviteNotice( + title = stringResource(R.string.title_inviteLinkNotFound), + body = stringResource(R.string.description_inviteLinkNotFound), + onBack = onBack, + ) + InviteLandingState.Phase.Banned -> InviteNotice( + title = stringResource(R.string.title_inviteBanned), + body = stringResource(R.string.description_inviteBanned), + onBack = onBack, + ) + InviteLandingState.Phase.LoadFailed -> InviteNotice( + title = stringResource(R.string.error_title_failedToJoinGroup), + body = stringResource(R.string.error_description_failedToJoinGroup), + onBack = onBack, + ) + InviteLandingState.Phase.Preview -> InvitePreview( + state = state, + onBack = onBack, + onJoin = onJoin, + onRequest = onRequest, + onCancelRequest = onCancelRequest, + onEncryption = onEncryption, + onReport = onReport, + ) + } +} + +@Composable +private fun InviteLoading(onBack: () -> Unit) { + CodeScaffold { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding)) { + AppBarWithTitle(onBackIconClicked = onBack) + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = CodeTheme.colors.textSecondary, + ) + } + } +} + +/** Nodes 12846:148 and 12846:160: a title, a line, and OK, centred under a back button. */ +@Composable +private fun InviteNotice(title: String, body: String, onBack: () -> Unit) { + CodeScaffold { padding -> + Box(modifier = Modifier.fillMaxSize().padding(padding).testTag("invite_notice")) { + AppBarWithTitle(onBackIconClicked = onBack) + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = CodeTheme.dimens.inset * 2), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = title, + style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.Bold), + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + text = body, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + CodeButton( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2), + buttonState = ButtonState.Subtle, + text = stringResource(R.string.action_ok), + onClick = onBack, + ) + } + } + } +} + +/** + * Nodes 12846:59 and 12846:126: the group as a requester sees it on its profile, with the invite + * in place of the host-approves line. Built from the profile's own pieces; the profile screen + * itself is bound to a chat the viewer does not have. + */ +@Composable +private fun InvitePreview( + state: InviteLandingState, + onBack: () -> Unit, + onJoin: () -> Unit, + onRequest: () -> Unit, + onCancelRequest: () -> Unit, + onEncryption: () -> Unit, + onReport: (ChatId) -> Unit, +) { + val group = state.group + val hazeState = rememberHazeState() + var pinnedHeight by remember { mutableStateOf(0.dp) } + var menuOpen by remember { mutableStateOf(false) } + val unknownName = stringResource(R.string.title_unnamedUser) + val members = remember(state.members, group?.creator, unknownName) { + state.members.take(MembersGridLimit).map { member -> + val profile = member.userProfile + ChattingGridItem( + userId = member.userId, + name = chatterName(profile.displayName, profile.username, unknownName), + picture = profile.profilePicture, + isHost = member.userId == group?.creator, + ) + } + } + + CodeScaffold { padding -> + BoxWithConstraints( + modifier = Modifier.fillMaxSize().padding(padding).testTag("group_invite_screen"), + ) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState) + .verticalScroll(scrollState) + .heightIn(min = maxHeight) + .padding(bottom = pinnedHeight), + ) { + ProfileHeader( + cover = group?.coverPicture, + access = group?.let { BlobAccessContext.ChatProfile(it.chatId) } ?: BlobAccessContext.Owned, + avatar = { modifier -> ChatSubjectAvatar(subject = group, modifier = modifier) }, + title = group?.title.orEmpty(), + subtitle = null, + body = group?.description?.takeIf { it.isNotBlank() }, + actions = {}, + ) + + // A dead link whose reason is not known gets no line rather than a guessed one. + val note = if (state.dead) { + state.deadReason?.let { deadReasonText(it) } + } else { + stringResource(R.string.subtitle_privateGroupInvited) + } + if (note != null) { + PrivateGroupNote( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x3), + summary = note, + detail = null, + ) + } + + // Nothing is fetched for a link that was dead on arrival; rows that arrived before + // it died stay. + GroupChattingGrid( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x6), + chatters = members, + title = pluralStringResource( + R.plurals.subtitle_memberCount, + state.memberCount.toInt(), + state.memberCount.toString(), + ), + onOpen = {}, + ) + } + + ProfileTopScrollEdge(hazeState = hazeState, scrollState = scrollState) + + AppBarWithTitle( + onBackIconClicked = onBack, + hazeState = hazeState, + endContent = { + val density = LocalDensity.current + var anchorHeight by remember { mutableStateOf(0.dp) } + Box(modifier = Modifier.onSizeChanged { anchorHeight = with(density) { it.height.toDp() } }) { + CircularIconButton(hazeState = hazeState, onClick = { menuOpen = true }) { size -> + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(R.string.action_moreProfileActions), + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + GroupProfileMenu( + expanded = menuOpen, + items = groupProfileMenuActions(isMember = false), + isPrivate = true, + anchorHeight = anchorHeight, + onDismiss = { menuOpen = false }, + onItem = { item -> + when (item) { + GroupProfileMenuAction.Encryption -> onEncryption() + GroupProfileMenuAction.Report -> group?.chatId?.let(onReport) + // A non-member has no notification setting to change. + GroupProfileMenuAction.Mute -> Unit + } + }, + ) + } + }, + ) + + val join = if (state.dead) { + PrivateGroupJoin.resolve(isPrivate = true, isMember = false, requestedAt = state.requestedAt) + } else { + null + } + val bar = Modifier.align(Alignment.BottomCenter) + if (join != null) { + PrivateJoinBar( + modifier = bar, + join = join, + hostName = null, + lobbyCall = when { + !state.requesting -> null + state.requestedAt == null -> LobbyCall.Request + else -> LobbyCall.Cancel + }, + onRequest = onRequest, + onCancel = onCancelRequest, + hazeState = hazeState, + onHeightChanged = { pinnedHeight = it }, + ) + } else { + JoinGroupBar( + modifier = bar, + joining = state.joining, + onJoin = onJoin, + hazeState = hazeState, + onHeightChanged = { pinnedHeight = it }, + ) + } + } + } +} + +@Composable +private fun JoinGroupBar( + joining: Boolean, + onJoin: () -> Unit, + hazeState: HazeState, + onHeightChanged: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + ProfilePinnedActionBar( + modifier = modifier, + text = stringResource(R.string.action_joinGroup), + // Frame 12846:175 keeps the button white while it spins; `isLoading` already blocks the + // tap and the view model ignores a second join. + onClick = onJoin, + isLoading = joining, + hazeState = hazeState, + onHeightChanged = onHeightChanged, + ) +} + +/** The line that stands where "You've been invited to join" does. */ +@Composable +private fun deadReasonText(reason: DeadInviteReason): String = when (reason) { + DeadInviteReason.EXHAUSTED -> stringResource(R.string.subtitle_inviteExhausted) + DeadInviteReason.EXPIRED -> stringResource(R.string.subtitle_inviteExpired) + DeadInviteReason.REVOKED -> stringResource(R.string.subtitle_inviteRevoked) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt index 2bc55d51d1..a3ebeac1a0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileScreen.kt @@ -278,7 +278,15 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { modifier = Modifier .padding(horizontal = CodeTheme.dimens.inset) .padding(top = CodeTheme.dimens.staticGrid.x3), - memberCount = group.memberCount, + summary = stringResource( + R.string.subtitle_privateGroupMembers, + pluralStringResource( + R.plurals.subtitle_memberCount, + group.memberCount.toInt(), + group.memberCount.toString(), + ), + ), + detail = stringResource(R.string.subtitle_hostApprovesJoins), ) } @@ -530,7 +538,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { /** The ⋯ button's menu: Encryption and Mute, then a divider and the red Report. */ @Composable -private fun GroupProfileMenu( +internal fun GroupProfileMenu( expanded: Boolean, items: List, isPrivate: Boolean, @@ -572,11 +580,15 @@ private fun GroupProfileMenu( private val RuleNameHold = 1500.milliseconds /** Four rows of portraits; the count beside the title opens the rest. */ -private const val MembersGridLimit = 16 +internal const val MembersGridLimit = 16 -/** "Private group · 48 members" over "The host approves who joins", behind a lock. */ +/** + * A line behind a lock under a private group's description, with an optional second line. A + * member count reads "Private group · 48 members" over "The host approves who joins"; an invite + * link replaces both ([summary] alone, nodes 12846:59 and 12846:126). + */ @Composable -private fun PrivateGroupNote(memberCount: Long, modifier: Modifier = Modifier) { +internal fun PrivateGroupNote(summary: String, detail: String?, modifier: Modifier = Modifier) { Column(modifier = modifier) { Row( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), @@ -589,19 +601,18 @@ private fun PrivateGroupNote(memberCount: Long, modifier: Modifier = Modifier) { tint = CodeTheme.colors.textSecondary, ) Text( - text = stringResource( - R.string.subtitle_privateGroupMembers, - pluralStringResource(R.plurals.subtitle_memberCount, memberCount.toInt(), memberCount.toString()), - ), + text = summary, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + if (detail != null) { + Text( + text = detail, style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) } - Text( - text = stringResource(R.string.subtitle_hostApprovesJoins), - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.textSecondary, - ) } } @@ -610,7 +621,7 @@ private fun PrivateGroupNote(memberCount: Long, modifier: Modifier = Modifier) { * with Cancel Request under it and how long the request has waited above it. */ @Composable -private fun PrivateJoinBar( +internal fun PrivateJoinBar( join: PrivateGroupJoin, hostName: String?, lobbyCall: LobbyCall?, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index e0d0a9e60e..8c785cc0ab 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -131,6 +131,7 @@ class ChatEventStallTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 4e5bfd8091..201137c91e 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -140,6 +140,7 @@ class ChatGroupAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index f4f5f5e070..8766b176aa 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -167,6 +167,7 @@ class ChatGroupCashLinkTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index 0dabae0b9a..ba4d6d6382 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -88,6 +88,7 @@ class ChatMentionPickerTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = mockk(relaxed = true), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 429aa1ce9d..063432f1fc 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -98,6 +98,7 @@ class ChatOpenTranscriptTest { identifier: ChatIdentifier? = null, ): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt index 2c6a66deac..a4d184db23 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatPreviewsReadyTest.kt @@ -78,6 +78,7 @@ class ChatPreviewsReadyTest { memory: LinkCardMemory?, ): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt index b3ca18df9b..62119e785f 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatReturnToOriginTest.kt @@ -97,6 +97,7 @@ class ChatReturnToOriginTest { identifier: ChatIdentifier? = null, ): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index e640c60601..63d22d5b09 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -131,6 +131,7 @@ class ChatSendFailureAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = com.flipcash.services.chat.WrongKeyChats(), mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt index a2150dde72..b26e464431 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatTypingGateTest.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -75,6 +76,10 @@ class ChatTypingGateTest { displayNumber = "(555) 123-4567", ) private val chatId = ChatId(UUID.randomUUID().bytes) + private val wrongKey = MutableStateFlow(false) + private val wrongKeyChats = mockk { + every { observe(any()) } returns wrongKey + } @Before fun setUp() { @@ -95,6 +100,7 @@ class ChatTypingGateTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, + wrongKeyChats = wrongKeyChats, mediaUploads = noMediaUploads(), e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, @@ -165,4 +171,82 @@ class ChatTypingGateTest { assertTrue(state.typingConstraints.resolved) assertFalse(state.typingConstraints.enabled) } + + @Test + fun `a wrong-key mark on the open chat reaches state and clears with it`() = runTest { + every { contactCoordinator.lookupContact(any()) } returns Result.failure(NoSuchElementException()) + coEvery { contactCoordinator.lookupContactByDmChatId(any()) } returns null + every { chatCoordinator.observeMetadata(chatId) } returns flowOf( + ChatMembership( + metadata = mockk(relaxed = true) { every { this@mockk.type } returns ChatType.TIP_DM }, + isMember = true, + ), + ) + + val viewModel = createViewModel() + viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByChatId(chatId))) + advanceUntilIdle() + assertFalse(viewModel.stateFlow.value.wrongKey) + + wrongKey.value = true + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.wrongKey) + + wrongKey.value = false + advanceUntilIdle() + assertFalse(viewModel.stateFlow.value.wrongKey) + } + + @Test + fun `the wrong-key event sets the flag and nothing else`() = runTest { + val viewModel = createViewModel() + val before = viewModel.stateFlow.value + viewModel.dispatchEvent(ChatViewModel.Event.OnWrongKey(true)) + advanceUntilIdle() + assertEquals(before.copy(wrongKey = true), viewModel.stateFlow.value) + + viewModel.dispatchEvent(ChatViewModel.Event.OnWrongKey(false)) + advanceUntilIdle() + assertEquals(before, viewModel.stateFlow.value) + } + + @Test + fun `the wrong-key mark follows the chat the screen switches to`() = runTest { + val other = ChatId(UUID.randomUUID().bytes) + every { contactCoordinator.lookupContact(any()) } returns Result.failure(NoSuchElementException()) + coEvery { contactCoordinator.lookupContactByDmChatId(any()) } returns null + for (id in listOf(chatId, other)) { + every { chatCoordinator.observeMetadata(id) } returns flowOf( + ChatMembership( + metadata = mockk(relaxed = true) { every { this@mockk.type } returns ChatType.TIP_DM }, + isMember = true, + ), + ) + } + val first = MutableStateFlow(true) + val second = MutableStateFlow(false) + every { wrongKeyChats.observe(chatId) } returns first + every { wrongKeyChats.observe(other) } returns second + coEvery { chatCoordinator.hydrateChat(other) } returns ChatHydration.Stored + every { chatCoordinator.observeMessages(other) } returns flowOf(emptyList()) + + val viewModel = createViewModel() + viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByChatId(chatId))) + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.wrongKey) + + viewModel.dispatchEvent(ChatViewModel.Event.OnChatOpened(ChatIdentifier.ByChatId(other))) + advanceUntilIdle() + assertFalse(viewModel.stateFlow.value.wrongKey) + + // The old chat's mark changing is no longer this screen's business. + first.value = false + first.value = true + advanceUntilIdle() + assertFalse(viewModel.stateFlow.value.wrongKey) + + second.value = true + advanceUntilIdle() + assertTrue(viewModel.stateFlow.value.wrongKey) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteChatScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteChatScreenshotTest.kt new file mode 100644 index 0000000000..aa7fb1ed4d --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteChatScreenshotTest.kt @@ -0,0 +1,87 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.components.SpeakerGateBar +import com.flipcash.app.messenger.internal.screens.components.SystemMessageRow +import com.flipcash.app.messenger.internal.screens.components.WrongKeyBanner +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.shared.chat.models.SystemUserLink +import dev.chrisbanes.haze.rememberHazeState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** Renders the in-chat invite states (nodes 12846:259, 12846:374, 12846:427 host, 12846:480 joiner). Not an assertion test. */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [32], qualifiers = "w390dp-h400dp-xxhdpi") +class InviteChatScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun render(name: String, content: @Composable () -> Unit) { + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Column(Modifier.width(390.dp).padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { content() } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width.coerceAtLeast(1), root.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val file = File(File("build/screenshots").apply { mkdirs() }, name) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath}") + } + + @Test + fun wrongKey_4d() = render("invite_4d.png") { + WrongKeyBanner(onLeave = {}) + SpeakerGateBar( + requirement = ChatRuleRequirement.Never, + hazeState = rememberHazeState(), + currencyName = null, + blurEnabled = false, + textOverride = "You can’t send messages here", + ) + } + + @Test + fun joinedWithInvite_5b() = render("invite_5b.png") { + SystemMessageRow( + text = "Alex joined with Jeff Yanta’s invite link", + userLinks = listOf(SystemUserLink(start = 17, end = 27, userId = listOf(1))), + ) + } + + @Test + fun youJoinedWithInvite_5d() = render("invite_5d.png") { + SystemMessageRow( + text = "You joined with Jeff Yanta’s invite link", + userLinks = listOf(SystemUserLink(start = 16, end = 26, userId = listOf(1))), + ) + } + + @Test + fun joinedWithYourInvite_5c() = render("invite_5c.png") { + SystemMessageRow(text = "Alex joined with your invite link") + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteLandingScreenshotTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteLandingScreenshotTest.kt new file mode 100644 index 0000000000..15cf4df03f --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteLandingScreenshotTest.kt @@ -0,0 +1,115 @@ +package com.flipcash.app.messenger.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import com.flipcash.features.messenger.R +import com.getcode.manager.BottomBarManager +import com.getcode.ui.components.bars.BottomBarView +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.profile.GroupInviteContent +import com.flipcash.app.messenger.internal.screens.profile.InviteLandingSamples +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.shared.chat.invite.DeadInviteReason +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders each invite landing state to `build/screenshots/` so it can be set beside its frame. + * Not an assertion test. Same mechanics as [GroupChromeScreenshotTest]. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +// API 32: the frosted-glass button modifier compiles an AGSL shader on 33+, which Robolectric cannot. +@Config(sdk = [32], qualifiers = "w390dp-h844dp-xxhdpi") +class InviteLandingScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + /** + * [then], when given, replaces [state] after the first frames, as the view model does when + * Join Group is pressed, so a button keeps the size it was laid out with. + */ + private fun render( + name: String, + state: InviteLandingState, + then: InviteLandingState? = null, + overlay: @Composable BoxScope.() -> Unit = {}, + ) { + composeRule.mainClock.autoAdvance = false + var current by mutableStateOf(state) + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Box(Modifier.width(390.dp).height(844.dp)) { + GroupInviteContent(current, {}, {}, {}, {}) + overlay() + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + if (then != null) { + composeRule.runOnUiThread { + current = then + Snapshot.sendApplyNotifications() + } + repeat(20) { composeRule.mainClock.advanceTimeByFrame() } + } + val root: View = composeRule.activity.findViewById(android.R.id.content) + val bitmap = Bitmap.createBitmap(root.width.coerceAtLeast(1), root.height.coerceAtLeast(1), Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val file = File(File("build/screenshots").apply { mkdirs() }, name) + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})") + } + + @Test fun invite_3a() = render("invite_3a.png", InviteLandingSamples.preview) + @Test fun joining_4a() = render("invite_4a.png", InviteLandingSamples.preview, then = InviteLandingSamples.joining) + @Test fun exhausted_3c() = render("invite_3c_exhausted.png", InviteLandingSamples.dead(DeadInviteReason.EXHAUSTED)) + @Test fun expired_3c() = render("invite_3c_expired.png", InviteLandingSamples.dead(DeadInviteReason.EXPIRED)) + @Test fun revoked_3c() = render("invite_3c_revoked.png", InviteLandingSamples.dead(DeadInviteReason.REVOKED)) + /** 4b: the link died between the preview and the tap; the screen becomes 3c in place, no alert. */ + @Test fun diedBeforeTap_4b() = render("invite_4b.png", InviteLandingSamples.preview, then = InviteLandingSamples.dead(DeadInviteReason.EXHAUSTED)) + + /** 4c: a transport failure leaves the preview as it was, under the app-level alert. */ + @Test fun networkFailure_4c() = render("invite_4c.png", InviteLandingSamples.preview) { + val activity = composeRule.activity + val message = remember { + BottomBarManager.clear() + BottomBarManager.showError( + title = activity.getString(R.string.error_title_failedToJoinGroup), + message = activity.getString(R.string.error_description_failedToJoinGroupAlert), + ) + BottomBarManager.messages.value.last().also { BottomBarManager.clear() } + } + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) + Box(Modifier.align(Alignment.BottomCenter)) { + BottomBarView(bottomBarMessage = message, onShown = {}, onClose = {}, onBackPressed = {}) + } + } + + @Test fun notFound_3e() = render("invite_3e.png", InviteLandingSamples.notFound) + @Test fun banned_3f() = render("invite_3f.png", InviteLandingSamples.banned) +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteLandingViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteLandingViewModelTest.kt new file mode 100644 index 0000000000..ff5f574617 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/InviteLandingViewModelTest.kt @@ -0,0 +1,443 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.shared.chat.LobbyRequests +import com.flipcash.shared.chat.invite.DeadInviteReason +import com.flipcash.shared.chat.invite.GroupInviteLanding +import com.flipcash.shared.chat.invite.InviteJoinResult +import com.flipcash.shared.chat.invite.InviteLookup +import com.flipcash.shared.chat.invite.InviteRosterResult +import com.getcode.manager.BottomBarManager +import com.getcode.manager.SelectedBottomBarAction +import com.flipcash.features.messenger.R +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** Each way an invite link can land (nodes 12846:59 to 12846:160) and each way a join can end. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class InviteLandingViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val landing = mockk() + private val lobby = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + + private val chatId = ChatId(ByteArray(16) { 5 }.toList()) + private val token = InviteToken(ByteArray(32) { 9 }) + private val secret = ByteArray(32) { 1 } + private val member = mockk(relaxed = true) + + private fun chat(count: Long = 48) = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(1_000), + title = "Moony", + rosterSummary = RosterSummary(memberCount = count, version = 1), + ) + + private fun viewModel() = InviteLandingViewModel(landing, lobby, resources) + + private val requests = MutableStateFlow>(emptyMap()) + private val requestedAt = Instant.fromEpochMilliseconds(5_000) + + @Before + fun setUp() { + BottomBarManager.clear() + every { lobby.observe() } returns requests + every { resources.getString(R.string.title_inviteLinkNotFound) } returns "Link Not Found" + every { resources.getString(R.string.description_inviteLinkNotFoundAlert) } returns "This invite link isn’t valid. Ask whoever sent it for a new one" + every { resources.getString(R.string.error_title_groupNotFound) } returns "Group Not Found" + every { resources.getString(R.string.description_inviteGroupGone) } returns "This group no longer exists" + every { resources.getString(R.string.title_inviteBanned) } returns "You Can’t Join This Group" + every { resources.getString(R.string.description_inviteBannedAlert) } returns "The host has removed you from this group" + } + + @After + fun tearDown() = BottomBarManager.clear() + + private fun found(dead: DeadInviteReason? = null) = InviteLookup.Found(token, chat(), dead) + + private fun page(next: PagingToken? = null) = InviteRosterResult.Page(listOf(member), next, 48) + + @Test + fun `a missing secret is the not-found frame`() { + val vm = viewModel().apply { open(null) } + assertEquals(InviteLandingState.Phase.NotFound, vm.state.value.phase) + } + + @Test + fun `an unknown invite is the not-found frame`() { + coEvery { landing.lookup(any()) } returns InviteLookup.NotFound + val vm = viewModel().apply { open(secret) } + assertEquals(InviteLandingState.Phase.NotFound, vm.state.value.phase) + } + + @Test + fun `a banned viewer sees the banned frame`() { + coEvery { landing.lookup(any()) } returns InviteLookup.Banned + val vm = viewModel().apply { open(secret) } + assertEquals(InviteLandingState.Phase.Banned, vm.state.value.phase) + } + + @Test + fun `a failed lookup is its own state`() { + coEvery { landing.lookup(any()) } returns InviteLookup.Failed + val vm = viewModel().apply { open(secret) } + assertEquals(InviteLandingState.Phase.LoadFailed, vm.state.value.phase) + } + + @Test + fun `an existing member goes straight to the chat`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { landing.lookup(any()) } returns InviteLookup.AlreadyMember(chatId) + val vm = viewModel().apply { open(secret) } + assertEquals(InviteLandingViewModel.Effect.OpenChat(chatId), vm.effects.first()) + } + + @Test + fun `a live link previews the group with its roster`() { + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns page() + val vm = viewModel().apply { open(secret) } + val state = vm.state.value + assertEquals(InviteLandingState.Phase.Preview, state.phase) + assertEquals(false, state.dead) + assertEquals(listOf(member), state.members) + assertEquals("Moony", state.group?.title) + } + + @Test + fun `a dead link shows its reason and never fetches the roster`() { + coEvery { landing.lookup(any()) } returns found(DeadInviteReason.EXPIRED) + val vm = viewModel().apply { open(secret) } + assertTrue(vm.state.value.dead) + assertEquals(DeadInviteReason.EXPIRED, vm.state.value.deadReason) + coVerify(exactly = 0) { landing.roster(any(), any(), any()) } + } + + @Test + fun `a roster refusal mid-paging keeps the loaded rows and goes dead`() { + val next = listOf(2) + coEvery { landing.lookup(any()) } returnsMany listOf(found(), found(DeadInviteReason.REVOKED)) + coEvery { landing.roster(any(), any(), null) } returns page(next) + coEvery { landing.roster(any(), any(), next) } returns InviteRosterResult.Denied + val vm = viewModel().apply { open(secret) } + val state = vm.state.value + assertEquals(listOf(member), state.members) + assertTrue(state.dead) + assertEquals(DeadInviteReason.REVOKED, state.deadReason) + } + + @Test + fun `a refusal whose reason cannot be learned shows no guessed reason`() { + val next = listOf(2) + coEvery { landing.lookup(any()) } returnsMany listOf(found(), InviteLookup.Failed) + coEvery { landing.roster(any(), any(), null) } returns page(next) + coEvery { landing.roster(any(), any(), next) } returns InviteRosterResult.Denied + val vm = viewModel().apply { open(secret) } + assertTrue(vm.state.value.dead) + assertNull(vm.state.value.deadReason) + } + + @Test + fun `joining lands in the chat`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns page() + coEvery { landing.join(any(), chatId, token) } returns InviteJoinResult.Joined(chatId) + val vm = viewModel().apply { open(secret) } + vm.join() + assertEquals(InviteLandingViewModel.Effect.OpenChat(chatId), vm.effects.first()) + } + + @Test + fun `a dead invite on join switches in place with no alert`() { + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns page() + coEvery { landing.join(any(), chatId, token) } returns InviteJoinResult.Dead(DeadInviteReason.EXHAUSTED) + val vm = viewModel().apply { open(secret) } + vm.join() + val state = vm.state.value + assertTrue(state.dead) + assertEquals(DeadInviteReason.EXHAUSTED, state.deadReason) + assertEquals(false, state.joining) + assertTrue(BottomBarManager.messages.value.isEmpty()) + } + + private fun openLive(): InviteLandingViewModel { + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns page() + return viewModel().apply { open(secret) } + } + + private fun dismissAlert() { + val alert = BottomBarManager.messages.value.single() + alert.onClose(SelectedBottomBarAction(index = -1)) + } + + private suspend fun assertPopsAfterAlert( + result: InviteJoinResult, + title: String, + body: String, + ) { + BottomBarManager.clear() + coEvery { landing.join(any(), chatId, token) } returns result + val vm = openLive() + vm.join() + + val alert = BottomBarManager.messages.value.single() + assertEquals(title, alert.title) + assertEquals(body, alert.subtitle) + // Still on the preview, and re-enabled, until the alert is dismissed. + assertEquals(InviteLandingState.Phase.Preview, vm.state.value.phase) + assertEquals(false, vm.state.value.joining) + + dismissAlert() + assertEquals(InviteLandingViewModel.Effect.Pop, vm.effects.first()) + } + + @Test + fun `join with an unknown invite alerts then pops the preview`() = runTest(mainCoroutineRule.dispatcher) { + assertPopsAfterAlert( + InviteJoinResult.NotFound, + "Link Not Found", + "This invite link isn’t valid. Ask whoever sent it for a new one", + ) + } + + @Test + fun `join into a group that is gone alerts then pops the preview`() = runTest(mainCoroutineRule.dispatcher) { + assertPopsAfterAlert(InviteJoinResult.GroupGone, "Group Not Found", "This group no longer exists") + } + + @Test + fun `join when banned alerts then pops the preview`() = runTest(mainCoroutineRule.dispatcher) { + assertPopsAfterAlert( + InviteJoinResult.Banned, + "You Can’t Join This Group", + "The host has removed you from this group", + ) + } + + @Test + fun `the open-time not-found and banned lookups keep their full-screen frames`() { + coEvery { landing.lookup(any()) } returnsMany listOf(InviteLookup.NotFound, InviteLookup.Banned) + val notFound = viewModel().apply { open(secret) } + assertEquals(InviteLandingState.Phase.NotFound, notFound.state.value.phase) + val banned = viewModel().apply { open(secret) } + assertEquals(InviteLandingState.Phase.Banned, banned.state.value.phase) + assertTrue(BottomBarManager.messages.value.isEmpty()) + } + + @Test + fun `a viewer who already asked sees the request on a dead link`() { + requests.value = mapOf(chatId to requestedAt) + coEvery { landing.lookup(any()) } returns found(DeadInviteReason.EXPIRED) + val vm = viewModel().apply { open(secret) } + assertTrue(vm.state.value.dead) + assertEquals(requestedAt, vm.state.value.requestedAt) + } + + @Test + fun `a request for another chat is not this one's`() { + requests.value = mapOf(ChatId(ByteArray(16) { 6 }.toList()) to requestedAt) + val vm = openLive() + assertNull(vm.state.value.requestedAt) + } + + @Test + fun `a viewer waiting in the lobby still sees a live link as joinable`() { + requests.value = mapOf(chatId to requestedAt) + val vm = openLive() + assertEquals(false, vm.state.value.dead) + assertEquals(InviteLandingState.Phase.Preview, vm.state.value.phase) + } + + @Test + fun `a request that leaves the store leaves the screen`() { + requests.value = mapOf(chatId to requestedAt) + val vm = openLive() + requests.value = emptyMap() + assertNull(vm.state.value.requestedAt) + } + + @Test + fun `joining clears the stored request for that chat`() = runTest(mainCoroutineRule.dispatcher) { + requests.value = mapOf(chatId to requestedAt) + coEvery { landing.join(any(), chatId, token) } returns InviteJoinResult.Joined(chatId) + val vm = openLive() + vm.join() + coVerify(exactly = 1) { lobby.clear(chatId) } + assertEquals(InviteLandingViewModel.Effect.OpenChat(chatId), vm.effects.first()) + } + + @Test + fun `a join that does not land leaves the stored request alone`() { + coEvery { landing.join(any(), chatId, token) } returns InviteJoinResult.Dead(DeadInviteReason.REVOKED) + val vm = openLive() + vm.join() + coVerify(exactly = 0) { lobby.clear(any()) } + } + + @Test + fun `after joining the held secret is gone, so a second join does nothing`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { landing.join(any(), chatId, token) } returns InviteJoinResult.Joined(chatId) + val vm = openLive() + vm.join() + vm.join() + coVerify(exactly = 1) { landing.join(any(), any(), any()) } + } + + @Test + fun `a double tap on join makes one call`() = runTest(mainCoroutineRule.dispatcher) { + val gate = CompletableDeferred() + coEvery { landing.join(any(), chatId, token) } coAnswers { gate.await() } + val vm = openLive() + vm.join() + vm.join() + assertTrue(vm.state.value.joining) + gate.complete(InviteJoinResult.Joined(chatId)) + coVerify(exactly = 1) { landing.join(any(), any(), any()) } + } + + @Test + fun `requesting to join records the time and a failure alerts`() { + every { resources.getString(R.string.error_title_failedToRequestJoin) } returns "Couldn't Request" + every { resources.getString(R.string.error_description_failedToRequestJoin) } returns "Try again" + coEvery { lobby.request(chatId) } returnsMany + listOf(Result.failure(RuntimeException("offline")), Result.success(requestedAt)) + coEvery { landing.lookup(any()) } returns found(DeadInviteReason.EXPIRED) + val vm = viewModel().apply { open(secret) } + + vm.requestToJoin() + assertNull(vm.state.value.requestedAt) + assertEquals(false, vm.state.value.requesting) + assertEquals("Couldn't Request", BottomBarManager.messages.value.single().title) + + vm.requestToJoin() + assertEquals(requestedAt, vm.state.value.requestedAt) + assertEquals(false, vm.state.value.requesting) + } + + @Test + fun `cancelling clears the request, and a failed cancel keeps it`() { + requests.value = mapOf(chatId to requestedAt) + coEvery { landing.lookup(any()) } returns found(DeadInviteReason.EXPIRED) + coEvery { lobby.cancel(chatId) } returnsMany + listOf(Result.failure(RuntimeException("offline")), Result.success(Unit)) + val vm = viewModel().apply { open(secret) } + assertEquals(requestedAt, vm.state.value.requestedAt) + + vm.cancelRequest() + assertEquals(requestedAt, vm.state.value.requestedAt) + assertEquals(false, vm.state.value.requesting) + + vm.cancelRequest() + assertNull(vm.state.value.requestedAt) + assertEquals(false, vm.state.value.requesting) + } + + @Test + fun `request and cancel before the group has loaded do nothing`() { + val vm = viewModel() + vm.requestToJoin() + vm.cancelRequest() + coVerify(exactly = 0) { lobby.request(any()) } + coVerify(exactly = 0) { lobby.cancel(any()) } + } + + @Test + fun `roster pages append in order`() { + val first = mockk(relaxed = true) + val second = mockk(relaxed = true) + val next = listOf(2) + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns InviteRosterResult.Page(listOf(first), next, 48) + coEvery { landing.roster(any(), any(), next) } returns InviteRosterResult.Page(listOf(second), null, 49) + val vm = viewModel().apply { open(secret) } + assertEquals(listOf(first, second), vm.state.value.members) + assertEquals(49L, vm.state.value.memberCount) + } + + @Test + fun `roster paging stops at the row cap`() { + val a = listOf(1) + val b = listOf(2) + val rows = List(100) { mockk(relaxed = true) } + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns InviteRosterResult.Page(rows, a, 500) + coEvery { landing.roster(any(), any(), a) } returns InviteRosterResult.Page(rows, b, 500) + coEvery { landing.roster(any(), any(), b) } returns InviteRosterResult.Page(rows, null, 500) + val vm = viewModel().apply { open(secret) } + assertEquals(200, vm.state.value.members.size) + coVerify(exactly = 0) { landing.roster(any(), any(), b) } + } + + @Test + fun `a failed later page keeps the rows already loaded`() { + val next = listOf(2) + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns page(next) + coEvery { landing.roster(any(), any(), next) } returns InviteRosterResult.Failed + val vm = viewModel().apply { open(secret) } + assertEquals(listOf(member), vm.state.value.members) + assertEquals(false, vm.state.value.dead) + } + + @Test + fun `a refused join stays on the preview with the generic alert`() { + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns page() + coEvery { landing.join(any(), chatId, token) } returns InviteJoinResult.Refused + every { resources.getString(R.string.error_title_failedToJoinGroup) } returns "Couldn't Join Group" + every { resources.getString(R.string.error_description_failedToJoinAlert) } returns "Something went wrong. Please try again" + val vm = viewModel().apply { open(secret) } + vm.join() + assertEquals(InviteLandingState.Phase.Preview, vm.state.value.phase) + assertEquals(false, vm.state.value.joining) + val alert = BottomBarManager.messages.value.single() + assertEquals("Couldn't Join Group", alert.title) + assertEquals("Something went wrong. Please try again", alert.subtitle) + } + + @Test + fun `a failed join stays on the preview with the alert`() { + coEvery { landing.lookup(any()) } returns found() + coEvery { landing.roster(any(), any(), null) } returns page() + coEvery { landing.join(any(), chatId, token) } returns InviteJoinResult.Failed + val vm = viewModel().apply { open(secret) } + vm.join() + assertEquals(InviteLandingState.Phase.Preview, vm.state.value.phase) + assertEquals(false, vm.state.value.joining) + assertTrue(BottomBarManager.messages.value.isNotEmpty()) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index 56bc825e6f..d361c43742 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -1,5 +1,6 @@ package com.flipcash.app.messenger.internal.link +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.Router @@ -50,7 +51,9 @@ class LinkCardClassifierTest { ?.let { DeeplinkType.TokenInfo(Mint(it)) } // As `AppRouter` does: a message link is matched first, on its exact shape; // otherwise the id segment is checked and anything after it is not. - "chat" -> link.messageLinkForTest() ?: link.pathSegmentsForTest().getOrNull(1) + // `/chat/invite` is ahead of every other chat route, as in `AppRouter`; whether + // its fragment parses does not matter to the classifier. + "chat" -> link.inviteLinkForTest() ?: link.messageLinkForTest() ?: link.pathSegmentsForTest().getOrNull(1) ?.let { runCatching { UUID.fromString(it) }.getOrNull() } ?.let { DeeplinkType.GroupChatInvite(ChatId(it.bytes)) } "tip" -> link.pathSegmentsForTest().getOrNull(1) @@ -63,6 +66,16 @@ class LinkCardClassifierTest { override fun dispatch(deepLink: DeepLink) = error("not used in classification tests") } + private fun DeepLink.inviteLinkForTest(): DeeplinkType? { + val segments = pathSegmentsForTest() + if (segments.size != 2 || segments[1] != "invite") return null + return if (data.contains("#s=")) { + DeeplinkType.GroupInvite(ByteArray(32)) + } else { + DeeplinkType.InvalidGroupInvite + } + } + private fun DeepLink.messageLinkForTest(): DeeplinkType.ChatMessage? { val segments = pathSegmentsForTest() if (segments.size != 4 || segments[2] != "m") return null @@ -270,6 +283,111 @@ class LinkCardClassifierTest { assertEquals(LinkCard.GroupInvite.State.Loading, group?.state) } + /** + * Spec surface 6 and Decision 6: an invite URL gets the card drawn from GetInvite and never a + * web card, since its fragment is the group's key. `link_detection.json` has no `/chat/invite` + * case; its `group-invite` vector is the plain join link, which still gets a group card. + */ + @Test + fun `an invite link becomes an invite card, and one with no secret gets none`() { + val classifier = LinkCardClassifier(router) + val invite = "https://app.flipcash.com/chat/invite#s=${"A".repeat(43)}" + for (url in listOf( + invite, + "https://jump.flipcash.com/#source=" + java.net.URLEncoder.encode(invite, "UTF-8"), + )) { + val card = assertIs(classifier.firstCard(listOf(DetectedUrl(0, url.length, url))), url) + assertEquals(invite, card.url, url) + assertEquals(LinkCard.InviteLink.State.Loading, card.state, url) + } + for (url in listOf("https://app.flipcash.com/chat/invite")) { + assertNull(classifier.firstCard(listOf(DetectedUrl(0, url.length, url))), url) + } + } + + @Test + fun `an invite card's toString leaves the secret out`() { + val url = "https://app.flipcash.com/chat/invite#s=${"A".repeat(43)}" + val card = LinkCard.InviteLink(url = url, start = 0, end = url.length, state = LinkCard.InviteLink.State.Loading) + assertFalse("A".repeat(43) in card.toString()) + } + + @Test + fun `an invite link beside an outside link is the card, not a web preview of either`() { + val classifier = LinkCardClassifier(router) + val invite = "https://app.flipcash.com/chat/invite#s=${"A".repeat(43)}" + val outside = "https://example.com/post" + + val card = classifier.firstCard( + listOf( + DetectedUrl(0, invite.length, invite), + DetectedUrl(invite.length + 1, invite.length + 1 + outside.length, outside), + ) + ) + + assertEquals(invite, assertIs(card).url) + } + + /** + * The fake router above stands in for the parser. These run the real `AppRouter` (through the + * module that builds it), so a route the fake mis-states cannot hide an invite card. + */ + @Test + fun `the real router gives an invite link its invite card on every host and wrapper`() { + val classifier = LinkCardClassifier( + com.flipcash.app.router.inject.RouterModule.providesRouter(io.mockk.mockk(relaxed = true)), + ) + val secret = "A".repeat(43) + val invite = "/chat/invite#s=$secret" + for (url in listOf( + "https://app.flipcash.com$invite", + "https://flipcash.com$invite", + "https://jump.flipcash.com/#source=" + + java.net.URLEncoder.encode("https://app.flipcash.com$invite", "UTF-8"), + )) { + assertIs(classifier.firstCard(listOf(DetectedUrl(0, url.length, url))), url) + assertEquals(32, classifier.inviteSecret(url)?.size, url) + } + // No secret: no card, and the website rule does not unfurl it either. + for (url in listOf( + "https://app.flipcash.com/chat/invite", + "https://app.flipcash.com/chat/invite#s=", + "https://flipcash.com/chat/invite", + )) { + assertNull(classifier.firstCard(listOf(DetectedUrl(0, url.length, url))), url) + assertNull(classifier.inviteSecret(url), url) + } + assertNull(classifier.inviteSecret("https://example.com$invite")) + } + + /** + * A tapped invite link opens its landing in the app, never the browser, so the secret in the + * fragment stays on the device. Runs the real router, as the card test above does. + */ + @Test + fun `a tapped invite link opens the invite landing in the app`() { + val classifier = LinkCardClassifier( + com.flipcash.app.router.inject.RouterModule.providesRouter(io.mockk.mockk(relaxed = true)), + ) + val invite = "/chat/invite#s=${"A".repeat(43)}" + for (url in listOf( + "https://app.flipcash.com$invite", + "https://flipcash.com$invite", + "https://jump.flipcash.com/#source=" + + java.net.URLEncoder.encode("https://app.flipcash.com$invite", "UTF-8"), + )) { + val route = assertIs(classifier.inviteRoute(url), url) + assertEquals(32, route.secret?.size, url) + } + + assertEquals( + AppRoute.Messaging.GroupInvite(null), + classifier.inviteRoute("https://app.flipcash.com/chat/invite"), + ) + assertNull(classifier.inviteRoute("https://example.com$invite")) + assertNull(classifier.inviteRoute("https://app.flipcash.com/chat/${UUID.randomUUID()}")) + } + /** The send-cash path under a chat opens a payment, not the group, so it stays a plain link. */ @Test fun `a chat send cash link stays a link`() { diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index d6d34afc53..d69ae7ef55 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -733,4 +733,52 @@ class LinkCardResolverTest { assertEquals(state, resolved) } + + private val inviteCard = LinkCard.InviteLink( + url = "https://app.flipcash.com/chat/invite#s=${"A".repeat(43)}", + start = 0, + end = 0, + state = LinkCard.InviteLink.State.Loading, + ) + + private val inviteResolved = LinkCard.InviteLink.State.Resolved( + chatId = ChatId(ByteArray(16) { 7 }), + title = "Night Owls", + picture = null, + memberCount = 12, + status = LinkCard.InviteLink.Status.Active, + ) + + @Test + fun `an invite card resolves from its lookup and paints resolved on the next frame`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + invite = { Result.success(inviteResolved) }, + ) + + assertEquals(inviteResolved, (resolver.resolve(inviteCard) as LinkCard.InviteLink).state) + assertEquals(inviteResolved, (resolver.peek(inviteCard) as LinkCard.InviteLink).state) + } + + @Test + fun `a failed invite lookup draws unavailable and is asked again`() = runTest { + var calls = 0 + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("unused")) }, + tokenMetadata = { Result.failure(IllegalStateException("unused")) }, + group = { Result.failure(IllegalStateException("unused")) }, + user = { Result.failure(IllegalStateException("unused")) }, + invite = { calls++; Result.failure(IllegalStateException("offline")) }, + ) + + assertEquals(LinkCard.InviteLink.State.Unavailable, (resolver.resolve(inviteCard) as LinkCard.InviteLink).state) + assertNull(resolver.peek(inviteCard)) + resolver.resolve(inviteCard) + assertEquals(2, calls) + } } diff --git a/apps/flipcash/features/scanner/build.gradle.kts b/apps/flipcash/features/scanner/build.gradle.kts index 2ee179eaa5..9e4467d409 100644 --- a/apps/flipcash/features/scanner/build.gradle.kts +++ b/apps/flipcash/features/scanner/build.gradle.kts @@ -31,4 +31,6 @@ dependencies { // `ui:components` keeps haze to itself, but `CircularIconButton` takes a `HazeState`, so a // caller wanting the frosted fill has to be able to name the type. implementation(libs.bundles.haze) + + testImplementation(kotlin("test")) } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/InviteRoutes.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/InviteRoutes.kt new file mode 100644 index 0000000000..f0bc1e3dc9 --- /dev/null +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/InviteRoutes.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.scanner.internal + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.DeeplinkType + +/** + * Where a scanned `/chat/invite` code goes: the same destination as the tapped link. A code + * with no valid secret opens the broken-link state, not nothing. Null for any other code. + */ +internal fun DeeplinkType.Navigatable.groupInviteRoutes(): List? = when (this) { + is DeeplinkType.GroupInvite -> listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.GroupInvite(secret), + ) + is DeeplinkType.InvalidGroupInvite -> listOf( + AppRoute.Tabs.Chats, + AppRoute.Messaging.GroupInvite(null), + ) + else -> null +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 33a89775ab..9740fa0757 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -151,6 +151,9 @@ internal fun Scanner() { jumpToMessageId = deeplink.messageId, ), ) + // Same destination as the tapped /chat/invite link. + is DeeplinkType.GroupInvite, + is DeeplinkType.InvalidGroupInvite -> deeplink.groupInviteRoutes().orEmpty() else -> emptyList() } if (routes.isNotEmpty()) { diff --git a/apps/flipcash/features/scanner/src/test/kotlin/com/flipcash/app/scanner/internal/InviteRoutesTest.kt b/apps/flipcash/features/scanner/src/test/kotlin/com/flipcash/app/scanner/internal/InviteRoutesTest.kt new file mode 100644 index 0000000000..236f1d3684 --- /dev/null +++ b/apps/flipcash/features/scanner/src/test/kotlin/com/flipcash/app/scanner/internal/InviteRoutesTest.kt @@ -0,0 +1,33 @@ +package com.flipcash.app.scanner.internal + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.DeeplinkType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class InviteRoutesTest { + + private val secret = ByteArray(32) { it.toByte() } + + @Test + fun `a scanned invite opens the invite under the chats tab`() { + assertEquals( + listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.GroupInvite(secret)), + DeeplinkType.GroupInvite(secret).groupInviteRoutes(), + ) + } + + @Test + fun `a scanned broken invite opens the broken-link state`() { + assertEquals( + listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.GroupInvite(null)), + DeeplinkType.InvalidGroupInvite.groupInviteRoutes(), + ) + } + + @Test + fun `another navigable code is not an invite`() { + assertNull(DeeplinkType.TipChat(com.flipcash.services.models.chat.ChatId(ByteArray(16).toList())).groupInviteRoutes()) + } +} diff --git a/apps/flipcash/features/search/build.gradle.kts b/apps/flipcash/features/search/build.gradle.kts index fbdf9b5d88..4d137880ef 100644 --- a/apps/flipcash/features/search/build.gradle.kts +++ b/apps/flipcash/features/search/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt index ec33f54827..b2649370d5 100644 --- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt +++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/MessageSearch.kt @@ -4,6 +4,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.WidgetContent +import com.flipcash.shared.chat.ui.labelInviteLinks import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -40,10 +41,21 @@ private fun searchableText(content: MessageContent): String? = when (content) { else -> null } -/** The messages in [messages] whose text contains [query] (already folded), newest first. */ -internal fun searchMessages(messages: List, query: String): List = +/** + * The messages in [messages] whose text contains [query] (already folded), newest first. + * + * Each invite link reads as [inviteLinkLabel], both for matching and in the hit's text. Its fragment + * is the group's key, so a query can't find a message by its secret and a result never shows it. + */ +internal fun searchMessages( + messages: List, + query: String, + inviteLinkLabel: String, +): List = messages.mapNotNull { (chatId, message) -> - val text = searchableText(message.content) ?: return@mapNotNull null + val text = searchableText(message.content) + ?.let { labelInviteLinks(it, inviteLinkLabel) } + ?: return@mapNotNull null if (!SearchMatching.matches(query, text)) return@mapNotNull null MessageHit( chatId = chatId, diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchResults.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchResults.kt index 1b5708e77c..295cbc3172 100644 --- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchResults.kt +++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchResults.kt @@ -80,11 +80,12 @@ internal fun buildResults( currencies: List, currencyShares: Map, senderName: (ID) -> String?, + inviteLinkLabel: String, ): SearchResults { val nameOf = { id: ID? -> id?.let(senderName) } val chatsById: Map = chats.associateBy { it.chatId } - val messageResults = searchMessages(messages.filter { it.chatId in chatsById }, query) + val messageResults = searchMessages(messages.filter { it.chatId in chatsById }, query, inviteLinkLabel) .map { hit -> MessageResult(chatsById.getValue(hit.chatId), hit, nameOf(hit.senderId)) } val linkResults = filterLinks( diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt index 7a8de96a19..27171650cc 100644 --- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt +++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt @@ -1,5 +1,6 @@ package com.flipcash.app.search.internal +import com.flipcash.core.R import com.getcode.opencode.exchange.Exchange import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow @@ -57,6 +58,8 @@ internal class SearchViewModel @Inject constructor( private val recentStore: RecentSearchStore, ) : ViewModel() { + private val inviteLinkLabel = resources.getString(R.string.label_inviteLinkPreview) + val query = TextFieldState() private val _chip = MutableStateFlow(SearchChip.All) @@ -129,6 +132,7 @@ internal class SearchViewModel @Inject constructor( currencies = currencies, currencyShares = transcripts.currencyShares, senderName = { id -> directory.profiles.nameOf(id) }, + inviteLinkLabel = inviteLinkLabel, ) } .flowOn(dispatchers.Default) diff --git a/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/MessageSearchInviteTest.kt b/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/MessageSearchInviteTest.kt new file mode 100644 index 0000000000..8f0b9ac0c3 --- /dev/null +++ b/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/MessageSearchInviteTest.kt @@ -0,0 +1,58 @@ +package com.flipcash.app.search.internal + +import com.flipcash.services.models.chat.MessageContent +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** An invite link's fragment is the group's key, so Search neither matches on it nor shows it. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class MessageSearchInviteTest { + + private val secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + private val invite = "https://app.flipcash.com/chat/invite#s=$secret" + + private fun find(query: String, vararg messages: StoredMessage) = + searchMessages(messages.toList(), SearchMatching.query(query)!!, inviteLinkLabel = "Invite link") + + @Test + fun `a hit shows Invite link in place of the URL and keeps the text around it`() { + val hits = find("join", stored(1, text("join us $invite tonight"))) + assertEquals(listOf("join us Invite link tonight"), hits.map { it.text }) + } + + @Test + fun `a photo caption with an invite link is labelled too`() { + val hits = find("photo", stored(1, MessageContent.Media(emptyList(), text("photo $invite")))) + assertEquals(listOf("photo Invite link"), hits.map { it.text }) + } + + @Test + fun `an invite link in a reply is labelled`() { + val hits = find("here", stored(2, MessageContent.Reply(1, listOf(text("here $invite"))))) + assertEquals(listOf("here Invite link"), hits.map { it.text }) + } + + @Test + fun `the secret is not searchable`() { + assertTrue(find(secret.take(12), stored(1, text(invite))).isEmpty()) + assertTrue(find("chat/invite", stored(1, text(invite))).isEmpty()) + } + + @Test + fun `a bad-secret invite URL is labelled`() { + val hits = find("try", stored(1, text("try https://app.flipcash.com/chat/invite#s=not!a*valid~secret"))) + assertEquals(listOf("try Invite link"), hits.map { it.text }) + } + + @Test + fun `a plain join link is left alone`() { + val join = "https://app.flipcash.com/chat/3f2c1b9a-0d4e-4c8b-9a1f-2b3c4d5e6f70" + val hits = find("join", stored(1, text("join $join"))) + assertEquals(listOf("join $join"), hits.map { it.text }) + } +} diff --git a/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/MessageSearchTest.kt b/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/MessageSearchTest.kt index b6ec24a401..a77b7b70f8 100644 --- a/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/MessageSearchTest.kt +++ b/apps/flipcash/features/search/src/test/kotlin/com/flipcash/app/search/internal/MessageSearchTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertEquals class MessageSearchTest { private fun find(query: String, vararg messages: StoredMessage) = - searchMessages(messages.toList(), SearchMatching.query(query)!!).map { it.messageId } + searchMessages(messages.toList(), SearchMatching.query(query)!!, inviteLinkLabel = "Invite link").map { it.messageId } @Test fun `text, captions and replies are searched, newest first`() { diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt index 4cf56bf7e6..ed9ab49521 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -124,6 +124,8 @@ val DeeplinkType.analytics: String is DeeplinkType.TipChat -> "TipChat" is DeeplinkType.GroupChatInvite -> "GroupChatInvite" is DeeplinkType.ChatMessage -> "ChatMessage" + is DeeplinkType.GroupInvite -> "GroupInvite" + is DeeplinkType.InvalidGroupInvite -> "InvalidGroupInvite" is DeeplinkType.Tipcard -> "Tipcard" is DeeplinkType.Profile -> "Profile" is DeeplinkType.EmailVerification -> "EmailVerification" diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 4c83d5d8a7..bb085882de 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -52,6 +52,12 @@ sealed interface ChatAction { */ data class OpenUser(val userId: ID, val profile: UserProfile, val isOwn: Boolean) : ChatAction + /** + * Opens the person a tapped name in a system notice names. Where [OpenUser] arrives with the + * profile a card already resolved, a notice carries the id alone. + */ + data class OpenUserById(val userId: ID, val isOwn: Boolean) : ChatAction + /** * Opens whoever a tapped `@handle` names. Only the handle travels: nothing is looked up until * the tap, and the handler decides where it leads once it has. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 1f56e7e4a3..a43e139354 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -73,6 +73,8 @@ sealed interface ChatListItem { val contentIndex: Int, override val timestamp: Instant, val text: String, + /** Names in [text] that open a profile when tapped. */ + val userLinks: List = emptyList(), ) : TranscriptMessage { override val isFromSelf: Boolean = false override val itemKey: Any = @@ -365,7 +367,7 @@ fun ChatListItem.ContentBubble.splitAroundLinkCard(): List true + is LinkCard.GroupInvite, is LinkCard.InviteLink, is LinkCard.User, is LinkCard.Message -> true is LinkCard.Cash, is LinkCard.TokenInfo, is LinkCard.Web -> false } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt index a941e198ea..839ec82c90 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt @@ -170,6 +170,45 @@ sealed interface LinkCard { ) } + /** + * A private group's invite link, `app.flipcash.com/chat/invite#s=…` (spec surface 6). + * + * Drawn like [GroupInvite], from what `GetInvite` says about the group, with the link's status + * where a join link states its rules. The secret is in [url]'s fragment and is the group's key, + * so the card names no chat until the lookup answers, nothing about it is written to disk, and + * [toString] leaves the URL out. + */ + data class InviteLink( + override val url: String, + override val start: Int, + override val end: Int, + val state: State, + ) : LinkCard { + + sealed interface State { + /** The lookup is out. Drawn as the group card's shimmer. */ + data object Loading : State + + /** No group to show: the lookup failed, or the link matches no invite. */ + data object Unavailable : State + + data class Resolved( + /** The chat the link admits to, for the picture's access context. */ + val chatId: ChatId, + /** The group's own title, or null when it has none; the card names it then. */ + val title: String?, + val picture: MediaItem?, + val memberCount: Long, + val status: Status, + ) : State + } + + /** Whether the link still admits anyone, and if not, why. */ + enum class Status { Active, Exhausted, Expired, Revoked } + + override fun toString(): String = "InviteLink(start=$start, end=$end, state=$state)" + } + /** * A person's link: `flipcash.com/`, `flipcash.com/`, or the legacy `/tip/`. * diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SystemMessageText.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SystemMessageText.kt index ab2632f464..6ff727f4fb 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SystemMessageText.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/SystemMessageText.kt @@ -31,7 +31,25 @@ private fun MessageContent.SystemEvent.worded( is MessageContent.SystemEvent.UserJoined -> { val joinedIsSelf = selfId != null && userId == selfId val admitter = admittedBy?.takeUnless { it == userId } + val inviter = invitedBy when { + // Joined with someone's invite link (option B). Before the admitted forms: a join is one + // or the other, and the inviter is who the link belongs to. + inviter != null -> when { + joinedIsSelf -> nameOf(inviter) + ?.let { resources.getString(R.string.label_chat_system_joinedSelfViaInvite, it) } + selfId != null && inviter == selfId -> nameOf(userId) + ?.let { resources.getString(R.string.label_chat_system_joinedViaYourInvite, it) } + else -> { + val joinedName = nameOf(userId) + val inviterName = nameOf(inviter) + if (joinedName != null && inviterName != null) { + resources.getString(R.string.label_chat_system_joinedViaInvite, joinedName, inviterName) + } else { + null + } + } + } admitter == null -> if (joinedIsSelf) { resources.getString(R.string.label_chat_system_joinedSelf) } else { @@ -56,6 +74,42 @@ private fun MessageContent.SystemEvent.worded( /** The users [MessageContent.System.event] names, apart from the viewer: the ones [displayText] looks up. */ fun MessageContent.System.namedUsers(selfId: ID?): List = when (val event = event) { - is MessageContent.SystemEvent.UserJoined -> listOfNotNull(event.userId, event.admittedBy) + is MessageContent.SystemEvent.UserJoined -> listOfNotNull(event.userId, event.admittedBy, event.invitedBy) null -> emptyList() }.distinct().filterNot { selfId != null && it == selfId } + +/** A name in a notice's [text][ChatListItem.SystemMessage.text] that opens [userId]'s profile. */ +data class SystemUserLink(val start: Int, val end: Int, val userId: ID) + +/** + * The tappable names in [text], the result of [displayText] for this notice. Only the inviter of an + * invite-link join is one: "your" and "you" name the viewer, who has no profile to open from here. + * + * The span is found by formatting the same string again with a marker in the inviter's place, so + * where the name sits follows the string resource and not an assumption about its wording. If the + * result doesn't line up with [text], there is no link. + */ +fun MessageContent.System.userLinks( + resources: ResourceHelper, + text: String, + selfId: ID?, + nameOf: (ID) -> String?, +): List { + val joined = event as? MessageContent.SystemEvent.UserJoined ?: return emptyList() + val inviter = joined.invitedBy?.takeUnless { selfId != null && it == selfId } ?: return emptyList() + val name = nameOf(inviter) ?: return emptyList() + val marked = when { + selfId != null && joined.userId == selfId -> + resources.getString(R.string.label_chat_system_joinedSelfViaInvite, INVITER_MARKER) + else -> { + val joinedName = nameOf(joined.userId) ?: return emptyList() + resources.getString(R.string.label_chat_system_joinedViaInvite, joinedName, INVITER_MARKER) + } + } + val at = marked.indexOf(INVITER_MARKER) + if (at < 0 || !text.regionMatches(at, name, 0, name.length)) return emptyList() + return listOf(SystemUserLink(at, at + name.length, inviter)) +} + +// A private-use character, so it can't collide with a name or the surrounding wording. +private const val INVITER_MARKER = "\uE000" diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt index 12edac9faf..7d202fa727 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatQuotePanel.kt @@ -109,8 +109,10 @@ fun ChatQuotePanel( ) when (val snippet = quote.snippet) { // Two lines, not one: one truncates most quoted sentences mid-clause. + // The quoted text is shown as a line, not a bubble, so an invite link in it reads + // "Invite link" rather than leaking its fragment, which is the group's key. is ChatQuoteSnippet.Text -> Text( - text = snippet.body, + text = labelInviteLinks(snippet.body, stringResource(R.string.label_inviteLinkPreview)), style = CodeTheme.typography.caption, // White alphas rather than the theme's secondary, which is a blue-grey: the // ground under this text is the author's colour, and a second hue muddies it. @@ -120,7 +122,10 @@ fun ChatQuotePanel( ) is ChatQuoteSnippet.Photo -> Text( - text = ChatMediaText.preview(snippet.caption, stringResource(R.string.label_chat_media_photo)), + text = ChatMediaText.preview( + snippet.caption?.let { labelInviteLinks(it, stringResource(R.string.label_inviteLinkPreview)) }, + stringResource(R.string.label_chat_media_photo), + ), style = CodeTheme.typography.caption, color = Color.White.copy(alpha = QuotePanelDefaults.snippetAlpha), maxLines = QuotePanelDefaults.snippetMaxLines, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt index 710e285526..60e124fbe1 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatSummaryMapping.kt @@ -148,6 +148,7 @@ private fun MessageContent.previewText( ): String? = when (this) { is MessageContent.Text -> { val message = text.takeIf { it.isNotEmpty() } + ?.let { labelInviteLinks(it) { resources.getString(R.string.label_inviteLinkPreview) } } when { message == null -> null sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) @@ -213,7 +214,8 @@ private fun MessageContent.previewText( // The caption, else "Photo", behind the camera emoji; prefixed like a typed message. is MessageContent.Media -> { - val message = ChatMediaText.preview(caption?.text, resources.getString(R.string.label_chat_media_photo)) + val caption = caption?.text?.let { labelInviteLinks(it) { resources.getString(R.string.label_inviteLinkPreview) } } + val message = ChatMediaText.preview(caption, resources.getString(R.string.label_chat_media_photo)) when { sentBySelf -> resources.getString(R.string.label_chat_preview_sentMessage, message) senderName != null -> diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt index a8ce3dd80e..14a74bf3a3 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ComposerReplyStrip.kt @@ -167,7 +167,7 @@ private fun QuoteLine(snippet: ChatQuoteSnippet) { val style = CodeTheme.typography.textSmall.copy(fontWeight = FontWeight.Medium) when (snippet) { is ChatQuoteSnippet.Text -> Text( - text = snippet.body, + text = labelInviteLinks(snippet.body, stringResource(R.string.label_inviteLinkPreview)), style = style, color = CodeTheme.colors.textMain, maxLines = ComposerReplyStripDefaults.textMaxLines, @@ -176,7 +176,10 @@ private fun QuoteLine(snippet: ChatQuoteSnippet) { // Plain text: the thumbnail is its own item in the strip's row, not part of this line. is ChatQuoteSnippet.Photo -> Text( - text = ChatMediaText.preview(snippet.caption, stringResource(R.string.label_chat_media_photo)), + text = ChatMediaText.preview( + snippet.caption?.let { labelInviteLinks(it, stringResource(R.string.label_inviteLinkPreview)) }, + stringResource(R.string.label_chat_media_photo), + ), style = style, color = CodeTheme.colors.textMain, maxLines = ComposerReplyStripDefaults.textMaxLines, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt index 107e33513f..c6d0a84a0b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/GroupInviteLinkCard.kt @@ -83,8 +83,80 @@ fun GroupInviteLinkCard( shape: CornerBasedShape = GroupInviteCardDefaults.SHAPE, ) { val state = card.state + GroupLinkCardBody( + loading = state is LinkCard.GroupInvite.State.Loading, + resolved = (state as? LinkCard.GroupInvite.State.Resolved)?.let { resolved -> + GroupCardFace( + chatId = card.chatId, + title = resolved.title, + picture = resolved.picture, + memberCount = resolved.memberCount, + details = { resolved.requirement?.let { RequirementLines(it) } }, + ) + }, + minHeight = minHeight, + ctaLabel = ctaLabel, + onStart = onStart, + modifier = modifier, + shape = shape, + ) +} - if (state is LinkCard.GroupInvite.State.Loading) { +/** + * A private group's invite link in a transcript (spec surface 6): the group card, drawn from what + * `GetInvite` says, with the link's status where a join link states its rules. "View" opens the + * invite landing, which decides between the preview, the dead-link and not-found screens, and the + * chat itself for a member. + */ +@Composable +fun InviteLinkCard( + card: LinkCard.InviteLink, + minHeight: Dp, + ctaLabel: String?, + onStart: (() -> Unit)?, + modifier: Modifier = Modifier, + shape: CornerBasedShape = GroupInviteCardDefaults.SHAPE, +) { + val state = card.state + GroupLinkCardBody( + loading = state is LinkCard.InviteLink.State.Loading, + resolved = (state as? LinkCard.InviteLink.State.Resolved)?.let { resolved -> + GroupCardFace( + chatId = resolved.chatId, + title = resolved.title, + picture = resolved.picture, + memberCount = resolved.memberCount, + details = { InviteStatusLine(resolved.status) }, + ) + }, + minHeight = minHeight, + ctaLabel = ctaLabel, + onStart = onStart, + modifier = modifier, + shape = shape, + ) +} + +/** What a resolved group card shows; [details] is the line under the member count. */ +private class GroupCardFace( + val chatId: ChatId, + val title: String?, + val picture: MediaItem?, + val memberCount: Long, + val details: @Composable () -> Unit, +) + +@Composable +private fun GroupLinkCardBody( + loading: Boolean, + resolved: GroupCardFace?, + minHeight: Dp, + ctaLabel: String?, + onStart: (() -> Unit)?, + modifier: Modifier, + shape: CornerBasedShape, +) { + if (loading) { // Nothing about a group is known before the lookup -- not even whether it is one -- so the // loading card is a plain shimmer. It is as tall as a resolved card with no requirement, // not the floor: the transcript is anchored at the bottom, so every dp the row grows when @@ -127,7 +199,6 @@ fun GroupInviteLinkCard( return } - val resolved = state as? LinkCard.GroupInvite.State.Resolved val placeholderTop = CodeTheme.colors.contactAvatar.colors.first() val tint = remember(resolved?.picture, placeholderTop) { BlurHash.averageColor(resolved?.picture?.blurhash()) @@ -175,7 +246,7 @@ fun GroupInviteLinkCard( ContactAvatar( image = resolved.picture, displayName = title, - access = BlobAccessContext.ChatProfile(card.chatId), + access = BlobAccessContext.ChatProfile(resolved.chatId), // Size first: the card is measured intrinsically, and a fixed size answers that // without asking the avatar's BoxWithConstraints, which would throw. modifier = Modifier @@ -203,7 +274,7 @@ fun GroupInviteLinkCard( style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) - resolved.requirement?.let { RequirementLines(it) } + resolved.details() } // Unavailable draws nothing between the band and the button: there is no group to name. @@ -248,6 +319,25 @@ fun GroupInviteLinkCard( * The rule: the balance line, then the staff * line, each on its own line and centred, since both can be set on one chat. */ +/** The link's status, centred under the member count like a join link's rule. */ +@Composable +private fun InviteStatusLine(status: LinkCard.InviteLink.Status) { + Text( + modifier = Modifier.padding(top = GroupInviteCardDefaults.REQUIREMENT_GAP), + text = stringResource( + when (status) { + LinkCard.InviteLink.Status.Active -> R.string.label_linkCard_privateGroupInvite + LinkCard.InviteLink.Status.Exhausted -> R.string.subtitle_inviteExhausted + LinkCard.InviteLink.Status.Expired -> R.string.subtitle_inviteExpired + LinkCard.InviteLink.Status.Revoked -> R.string.subtitle_inviteRevoked + } + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) +} + @Composable private fun RequirementLines(requirement: LinkCard.GroupInvite.Requirement) { requirement.amount?.let { amount -> @@ -415,3 +505,42 @@ private fun Preview_GroupInvite_LargeFont() { } // endregion + +private fun previewInviteCard(status: LinkCard.InviteLink.Status) = LinkCard.InviteLink( + url = "https://app.flipcash.com/chat/invite#s=preview", + start = 0, + end = 0, + state = LinkCard.InviteLink.State.Resolved( + chatId = ChatId(ByteArray(16) { it.toByte() }), + title = PreviewGroupResolved.title, + picture = PreviewGroupResolved.picture, + memberCount = PreviewGroupResolved.memberCount, + status = status, + ), +) + +@Composable +private fun PreviewInviteCard(status: LinkCard.InviteLink.Status) { + Box(Modifier.width(280.dp).padding(8.dp)) { + InviteLinkCard( + card = previewInviteCard(status), + minHeight = 280.dp * 224f / 328f, + ctaLabel = "View", + onStart = {}, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_InviteLink_Active() { + PreviewInviteCard(LinkCard.InviteLink.Status.Active) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_InviteLink_Expired() { + PreviewInviteCard(LinkCard.InviteLink.Status.Expired) +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 921dffa322..260ce67695 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -118,7 +118,7 @@ internal fun LinkCardView( return } - if (live is LinkCard.GroupInvite) { + if (live is LinkCard.GroupInvite || live is LinkCard.InviteLink) { // Only the button opens a group, so the card takes no tap of its own. The long press // still has to reach the transcript, or pressing the card would select nothing. BoxWithConstraints( @@ -134,15 +134,16 @@ internal fun LinkCardView( } }, ) { - GroupInviteLinkCard( - card = live, - minHeight = maxWidth * LinkCardDefaults.CARD_ASPECT, - onStart = onClick?.let { click -> { click(live) } }, - // A transcript card opens the group named on it, so it says "View"; the group's - // own empty state draws this card with "Invite People". - ctaLabel = stringResource(R.string.action_linkCard_view), - shape = shape, - ) + val minHeight = maxWidth * LinkCardDefaults.CARD_ASPECT + // A transcript card opens the group named on it, so it says "View"; the group's + // own empty state draws this card with "Invite People". + val ctaLabel = stringResource(R.string.action_linkCard_view) + val onStart = onClick?.let { click -> { click(live) } } + if (live is LinkCard.InviteLink) { + InviteLinkCard(card = live, minHeight = minHeight, ctaLabel = ctaLabel, onStart = onStart, shape = shape) + } else if (live is LinkCard.GroupInvite) { + GroupInviteLinkCard(card = live, minHeight = minHeight, ctaLabel = ctaLabel, onStart = onStart, shape = shape) + } } return } @@ -205,7 +206,7 @@ internal fun LinkCardView( ) is LinkCard.TokenInfo -> TokenLinkCard(card = live, height = height, shape = shape) // Drawn above; unreachable here. - is LinkCard.GroupInvite, is LinkCard.User, is LinkCard.Web, is LinkCard.Message -> Unit + is LinkCard.GroupInvite, is LinkCard.InviteLink, is LinkCard.User, is LinkCard.Web, is LinkCard.Message -> Unit } } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index d3d197b563..c1063d915b 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -1011,6 +1011,10 @@ private fun rememberLinkCardClick(): (LinkCard) -> Unit { // The transcript's handler follows a message link in the app: a jump in place when it // points into this chat, a push with the way back otherwise. is LinkCard.Message -> uriHandler.openUri(card.url) + // The same handler opens the invite landing in the app, never the browser: the + // fragment is the group's key. The landing picks the preview, a dead-link or + // not-found screen, or the chat for a member. + is LinkCard.InviteLink -> uriHandler.openUri(card.url) // The chat opens it, because only the chat knows whether this reader may collect it. // A voucher already being claimed would only start the same claim again. is LinkCard.Cash -> if ((card.state as? LinkCard.Cash.State.Resolved)?.claim != LinkCard.Cash.Claim.Claiming) { diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt index de40fc18dd..a31564c949 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageLinkCard.kt @@ -289,7 +289,8 @@ private fun Excerpt(body: Body) { private fun ExcerptText(body: Body.Text) { Text( modifier = if (body.redacted) Modifier.redactedText() else Modifier, - text = flattenExcerpt(body.text), + // A quoted invite link reads "Invite link": its fragment is the group's key. + text = flattenExcerpt(labelInviteLinks(body.text, stringResource(R.string.label_inviteLinkPreview))), style = MessageLinkCardDefaults.bodyStyle, color = if (body.redacted) redactedTextColor(MessageLinkCardDefaults.EXCERPT) else MessageLinkCardDefaults.EXCERPT, maxLines = MessageLinkCardDefaults.BODY_LINES, diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt index 6ba369eef8..94d19773c6 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ReactionAlertPill.kt @@ -156,7 +156,7 @@ private fun AlertRow(content: ReactionAlertContent) { ) if (content.quote.isNotEmpty()) { Text( - text = content.quote, + text = labelInviteLinks(content.quote, stringResource(R.string.label_inviteLinkPreview)), style = CodeTheme.typography.caption, color = CodeTheme.colors.textSecondary, maxLines = 1, diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SystemMessageTextTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SystemMessageTextTest.kt index 62d6012660..a1f4b255e9 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SystemMessageTextTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/models/SystemMessageTextTest.kt @@ -25,6 +25,9 @@ class SystemMessageTextTest { R.string.label_chat_system_added to "%1\$s added %2\$s", R.string.label_chat_system_addedSelf to "%1\$s added you", R.string.label_chat_system_selfAdded to "You added %1\$s", + R.string.label_chat_system_joinedViaInvite to "%1\$s joined with %2\$s’s invite link", + R.string.label_chat_system_joinedSelfViaInvite to "You joined with %1\$s’s invite link", + R.string.label_chat_system_joinedViaYourInvite to "%1\$s joined with your invite link", ) private val resources = mockk { @@ -37,9 +40,10 @@ class SystemMessageTextTest { private fun text( joined: ID, admittedBy: ID? = null, + invitedBy: ID? = null, nameOf: (ID) -> String? = names::get, fallback: String = "Someone joined", - ) = MessageContent.System(fallback, UserJoined(joined, admittedBy)) + ) = MessageContent.System(fallback, UserJoined(joined, admittedBy, invitedBy)) .displayText(resources, self, nameOf) @Test @@ -93,4 +97,144 @@ class SystemMessageTextTest { assertEquals(listOf(alex), MessageContent.System("x", UserJoined(alex, alex)).namedUsers(self)) assertEquals(emptyList(), MessageContent.System("x").namedUsers(self)) } + + @Test + fun `an invite-link join names the joiner and the link's owner`() { + assertEquals("Alex joined with Sam’s invite link", text(alex, invitedBy = sam)) + } + + @Test + fun `the viewer joining with an invite reads as you`() { + assertEquals("You joined with Sam’s invite link", text(self, invitedBy = sam)) + } + + @Test + fun `the viewer's own invite reads as your`() { + assertEquals("Alex joined with your invite link", text(alex, invitedBy = self)) + } + + @Test + fun `an invite join wins over an admitted one`() { + assertEquals("Alex joined with Sam’s invite link", text(alex, admittedBy = sam, invitedBy = sam)) + } + + @Test + fun `an invite join whose names are not known yet falls back`() { + assertEquals("Someone joined", text(alex, invitedBy = sam, nameOf = { if (it == alex) "Alex" else null })) + assertEquals("Someone joined", text(alex, invitedBy = sam, nameOf = { if (it == sam) "Sam" else null })) + assertEquals("Someone joined", text(self, invitedBy = sam, nameOf = { null })) + } + + @Test + fun `an invite join names the inviter among the named users`() { + val notice = MessageContent.System("x", UserJoined(alex, null, sam)) + assertEquals(listOf(alex, sam), notice.namedUsers(self)) + assertEquals(listOf(alex), notice.namedUsers(sam)) + } + + private fun linksOf(joined: ID, invitedBy: ID?, nameOf: (ID) -> String? = names::get): List { + val notice = MessageContent.System("x", UserJoined(joined, null, invitedBy)) + val text = notice.displayText(resources, self, nameOf).orEmpty() + return notice.userLinks(resources, text, self, nameOf) + } + + @Test + fun `the inviter's name is the one tappable span`() { + val text = "Alex joined with Sam’s invite link" + val link = linksOf(alex, sam).single() + assertEquals("Sam", text.substring(link.start, link.end)) + assertEquals(sam, link.userId) + } + + @Test + fun `the span lands on the inviter when the joiner's name contains it`() { + val nameOf = { id: ID -> mapOf(alex to "Sam Jr", sam to "Sam")[id] } + val link = linksOf(alex, sam, nameOf).single() + assertEquals("Sam", "Sam Jr joined with Sam’s invite link".substring(link.start, link.end)) + assertEquals(19, link.start) + } + + @Test + fun `your own invite and a plain join have no tappable name`() { + assertEquals(emptyList(), linksOf(alex, self)) + assertEquals(emptyList(), linksOf(self, null)) + assertEquals(emptyList(), linksOf(alex, null)) + } + + private fun spanText(joined: ID, invitedBy: ID?, nameOf: (ID) -> String?): Pair { + val notice = MessageContent.System("x", UserJoined(joined, null, invitedBy)) + val text = notice.displayText(resources, self, nameOf).orEmpty() + val link = notice.userLinks(resources, text, self, nameOf).singleOrNull() + return text to link?.let { text.substring(it.start, it.end) } + } + + @Test + fun `the span lands on the inviter when the inviter's name contains the joiner's`() { + val nameOf = { id: ID -> mapOf(alex to "Sam", sam to "Sam Jr")[id] } + val (text, span) = spanText(alex, sam, nameOf) + assertEquals("Sam joined with Sam Jr’s invite link", text) + assertEquals("Sam Jr", span) + } + + @Test + fun `two people with the same name still get the inviter's span`() { + val nameOf = { id: ID -> mapOf(alex to "Sam", sam to "Sam")[id] } + val link = MessageContent.System("x", UserJoined(alex, null, sam)).let { + it.userLinks(resources, "Sam joined with Sam’s invite link", self, nameOf).single() + } + assertEquals(16, link.start) + assertEquals(sam, link.userId) + } + + @Test + fun `the viewer joining with a link whose owner's name is inside the wording still anchors`() { + val nameOf = { id: ID -> mapOf(sam to "joined")[id] } + val (text, span) = spanText(self, sam, nameOf) + assertEquals("You joined with joined’s invite link", text) + assertEquals("joined", span) + assertEquals(16, MessageContent.System("x", UserJoined(self, null, sam)) + .userLinks(resources, text, self, nameOf).single().start) + } + + @Test + fun `an inviter who is the viewer has no link`() { + assertEquals(emptyList(), MessageContent.System("x", UserJoined(alex, null, self)) + .userLinks(resources, "Alex joined with your invite link", self, names::get)) + assertEquals(emptyList(), MessageContent.System("x", UserJoined(self, null, self)) + .userLinks(resources, "You joined with your invite link", self, names::get)) + } + + @Test + fun `a name that cannot be looked up has no link`() { + val notice = MessageContent.System("x", UserJoined(alex, null, sam)) + assertEquals(emptyList(), notice.userLinks(resources, "Alex joined with Sam’s invite link", self) { null }) + assertEquals( + emptyList(), + notice.userLinks(resources, "Alex joined with Sam’s invite link", self) { if (it == sam) "Sam" else null }, + ) + assertEquals( + emptyList(), + notice.userLinks(resources, "Alex joined with Sam’s invite link", self) { if (it == alex) "Alex" else null }, + ) + } + + @Test + fun `text that is not the formatted string has no link`() { + val notice = MessageContent.System("x", UserJoined(alex, null, sam)) + assertEquals(emptyList(), notice.userLinks(resources, "Someone joined", self, names::get)) + } + + @Test + fun `the span follows the translation's word order`() { + val reordered = mockk { + every { getString(R.string.label_chat_system_joinedViaInvite, *anyVararg()) } answers { + "%2\$s hat %1\$s per Einladungslink hinzugefügt".format(*(args[1] as Array<*>)) + } + } + val notice = MessageContent.System("x", UserJoined(alex, null, sam)) + val text = notice.displayText(reordered, self, names::get).orEmpty() + assertEquals("Sam hat Alex per Einladungslink hinzugefügt", text) + val link = notice.userLinks(reordered, text, self, names::get).single() + assertEquals("Sam", text.substring(link.start, link.end)) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryInvitePreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryInvitePreviewTest.kt new file mode 100644 index 0000000000..bdffbf5bf9 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryInvitePreviewTest.kt @@ -0,0 +1,95 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.core.R +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.ChatSummary +import com.getcode.opencode.model.core.ID +import com.getcode.util.resources.ResourceHelper +import io.mockk.every +import io.mockk.mockk +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * The chat-list row never shows a raw invite URL: its fragment is the group's key. The row reads + * "Invite link" in its place and keeps the rest of the message and its sender prefix. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ChatSummaryInvitePreviewTest { + + private val sentAt = Instant.fromEpochSeconds(1_000) + private val self: ID = listOf(1) + private val other: ID = listOf(2) + private val invite = "https://app.flipcash.com/chat/invite#s=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + private val resources = mockk { + every { getString(R.string.label_inviteLinkPreview) } returns "Invite link" + every { getString(R.string.label_chat_preview_senderMessage, *anyVararg()) } answers { + val (name, message) = (args[1] as Array<*>) + "$name: $message" + } + every { getString(R.string.label_chat_preview_sentMessage, *anyVararg()) } answers { + "You: ${(args[1] as Array<*>)[0]}" + } + } + + private fun profile(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + private fun preview(text: String, senderId: ID = other): String? = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(byteArrayOf(9)), + type = ChatType.GROUP, + members = listOf( + ChatMember(userId = self, userProfile = profile("Me"), pointers = emptyList()), + ChatMember(userId = other, userProfile = profile("XCTests"), pointers = emptyList()), + ), + lastMessage = ChatMessage( + messageId = 42, + senderId = senderId, + content = listOf(MessageContent.Text(text)), + timestamp = sentAt, + unreadSeq = 0, + ), + lastActivity = sentAt, + title = "Links", + ), + unreadCount = 0, + ).toConversationReference(selfId = self, tokensByMint = emptyMap(), resources = resources).lastMessagePreview + + @Test + fun `a bare invite URL previews as Invite link under the sender's name`() { + assertEquals("XCTests: Invite link", preview(invite)) + } + + @Test + fun `text around an invite URL stays`() { + assertEquals("XCTests: come join Invite link today", preview("come join $invite today")) + } + + @Test + fun `an invite URL with a bad secret still previews as Invite link`() { + assertEquals("You: Invite link", preview("https://app.flipcash.com/chat/invite#s=bad!secret", senderId = self)) + } + + @Test + fun `a plain join link previews unchanged`() { + val join = "https://app.flipcash.com/chat/3f2c1b9a-0d4e-4c8b-9a1f-2b3c4d5e6f70" + assertEquals("XCTests: $join", preview(join)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt index e4a6c418e2..1c206865d8 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ChatSummaryPreviewTest.kt @@ -513,4 +513,30 @@ class ChatSummaryPreviewTest { val notice = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(self, admittedBy = other)) assertNull(groupSummary(listOf(notice), senderId = null).unnamedGroupSender(self, emptyMap())) } + + @Test + fun `an invite-link join previews with the inviter's name, for every viewer form`() { + val inviter: ID = listOf(6) + every { resources.getString(R.string.label_chat_system_joinedViaInvite, "Alice", "Jeff") } returns "Alice joined with Jeff’s invite link" + every { resources.getString(R.string.label_chat_system_joinedSelfViaInvite, "Alice") } returns "You joined with Alice’s invite link" + every { resources.getString(R.string.label_chat_system_joinedViaYourInvite, "Alice") } returns "Alice joined with your invite link" + // Named through the roster: `other` is Alice; the inviter is Jeff. + val roster = mapOf(inviter.hexEncodedString() to profile("Jeff")) + + val others = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(other, null, inviter)) + assertEquals("Alice joined with Jeff’s invite link", groupReference(listOf(others), senderId = null, senderProfiles = roster).lastMessagePreview) + + val viewer = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(self, null, other)) + assertEquals("You joined with Alice’s invite link", groupReference(listOf(viewer), senderId = null).lastMessagePreview) + + val host = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(other, null, self)) + assertEquals("Alice joined with your invite link", groupReference(listOf(host), senderId = null).lastMessagePreview) + } + + @Test + fun `an invite-link join whose inviter is unknown asks for them`() { + val stranger: ID = listOf(5) + val notice = MessageContent.System("Someone joined", MessageContent.SystemEvent.UserJoined(self, null, stranger)) + assertEquals(stranger, groupSummary(listOf(notice), senderId = null).unnamedGroupSender(self, emptyMap())) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/InviteLinkQuoteTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/InviteLinkQuoteTest.kt new file mode 100644 index 0000000000..4b0550e820 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/InviteLinkQuoteTest.kt @@ -0,0 +1,115 @@ +package com.flipcash.shared.chat.ui + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.alerts.ReactionAlertContent +import com.flipcash.shared.chat.models.ChatQuote +import com.flipcash.shared.chat.models.ChatQuoteSnippet +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCard.Message.Body +import com.flipcash.shared.chat.models.LinkCard.Message.State +import com.flipcash.services.models.chat.BlobAccessContext +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Clock + +/** + * The places outside a bubble that quote a message's text read an invite link as "Invite link": + * its fragment is the group's key. + */ +@RunWith(RobolectricTestRunner::class) +class InviteLinkQuoteTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val secret = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + private val invite = "https://app.flipcash.com/chat/invite#s=$secret" + + private fun assertNoSecret() { + composeTestRule.onAllNodesWithText(secret, substring = true).assertCountEquals(0) + } + + private fun quote(snippet: ChatQuoteSnippet) = ChatQuote( + messageId = 1, + authorName = "Ada", + snippet = snippet, + accent = null, + nameAccent = null, + ) + + @Test + fun `the reply strip labels an invite link in quoted text`() { + composeTestRule.setContent { + DesignSystem { ComposerReplyStrip(quote(ChatQuoteSnippet.Text("join $invite now")), onDismiss = {}) } + } + composeTestRule.onNodeWithText("join Invite link now").assertExists() + assertNoSecret() + } + + @Test + fun `the reply strip labels an invite link in a photo caption`() { + composeTestRule.setContent { + DesignSystem { + ComposerReplyStrip(quote(ChatQuoteSnippet.Photo(caption = invite, rendition = null, sealed = false, redacted = false)), onDismiss = {}) + } + } + composeTestRule.onNodeWithText("Invite link", substring = true).assertExists() + assertNoSecret() + } + + @Test + fun `the reaction pill labels an invite link in its quote`() { + val content = ReactionAlertContent( + chatId = ChatId(ByteArray(16)), + messageId = 1, + kind = ReactionAlertContent.Kind.Reaction, + chatName = "Ballers", + quote = invite, + actorNames = listOf("Ada"), + emojis = listOf("👍"), + ) + composeTestRule.setContent { DesignSystem { ReactionAlertPill(content, animate = false) } } + composeTestRule.onNodeWithText("Invite link", substring = true).assertExists() + assertNoSecret() + } + + @Test + fun `a quoted-message card labels an invite link in its excerpt`() { + val card = LinkCard.Message( + url = "https://app.flipcash.com/chat/x/1", + start = 0, + end = 0, + chatId = ChatId(ByteArray(16)), + messageId = 1, + isGroup = true, + state = State.Resolved( + source = LinkCard.Message.Source( + title = "Ballers", + kind = LinkCard.Message.Kind.PublicGroup, + picture = null, + pictureAccess = BlobAccessContext.ChatProfile(ChatId(ByteArray(16))), + ), + message = LinkCard.Message.Excerpt( + author = "Ada", + isFromSelf = false, + sentAt = Clock.System.now(), + body = Body.Text("come $invite"), + ), + reactionCount = 0, + ), + ) + composeTestRule.setContent { + DesignSystem { MessageLinkCard(card, shape = RoundedCornerShape(8), onOpen = null) } + } + composeTestRule.onNodeWithText("come Invite link", substring = true).assertExists() + assertNoSecret() + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 68b91235b1..c873b03142 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -7,6 +7,7 @@ import androidx.paging.PagingData import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.InviteParameters import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata @@ -615,6 +616,13 @@ interface GroupOperations { /** Joins [chatId], caching the chat so it is in the list before the next sync. */ suspend fun join(chatId: ChatId): Result + /** + * Joins private group [chatId] with an invite link, caching the chat as [join] does. The + * failures are `JoinChatError`'s, including the three dead-invite ones; the caller decides + * what each looks like. + */ + suspend fun joinWithInvite(chatId: ChatId, invite: InviteParameters): Result + /** * Leaves [chatId]. The chat drops out of the list immediately and comes back if the call * fails. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt index 639620c347..ed71b74a60 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt @@ -54,6 +54,9 @@ class LobbyRequests @Inject constructor( .recoverCatching { if (it !is LeaveLobbyError.NotFound) throw it } .onSuccess { store.remove(chatId) } + /** Drops the stored request for [chatId], for a viewer who left the lobby by other means. */ + suspend fun clear(chatId: ChatId) = store.remove(chatId) + /** * Asks GetChat about [chatIds], or every stored request when null, and drops each that is no * longer in the lobby. A failed GetChat keeps its request: an unanswered question is not a no. diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index e6bab14787..c3c5496294 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -6,6 +6,7 @@ import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.InviteParameters import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters @@ -169,8 +170,13 @@ class GroupFeedDelegate @Inject constructor( /** * Joins [chatId], caching the chat the server returns so the list has it before any sync runs. */ - override suspend fun join(chatId: ChatId): Result { - return chatController.joinChat(chatId) + override suspend fun join(chatId: ChatId): Result = joinWith(chatId, invite = null) + + override suspend fun joinWithInvite(chatId: ChatId, invite: InviteParameters): Result = + joinWith(chatId, invite) + + private suspend fun joinWith(chatId: ChatId, invite: InviteParameters?): Result { + return chatController.joinChat(chatId, invite) .onSuccess { metadata -> persist(listOf(metadata)) metadataDataSource.setMembership(metadata.chatId, isMember = true) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/invite/GroupInviteLanding.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/invite/GroupInviteLanding.kt new file mode 100644 index 0000000000..35af8c4393 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/invite/GroupInviteLanding.kt @@ -0,0 +1,156 @@ +package com.flipcash.shared.chat.invite + +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.toInviteSecretEnvelope +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetInviteError +import com.flipcash.services.models.GetRosterError +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InviteParameters +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.chatcipher.InviteCipher +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import javax.inject.Inject + +/** Why a link no longer admits anyone. Each has its own line under the group's description (node 12846:126). */ +enum class DeadInviteReason { EXHAUSTED, EXPIRED, REVOKED } + +/** What opening an invite link found. */ +sealed interface InviteLookup { + /** The viewer is already in the group, so the link has nothing to offer: open the chat. */ + data class AlreadyMember(val chatId: ChatId) : InviteLookup + + /** The group as the link shows it. [dead] is set when the link no longer admits anyone. */ + data class Found( + val token: InviteToken, + val chat: ChatMetadata, + val dead: DeadInviteReason?, + ) : InviteLookup + + /** The link matches no invite (node 12846:148). */ + data object NotFound : InviteLookup + + /** The host removed the viewer from this group (node 12846:160). */ + data object Banned : InviteLookup + + /** The lookup itself failed; nothing is known about the link. */ + data object Failed : InviteLookup +} + +/** What joining through a link came to. */ +sealed interface InviteJoinResult { + data class Joined(val chatId: ChatId) : InviteJoinResult + + /** The link died between preview and join; the screen switches to the dead state in place. */ + data class Dead(val reason: DeadInviteReason) : InviteJoinResult + + /** INVITE_NOT_FOUND: the link matches no invite. */ + data object NotFound : InviteJoinResult + + /** NOT_FOUND: the link is real but the group is gone. */ + data object GroupGone : InviteJoinResult + + data object Banned : InviteJoinResult + + /** + * DENIED, or RULES_NOT_SATISFIED. Neither should happen with an invite: the server answers + * DENIED to a private group joined without one, and a private group has no rules. The screen + * stays on the preview with the generic alert, not the connection one. + */ + data object Refused : InviteJoinResult + + /** A transport failure: the connection alert (node 12846:220). */ + data object Failed : InviteJoinResult +} + +/** One page of the preview roster, or the news that the link stopped granting it. */ +sealed interface InviteRosterResult { + data class Page(val members: List, val next: PagingToken?, val memberCount: Long) : InviteRosterResult + + /** `GetRoster` answered DENIED: the invite is no longer valid. Rows already loaded stay. */ + data object Denied : InviteRosterResult + + data object Failed : InviteRosterResult +} + +/** + * The joiner's side of an invite link: derive the token, look the link up, page the preview + * roster with it, and join. The secret is held for the length of one call and never kept. + */ +class GroupInviteLanding @Inject constructor( + private val inviteCipher: InviteCipher, + private val keys: ChatKeySource, + private val chatController: ChatController, + private val metadata: ChatMetadataDataSource, + private val chatCoordinator: ChatCoordinator, +) { + suspend fun lookup(secret: ByteArray): InviteLookup { + val token = runCatching { InviteToken(inviteCipher.inviteToken(secret)) } + .getOrElse { return InviteLookup.NotFound } + val preview = chatController.getInvite(token).getOrElse { error -> + return when (error) { + is GetInviteError.NotFound -> InviteLookup.NotFound + is GetInviteError.Banned -> InviteLookup.Banned + else -> InviteLookup.Failed + } + } + if (metadata.isMember(preview.chat.chatId)) return InviteLookup.AlreadyMember(preview.chat.chatId) + return InviteLookup.Found(token, preview.chat, preview.status.deadReason()) + } + + /** The invite's own token can fetch the roster of a group the viewer is not in. */ + suspend fun roster(chatId: ChatId, token: InviteToken, page: PagingToken?): InviteRosterResult = + chatController.getRoster(chatId, QueryOptions(token = page, descending = false), token).fold( + onSuccess = { InviteRosterResult.Page(it.members, it.pagingToken.takeIf { _ -> it.hasMore }, it.rosterSummary.memberCount) }, + onFailure = { if (it is GetRosterError.Denied) InviteRosterResult.Denied else InviteRosterResult.Failed }, + ) + + suspend fun join(secret: ByteArray, chatId: ChatId, token: InviteToken): InviteJoinResult { + val owner = keys.ownKeyPair() ?: return InviteJoinResult.Failed + val parameters = runCatching { + val inviteId = inviteCipher.inviteId(secret) + val sealed = inviteCipher.sealInviteSecretEnvelope(owner, secret, chatId.bytes, inviteId) + InviteParameters(token, sealed.toInviteSecretEnvelope()) + }.getOrElse { return InviteJoinResult.Failed } + + return chatCoordinator.joinWithInvite(chatId, parameters).fold( + onSuccess = { InviteJoinResult.Joined(chatId) }, + onFailure = { error -> + when (error) { + is JoinChatError.InviteExhausted -> InviteJoinResult.Dead(DeadInviteReason.EXHAUSTED) + is JoinChatError.InviteExpired -> InviteJoinResult.Dead(DeadInviteReason.EXPIRED) + is JoinChatError.InviteRevoked -> InviteJoinResult.Dead(DeadInviteReason.REVOKED) + is JoinChatError.InviteNotFound -> InviteJoinResult.NotFound + is JoinChatError.NotFound -> InviteJoinResult.GroupGone + is JoinChatError.Banned -> InviteJoinResult.Banned + is JoinChatError.Denied, is JoinChatError.RulesNotSatisfied -> { + // Points to a client bug (see InviteJoinResult.Refused). The repository has + // already reported the error; this line says it was an invite join. The + // chat id is the only identifier here, never the link or its secret. + trace(tag = TAG, message = "JoinChat refused an invite join (${error.message}) for $chatId", type = TraceType.Error) + InviteJoinResult.Refused + } + else -> InviteJoinResult.Failed + } + }, + ) + } +} + +private fun Invite.Status.deadReason(): DeadInviteReason? = when (this) { + Invite.Status.EXHAUSTED -> DeadInviteReason.EXHAUSTED + Invite.Status.EXPIRED -> DeadInviteReason.EXPIRED + Invite.Status.REVOKED -> DeadInviteReason.REVOKED + Invite.Status.ACTIVE, Invite.Status.UNKNOWN -> null +} + +private const val TAG = "GroupInviteLanding" diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/InviteLinkText.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/InviteLinkText.kt new file mode 100644 index 0000000000..201f2680d3 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/InviteLinkText.kt @@ -0,0 +1,70 @@ +package com.flipcash.shared.chat.ui + +import android.net.Uri +import com.flipcash.app.core.links.FIRST_PARTY_HOSTS + +/** + * [text] with every invite link in it replaced by [label], for the places that show a message as a + * line of text rather than as a bubble: the chat-list preview, a push and a reply's citation. + * + * An invite link's fragment is the group's key, so none of it may reach a preview. That holds for a + * link whose secret does not parse, too: it is still shaped like an invite, and its fragment is still + * whatever the sender pasted. The replaced span runs to the next whitespace, past wherever + * [detectUrls] stopped, so a fragment the URL pattern cut short does not leave a tail behind. + * + * The links are found by the same [detectUrls] pass the transcript and the link card read, so a span + * the card would draw as an invite is the span replaced here. Anything else in [text] is kept. + */ +fun labelInviteLinks(text: String, label: String): String = labelInviteLinks(text) { label } + +/** [labelInviteLinks], asking for the label only when [text] has an invite link to replace. */ +fun labelInviteLinks(text: String, label: () -> String): String { + // Most messages carry no invite; skip the detection pass for them. + if (!text.contains(INVITE_PATH, ignoreCase = true) && !text.contains(ENCODED_INVITE_PATH, ignoreCase = true)) { + return text + } + val invites = detectUrls(text).filter { isInviteLinkUrl(it.url) } + if (invites.isEmpty()) return text + val replacement = label() + return buildString { + var cursor = 0 + for (link in invites) { + if (link.start < cursor) continue + append(text, cursor, link.start) + append(replacement) + cursor = text.indexOfFirst(link.end) { it.isWhitespace() }.takeIf { it >= 0 } ?: text.length + } + append(text, cursor, text.length) + } +} + +/** + * Whether [url] is an invite link: `/chat/invite` on one of our hosts, as the router matches it, or + * a jump link wrapping one. Whether its secret is valid does not matter here. + */ +fun isInviteLinkUrl(url: String): Boolean { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false + val host = uri.host?.lowercase() ?: return false + if (host !in FIRST_PARTY_HOSTS) return false + if (host == JUMP_HOST) { + val target = url.substringAfter("#source=", missingDelimiterValue = "") + .takeIf { it.isNotBlank() } + ?.let(Uri::decode) + ?: return false + return runCatching { Uri.parse(target).host?.lowercase() }.getOrNull() != JUMP_HOST && + isInviteLinkUrl(target) + } + val segments = uri.pathSegments + return segments.size == 2 && + segments[0].equals("chat", ignoreCase = true) && + segments[1].equals("invite", ignoreCase = true) +} + +private inline fun String.indexOfFirst(from: Int, predicate: (Char) -> Boolean): Int { + for (i in from until length) if (predicate(this[i])) return i + return -1 +} + +private const val INVITE_PATH = "chat/invite" +private const val ENCODED_INVITE_PATH = "chat%2Finvite" +private const val JUMP_HOST = "jump.flipcash.com" diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/invite/GroupInviteLandingTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/invite/GroupInviteLandingTest.kt new file mode 100644 index 0000000000..532aca14aa --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/invite/GroupInviteLandingTest.kt @@ -0,0 +1,174 @@ +package com.flipcash.shared.chat.invite + +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeInviteCipher +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetInviteError +import com.flipcash.services.models.GetRosterError +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.Invite +import com.flipcash.services.models.chat.InvitePreview +import com.flipcash.services.models.chat.InviteToken +import com.flipcash.services.models.chat.RosterPage +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.chatcipher.InviteCipher +import com.getcode.ed25519kmp.KeyPair +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Instant + +/** The joiner's calls: the token is derived from the secret, and every server answer maps to one state. */ +class GroupInviteLandingTest { + + private val cipher = FakeInviteCipher() + private val keys = mockk { every { ownKeyPair() } returns KeyPair(ByteArray(32), ByteArray(64)) } + private val controller = mockk() + private val metadata = mockk() + private val coordinator = mockk() + private val landing = GroupInviteLanding(cipher, keys, controller, metadata, coordinator) + + private val secret = ByteArray(32) { 3 } + private val chatId = ChatId(ByteArray(16) { 4 }.toList()) + private val token = InviteToken(cipher.inviteToken(secret)) + + private val chat = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(1), + title = "Moony", + rosterSummary = RosterSummary(memberCount = 48, version = 1), + ) + + private fun preview(status: Invite.Status) = InvitePreview(chat, status) + + @Test + fun `lookup asks with the token derived from the secret`() = runTest { + coEvery { controller.getInvite(token) } returns Result.success(preview(Invite.Status.ACTIVE)) + coEvery { metadata.isMember(chatId) } returns false + val result = landing.lookup(secret) + assertEquals(InviteLookup.Found(token, chat, null), result) + } + + @Test + fun `a member is sent to the chat`() = runTest { + coEvery { controller.getInvite(token) } returns Result.success(preview(Invite.Status.ACTIVE)) + coEvery { metadata.isMember(chatId) } returns true + assertEquals(InviteLookup.AlreadyMember(chatId), landing.lookup(secret)) + } + + @Test + fun `each dead status carries its reason`() = runTest { + coEvery { metadata.isMember(chatId) } returns false + val expected = mapOf( + Invite.Status.EXHAUSTED to DeadInviteReason.EXHAUSTED, + Invite.Status.EXPIRED to DeadInviteReason.EXPIRED, + Invite.Status.REVOKED to DeadInviteReason.REVOKED, + ) + expected.forEach { (status, reason) -> + coEvery { controller.getInvite(token) } returns Result.success(preview(status)) + assertEquals(reason, (landing.lookup(secret) as InviteLookup.Found).dead) + } + } + + @Test + fun `lookup errors map to their frames`() = runTest { + coEvery { controller.getInvite(token) } returns Result.failure(GetInviteError.NotFound()) + assertEquals(InviteLookup.NotFound, landing.lookup(secret)) + coEvery { controller.getInvite(token) } returns Result.failure(GetInviteError.Banned()) + assertEquals(InviteLookup.Banned, landing.lookup(secret)) + coEvery { controller.getInvite(token) } returns Result.failure(GetInviteError.Other(RuntimeException("offline"))) + assertEquals(InviteLookup.Failed, landing.lookup(secret)) + } + + @Test + fun `a roster refusal is Denied and any other failure is Failed`() = runTest { + coEvery { controller.getRoster(chatId, any(), token) } returns Result.failure(GetRosterError.Denied()) + assertEquals(InviteRosterResult.Denied, landing.roster(chatId, token, null)) + coEvery { controller.getRoster(chatId, any(), token) } returns Result.failure(GetRosterError.Other()) + assertEquals(InviteRosterResult.Failed, landing.roster(chatId, token, null)) + } + + @Test + fun `a roster page carries the next token only while more remain`() = runTest { + val member = mockk(relaxed = true) + val summary = RosterSummary(memberCount = 48, version = 1) + coEvery { controller.getRoster(chatId, any(), token) } returns + Result.success(RosterPage(listOf(member), summary, listOf(1), hasMore = true)) + assertEquals(listOf(1), (landing.roster(chatId, token, null) as InviteRosterResult.Page).next) + coEvery { controller.getRoster(chatId, any(), token) } returns + Result.success(RosterPage(listOf(member), summary, listOf(1), hasMore = false)) + assertEquals(null, (landing.roster(chatId, token, null) as InviteRosterResult.Page).next) + } + + @Test + fun `join sends the token and a sealed secret`() = runTest { + coEvery { coordinator.joinWithInvite(chatId, any()) } returns Result.success(Unit) + assertEquals(InviteJoinResult.Joined(chatId), landing.join(secret, chatId, token)) + coVerify { coordinator.joinWithInvite(chatId, match { it.token == token }) } + } + + @Test + fun `join errors map to their outcomes`() = runTest { + val cases = listOf( + JoinChatError.InviteExhausted() to InviteJoinResult.Dead(DeadInviteReason.EXHAUSTED), + JoinChatError.InviteExpired() to InviteJoinResult.Dead(DeadInviteReason.EXPIRED), + JoinChatError.InviteRevoked() to InviteJoinResult.Dead(DeadInviteReason.REVOKED), + JoinChatError.InviteNotFound() to InviteJoinResult.NotFound, + JoinChatError.NotFound() to InviteJoinResult.GroupGone, + JoinChatError.Banned() to InviteJoinResult.Banned, + JoinChatError.Denied() to InviteJoinResult.Refused, + JoinChatError.RulesNotSatisfied() to InviteJoinResult.Refused, + JoinChatError.Other(RuntimeException("offline")) to InviteJoinResult.Failed, + ) + cases.forEach { (error, outcome) -> + coEvery { coordinator.joinWithInvite(chatId, any()) } returns Result.failure(error) + assertEquals(outcome, landing.join(secret, chatId, token), error::class.simpleName) + } + } + + @Test + fun `join without an account fails without calling the server`() = runTest { + every { keys.ownKeyPair() } returns null + assertIs(landing.join(secret, chatId, token)) + coVerify(exactly = 0) { coordinator.joinWithInvite(any(), any()) } + } + + @Test + fun `a secret that cannot be turned into a token is a link not found, with no call`() = runTest { + val broken = object : InviteCipher by cipher { + override fun inviteToken(secret: ByteArray): ByteArray = throw ChatCipherException("bad secret") + } + val landing = GroupInviteLanding(broken, keys, controller, metadata, coordinator) + + assertEquals(InviteLookup.NotFound, landing.lookup(secret)) + coVerify(exactly = 0) { controller.getInvite(any()) } + } + + @Test + fun `join that cannot seal the secret fails without calling the server`() = runTest { + val broken = object : InviteCipher by cipher { + override fun sealInviteSecretEnvelope(owner: KeyPair, secret: ByteArray, chatId: ByteArray, inviteId: ByteArray): EncryptedPayload = + throw ChatCipherException("cannot seal") + } + val landing = GroupInviteLanding(broken, keys, controller, metadata, coordinator) + + assertIs(landing.join(secret, chatId, token)) + coVerify(exactly = 0) { coordinator.joinWithInvite(any(), any()) } + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ui/InviteLinkTextTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ui/InviteLinkTextTest.kt new file mode 100644 index 0000000000..6ecc3dfd4c --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ui/InviteLinkTextTest.kt @@ -0,0 +1,73 @@ +package com.flipcash.shared.chat.ui + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** An invite link's fragment is the group's key, so a preview line shows "Invite link" in its place. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class InviteLinkTextTest { + + private val invite = "https://app.flipcash.com/chat/invite#s=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + + private fun label(text: String) = labelInviteLinks(text, "Invite link") + + @Test + fun `a bare invite URL becomes the label`() { + assertEquals("Invite link", label(invite)) + } + + @Test + fun `text around an invite URL is kept`() { + assertEquals("join us: Invite link see you there", label("join us: $invite see you there")) + } + + @Test + fun `an invite URL with a bad secret is labelled and none of its fragment shows`() { + val result = label("try https://app.flipcash.com/chat/invite#s=not!a*valid~secret now") + assertEquals("try Invite link now", result) + assertFalse(result.contains("#s=")) + } + + @Test + fun `an invite URL with no fragment is still an invite`() { + assertEquals("Invite link", label("https://app.flipcash.com/chat/invite")) + } + + @Test + fun `a plain join link is left alone`() { + val join = "https://app.flipcash.com/chat/3f2c1b9a-0d4e-4c8b-9a1f-2b3c4d5e6f70" + assertEquals("ask here $join", label("ask here $join")) + } + + @Test + fun `every invite in a message is labelled`() { + assertEquals("Invite link and Invite link", label("$invite and $invite")) + } + + @Test + fun `a jump link wrapping an invite is an invite`() { + val jump = "https://jump.flipcash.com/#source=" + android.net.Uri.encode(invite) + assertTrue(isInviteLinkUrl(jump)) + assertEquals("Invite link", label(jump)) + } + + @Test + fun `invite path on another host is not ours`() { + val other = "https://example.com/chat/invite#s=abc" + assertFalse(isInviteLinkUrl(other)) + assertEquals(other, label(other)) + } + + @Test + fun `the label is only asked for when there is an invite`() { + var asked = false + assertEquals("gm", labelInviteLinks("gm") { asked = true; "Invite link" }) + assertFalse(asked) + } +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 480f64b423..f04a511074 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -32,6 +32,7 @@ import com.flipcash.app.core.util.Linkify import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.PaymentRequests +import com.flipcash.shared.chat.ui.labelInviteLinks import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.app.persistence.sources.UserProfileDataSource @@ -490,8 +491,13 @@ class NotificationService : FirebaseMessagingService(), // stand-in. The opened text is the message itself, with no sender prefix to strip; a group // line takes its sender from the Person. Unencrypted messages open to null and keep the body. // A payment request carries only its id, so its body comes from fetching the request. - val opened = openPushedMessage(chatId, metadata) ?: pushedRequestBody(chatId, metadata) - style.addMessage(opened ?: planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) + // An invite link's fragment is the group's key, so it never shows on the lock screen. Only + // message text is labelled; a request's body is built from its amount and title. + val inviteLabel = getString(com.flipcash.core.R.string.label_inviteLinkPreview) + val line = openPushedMessage(chatId, metadata)?.let { labelInviteLinks(it, inviteLabel) } + ?: pushedRequestBody(chatId, metadata) + ?: labelInviteLinks(planMessageBody(body, senderNames), inviteLabel) + style.addMessage(line, System.currentTimeMillis(), senderPerson) // After the extract above, not before: a re-post rebuilds the style from the notification // already on screen, which carries the old flag and title back with it. diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 547f53f8cb..56dfdcba82 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -29,6 +29,8 @@ import com.getcode.solana.keys.Mint import com.getcode.utils.TraceType import com.getcode.utils.decodeBase64 import com.getcode.utils.decodeBase64UrlSafe +import com.getcode.chatcipher.InviteLink +import com.getcode.chatcipher.InviteLinkRoute import com.getcode.utils.trace import dev.theolm.rinku.DeepLink import org.json.JSONObject @@ -96,7 +98,11 @@ internal class AppRouter( } override fun dispatch(deepLink: DeepLink): DeeplinkAction { - val type = classify(deepLink) ?: return deepLink.unrouted() + // An invite-shaped link that no route claimed (`/chat/invite/extra#s=…`) is a broken + // invite, not a page for the browser: handing it on would send its fragment, the key, + // to whatever opens it. + val type = classify(deepLink) + ?: if (deepLink.mayCarryInviteSecret()) DeeplinkType.InvalidGroupInvite else return deepLink.unrouted() // Not logged in — redirect to login (or login deeplink itself) if (authStateProvider() !is AuthState.Ready) { @@ -134,6 +140,14 @@ internal class AppRouter( // The same destination a group push tap resolves to (see ChatTapTargetPlanner): the // chat screen is the gated preview when the viewer is not a member, so an invite and // a push land on one screen rather than on two that would have to agree. + is DeeplinkType.GroupInvite -> DeeplinkAction.Navigate( + listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.GroupInvite(type.secret)) + ) + + is DeeplinkType.InvalidGroupInvite -> DeeplinkAction.Navigate( + listOf(AppRoute.Tabs.Chats, AppRoute.Messaging.GroupInvite(null)) + ) + is DeeplinkType.GroupChatInvite -> DeeplinkAction.Navigate( listOf( AppRoute.Tabs.Chats, @@ -205,14 +219,7 @@ internal class AppRouter( // and inside a LaunchedEffect (App). A parse failure must degrade to "no deeplink", loudly // in logs, never to a crash. return runCatching { classifyOrThrow(deepLink) } - .onFailure { - trace( - tag = "AppRouter", - message = "Failed to classify deeplink; ignoring it", - error = it, - type = TraceType.Error, - ) - } + .onFailure { traceClassifyFailure(deepLink, it) } .getOrNull() } @@ -248,6 +255,9 @@ internal class AppRouter( deepLink.isEmailVerification() -> deepLink.handleEmailVerification() deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() + // Ahead of every other `/chat/` route: `/chat/invite` has a secret in its fragment and + // must never fall through to a chat, whether or not the secret parses. + deepLink.isGroupInvite() -> deepLink.handleGroupInvite() // Before the invite, which matches any path under a group's `/chat/{uuid}` and would // otherwise open a group's message link as the bare group. deepLink.isChatMessage() -> deepLink.handleChatMessage() @@ -519,3 +529,47 @@ private fun DeepLink.handleEmailVerification(): DeeplinkType.EmailVerification? return null } + +/** `app.flipcash.com/chat/invite`, with or without a usable fragment. */ +private fun DeepLink.isGroupInvite(): Boolean = + chat.contains(pathSegments.getOrNull(0)) && + pathSegments.getOrNull(1).equals("invite", ignoreCase = true) && + pathSegments.size == 2 + +private fun DeepLink.handleGroupInvite(): DeeplinkType = + // InviteLink owns the fragment grammar; it only reads links on the app origin, so the host is + // pinned to it here the same way the other routes are matched on path alone. + when (val route = InviteLink.route(asAppOrigin())) { + is InviteLinkRoute.Invite -> DeeplinkType.GroupInvite(route.secret) + else -> DeeplinkType.InvalidGroupInvite + } + +private fun DeepLink.asAppOrigin(): String { + val tail = data.substringAfter(host, missingDelimiterValue = "") + return "https://app.flipcash.com$tail" +} + +private fun DeepLink.mayCarryInviteSecret(): Boolean = + data.contains("chat/invite", ignoreCase = true) || data.contains("chat%2Finvite", ignoreCase = true) + +/** + * An invite link's fragment is its key, and an exception raised while parsing it may quote the URL. + * So for anything shaped like one the trace carries the exception type and nothing else: no + * message, no stack, and no hand-off to the error reporters. + */ +internal fun traceClassifyFailure(deepLink: DeepLink, error: Throwable) { + if (deepLink.mayCarryInviteSecret()) { + trace( + tag = "AppRouter", + message = "Failed to classify an invite-shaped deeplink; ignoring it (${error::class.simpleName})", + type = TraceType.Error, + ) + } else { + trace( + tag = "AppRouter", + message = "Failed to classify deeplink; ignoring it", + error = error, + type = TraceType.Error, + ) + } +} 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 832ac6f1ac..22b7c38183 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 @@ -21,7 +21,15 @@ import org.robolectric.annotation.Config import java.net.URLEncoder import java.util.UUID import kotlin.test.Test +import org.robolectric.RuntimeEnvironment +import com.getcode.chatcipher.InviteLink +import com.getcode.utils.BreadcrumbSink +import com.getcode.utils.ErrorReporter +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceManager +import com.getcode.utils.TraceType import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -1168,4 +1176,200 @@ class AppRouterTest { } // endregion + + // region classify - group invite link + + private val inviteSecret = ByteArray(32) { (it * 7 + 3).toByte() } + + @Test + fun `classify reads the secret from a valid invite link`() { + val type = router.classify(DeepLink(InviteLink.url(inviteSecret))) + assertIs(type) + assertTrue(type.secret.contentEquals(inviteSecret)) + } + + @Test + fun `classify treats an invite link with a malformed fragment as invalid, not a chat`() { + for (url in listOf( + "https://app.flipcash.com/chat/invite", + "https://app.flipcash.com/chat/invite#", + "https://app.flipcash.com/chat/invite#s=", + "https://app.flipcash.com/chat/invite#s=tooshort", + "https://app.flipcash.com/chat/invite#x=" + "A".repeat(43), + InviteLink.url(inviteSecret) + "/s=" + "A".repeat(43), + )) { + assertEquals(DeeplinkType.InvalidGroupInvite, router.classify(DeepLink(url)), url) + } + } + + @Test + fun `classify keeps a plain group link on the chat route`() { + assertIs( + router.classify(DeepLink("https://app.flipcash.com/chat/$groupUuid")) + ) + } + + @Test + fun `dispatch sends a valid invite to the invite route and a broken one to the broken state`() { + loggedIn() + val ok = router.dispatch(DeepLink(InviteLink.url(inviteSecret))) + assertIs(ok) + val route = ok.routes.last() + assertIs(route) + assertTrue(route.secret.contentEquals(inviteSecret)) + + val broken = router.dispatch(DeepLink("https://app.flipcash.com/chat/invite#s=nope")) + assertIs(broken) + assertNull((broken.routes.last() as AppRoute.Messaging.GroupInvite).secret) + } + + @Test + fun `invite types never print the secret`() { + val link = InviteLink.url(inviteSecret) + val encoded = link.substringAfter("#s=") + val type = router.classify(DeepLink(link))!! + assertFalse(type.toString().contains(encoded)) + assertFalse(AppRoute.Messaging.GroupInvite(inviteSecret).toString().contains(encoded)) + } + + @Test + fun `a classify failure on an invite-shaped link reports no fragment, secret or cause`() { + val link = InviteLink.url(inviteSecret) + val encoded = link.substringAfter("#s=") + val lines = mutableListOf() + val sink = object : BreadcrumbSink { + override fun record(message: String, metadata: Map, type: TraceType) { + lines += message + metadata.toString() + } + } + val reported = mutableListOf() + val reporter = object : ErrorReporter { + override fun report(error: Throwable, cause: Throwable, isNotifiable: Boolean) { reported += error } + } + TraceManager.initialize(RuntimeEnvironment.getApplication()) + TraceManager.addSink(sink) + ErrorUtils.addReporter(reporter) + try { + traceClassifyFailure(DeepLink(link), IllegalStateException("bad url $link")) + // Sanity: a link that is not an invite still reports its cause. + traceClassifyFailure(DeepLink("https://app.flipcash.com/c/e=x"), IllegalStateException("boom")) + } finally { + TraceManager.removeSink(sink) + ErrorUtils.removeReporter(reporter) + } + assertTrue(lines.isNotEmpty()) + assertTrue(lines.none { it.contains(encoded) || it.contains("#s=") }) + assertTrue(reported.none { it.message.orEmpty().contains(encoded) }) + } + + private val encodedSecret get() = InviteLink.url(inviteSecret).substringAfter("#s=") + + private fun inviteRouteOf(url: String): AppRoute.Messaging.GroupInvite { + val action = router.dispatch(DeepLink(url)) + assertIs(action, url) + return assertIs(action.routes.last(), url) + } + + @Test + fun `an invite with a trailing slash opens`() { + loggedIn() + val route = inviteRouteOf("https://app.flipcash.com/chat/invite/#s=$encodedSecret") + assertTrue(route.secret.contentEquals(inviteSecret)) + } + + @Test + fun `an invite on the apex host opens, with the host pinned to the app origin`() { + loggedIn() + val route = inviteRouteOf("https://flipcash.com/chat/invite#s=$encodedSecret") + assertTrue(route.secret.contentEquals(inviteSecret)) + } + + /** + * The `chat` segment is matched exactly, as for every chat route, and `invite` is matched + * ignoring case. The secret parser then reads only the canonical `/chat/invite` path, so a + * cased path is a broken invite either way, and never a page for the browser. + */ + @Test + fun `a cased invite path is a broken invite and never leaves the app`() { + loggedIn() + assertEquals(DeeplinkType.InvalidGroupInvite, router.classify(DeepLink("https://app.flipcash.com/chat/Invite#s=$encodedSecret"))) + assertNull(router.classify(DeepLink("https://app.flipcash.com/Chat/Invite#s=$encodedSecret"))) + for (url in listOf( + "https://app.flipcash.com/chat/Invite#s=$encodedSecret", + "https://app.flipcash.com/Chat/Invite#s=$encodedSecret", + "https://flipcash.com/CHAT/INVITE#s=$encodedSecret", + )) { + assertNull(inviteRouteOf(url).secret, url) + } + } + + @Test + fun `an invite path with a tail is a broken invite, not a browser link`() { + loggedIn() + for (url in listOf( + "https://app.flipcash.com/chat/invite/extra#s=$encodedSecret", + "https://flipcash.com/chat/invite/extra#s=$encodedSecret", + "https://flipcash.com/www/chat/invite#s=$encodedSecret", + )) { + val action = router.dispatch(DeepLink(url)) + assertIs(action, url) + assertNull((action.routes.last() as AppRoute.Messaging.GroupInvite).secret, url) + } + } + + @Test + fun `other apex links still go to the browser`() { + loggedIn() + assertIs(router.dispatch(DeepLink("https://flipcash.com/download"))) + } + + @Test + fun `other fragment keys beside the secret are ignored`() { + loggedIn() + assertTrue(inviteRouteOf("https://app.flipcash.com/chat/invite#s=$encodedSecret/x=1").secret.contentEquals(inviteSecret)) + assertTrue(inviteRouteOf("https://app.flipcash.com/chat/invite#x=1/s=$encodedSecret").secret.contentEquals(inviteSecret)) + } + + @Test + fun `two secrets or an empty one are a broken invite`() { + loggedIn() + assertNull(inviteRouteOf("https://app.flipcash.com/chat/invite#s=$encodedSecret/s=$encodedSecret").secret) + assertNull(inviteRouteOf("https://app.flipcash.com/chat/invite#s=").secret) + } + + @Test + fun `a jump wrapped invite opens the invite`() { + loggedIn() + val wrapped = "https://jump.flipcash.com/#source=" + URLEncoder.encode(InviteLink.url(inviteSecret), "UTF-8") + val route = inviteRouteOf(wrapped) + assertTrue(route.secret.contentEquals(inviteSecret)) + } + + @Test + fun `a signed out invite goes to onboarding and the link is dropped`() { + loggedOut() + for (url in listOf( + InviteLink.url(inviteSecret), + "https://app.flipcash.com/chat/invite#s=", + "https://flipcash.com/chat/invite/extra#s=$encodedSecret", + )) { + val action = router.dispatch(DeepLink(url)) + assertIs(action, url) + assertEquals(listOf(AppRoute.OnboardingFlow()), action.routes, url) + } + } + + @Test + fun `group invite routes redact the secret and compare by content`() { + val route = AppRoute.Messaging.GroupInvite(inviteSecret) + assertFalse(route.toString().contains(encodedSecret)) + assertFalse(route.toString().contains(inviteSecret.joinToString(","))) + assertEquals(route, AppRoute.Messaging.GroupInvite(inviteSecret.copyOf())) + assertEquals(route.hashCode(), AppRoute.Messaging.GroupInvite(inviteSecret.copyOf()).hashCode()) + assertTrue(route != AppRoute.Messaging.GroupInvite(ByteArray(32))) + assertTrue(route != AppRoute.Messaging.GroupInvite(null)) + assertEquals(AppRoute.Messaging.GroupInvite(null), AppRoute.Messaging.GroupInvite(null)) + } + + // endregion } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index 1c1e3042fd..11dedb6793 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -18,6 +18,7 @@ import com.flipcash.services.internal.network.services.ChatService import com.flipcash.services.models.CreateInviteError import com.flipcash.services.models.GetChatInvitesError import com.flipcash.services.models.GetInviteError +import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.GetKeyEnvelopeError import com.flipcash.services.models.RevokeInviteError import com.flipcash.services.models.LeaveChatError @@ -185,7 +186,7 @@ internal class InternalChatRepository( chatId: ChatId, invite: InviteParameters?, ): Result = service.joinChat(owner, chatId, invite) - .onFailure { ErrorUtils.handleError(it) } + .onFailure { if (!it.isExpectedInviteFailure()) ErrorUtils.handleError(it) } .map { mapper.map(it) } override suspend fun leaveChat( @@ -351,11 +352,18 @@ internal class InternalChatRepository( service.revokeInvite(owner, chatId, inviteId) .onFailure { if (!it.isExpectedInviteFailure()) ErrorUtils.handleError(it) } - // Expected outcomes of a stale or bad link; not worth reporting. - private fun Throwable.isExpectedInviteFailure(): Boolean = - this is CreateInviteError.Denied || this is CreateInviteError.NotFound || +} + +// Expected outcomes of a stale or bad link; not worth reporting. JoinChat's results that the +// joiner sees as a user outcome are here too: the three dead-invite states, a link that matches +// nothing, a vanished group and a ban. DENIED and RULES_NOT_SATISFIED stay out: with an invite +// they point to a client bug. +internal fun Throwable.isExpectedInviteFailure(): Boolean = + this is JoinChatError.InviteExhausted || this is JoinChatError.InviteExpired || + this is JoinChatError.InviteRevoked || this is JoinChatError.InviteNotFound || + this is JoinChatError.NotFound || this is JoinChatError.Banned || + this is CreateInviteError.Denied || this is CreateInviteError.NotFound || this is CreateInviteError.InvalidExpiresAt || this is CreateInviteError.TooManyInvites || this is GetInviteError.NotFound || this is GetInviteError.Banned || this is GetChatInvitesError.Denied || this is GetChatInvitesError.NotFound || this is RevokeInviteError.Denied || this is RevokeInviteError.NotFound -} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/repositories/InternalChatRepositoryJoinTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/repositories/InternalChatRepositoryJoinTest.kt new file mode 100644 index 0000000000..68ca5f9c74 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/repositories/InternalChatRepositoryJoinTest.kt @@ -0,0 +1,71 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.internal.network.services.ChatService +import com.flipcash.services.models.JoinChatError +import com.flipcash.services.models.chat.ChatId +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.utils.ErrorUtils +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** Which JoinChat failures reach error reporting. */ +class InternalChatRepositoryJoinTest { + + private val service = mockk() + private val repository = InternalChatRepository(service, mockk(), mockk()) + private val owner = mockk() + private val chatId = ChatId(ByteArray(16) { 5 }.toList()) + + @Before + fun setUp() { + mockkObject(ErrorUtils) + every { ErrorUtils.handleError(any()) } returns Unit + } + + @After + fun tearDown() = unmockkObject(ErrorUtils) + + private suspend fun joinFailing(error: JoinChatError) { + coEvery { service.joinChat(owner, chatId, any()) } returns Result.failure(error) + repository.joinChat(owner, chatId, invite = null) + } + + @Test + fun `a dead invite is not reported`() = runTest { + joinFailing(JoinChatError.InviteExpired()) + verify(exactly = 0) { ErrorUtils.handleError(any()) } + } + + @Test + fun `an unknown invite is not reported`() = runTest { + joinFailing(JoinChatError.InviteNotFound()) + verify(exactly = 0) { ErrorUtils.handleError(any()) } + } + + @Test + fun `a ban is not reported`() = runTest { + joinFailing(JoinChatError.Banned()) + verify(exactly = 0) { ErrorUtils.handleError(any()) } + } + + @Test + fun `a vanished group is not reported`() = runTest { + joinFailing(JoinChatError.NotFound()) + verify(exactly = 0) { ErrorUtils.handleError(any()) } + } + + @Test + fun `denied is reported`() = runTest { + val denied = JoinChatError.Denied() + joinFailing(denied) + verify(exactly = 1) { ErrorUtils.handleError(denied) } + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/repositories/InviteFailureTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/repositories/InviteFailureTest.kt new file mode 100644 index 0000000000..38c1dc685b --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/repositories/InviteFailureTest.kt @@ -0,0 +1,31 @@ +package com.flipcash.services.internal.repositories + +import com.flipcash.services.models.JoinChatError +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class InviteFailureTest { + @Test + fun `dead-invite join results are expected and stay out of error reporting`() { + assertTrue(JoinChatError.InviteExhausted().isExpectedInviteFailure()) + assertTrue(JoinChatError.InviteExpired().isExpectedInviteFailure()) + assertTrue(JoinChatError.InviteRevoked().isExpectedInviteFailure()) + } + + @Test + fun `a bad link, a vanished group and a ban are user outcomes`() { + assertTrue(JoinChatError.InviteNotFound().isExpectedInviteFailure()) + assertTrue(JoinChatError.NotFound().isExpectedInviteFailure()) + assertTrue(JoinChatError.Banned().isExpectedInviteFailure()) + } + + @Test + fun `other join failures are still reported`() { + // DENIED with an invite points to a client bug, so it must reach error reporting. + assertFalse(JoinChatError.Denied().isExpectedInviteFailure()) + assertFalse(JoinChatError.RulesNotSatisfied().isExpectedInviteFailure()) + assertFalse(JoinChatError.Unrecognized().isExpectedInviteFailure()) + assertFalse(JoinChatError.Other(RuntimeException()).isExpectedInviteFailure()) + } +}