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 6cd78e242d..f1c3b26d69 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 @@ -26,6 +26,7 @@ import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.FindByUsernameScreen import com.flipcash.app.tipping.ArchivedChatsScreen +import com.flipcash.app.tipping.RequestsScreen import com.flipcash.app.tipping.NewChatScreen import com.flipcash.app.tipping.NewGroupFlowScreen import com.flipcash.app.tipping.ChatsScreen @@ -137,6 +138,7 @@ fun appEntryProvider( annotatedEntry { key -> ProfileScreen(key.address, key.origin) } annotatedEntry { NewChatScreen() } annotatedEntry { ArchivedChatsScreen() } + annotatedEntry { RequestsScreen() } annotatedEntry { FindByUsernameScreen() } annotatedEntry { key -> NewGroupFlowScreen(route = key, resultStateRegistry = resultStateRegistry) @@ -149,6 +151,9 @@ fun appEntryProvider( annotatedEntry { key -> ChatPhotoViewerScreen(key.chatId, key.messageId) } annotatedEntry { E2eeLearnMoreScreen(forGroup = false) } annotatedEntry { E2eeLearnMoreScreen(forGroup = true) } + annotatedEntry { + E2eeLearnMoreScreen(forGroup = true, isPrivate = true) + } // Tokens annotatedEntry(testTag = "token_info_screen") { key -> 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 97d284c755..8c8361098e 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 @@ -363,9 +363,16 @@ sealed interface AppRoute : NavKey, Parcelable { // profile is the flow's root, so back returns to wherever the tap came from, and Open // Chat pushes the transcript on top of it. val openOnProfile: Boolean = false, + // Open on a private group's Join Requests as the flow's root, so back returns to the + // Requests screen that set it, the way iOS does. + val openOnJoinRequests: Boolean = false, ) : Messaging, FlowRoute { override val initialStack: List - get() = listOf(if (openOnProfile) ChatStep.GroupProfile else ChatStep.Conversation) + get() = when { + openOnJoinRequests -> listOf(ChatStep.JoinRequests) + openOnProfile -> listOf(ChatStep.GroupProfile) + else -> listOf(ChatStep.Conversation) + } } /** @@ -389,6 +396,14 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize data object ArchivedChats : Messaging + /** + * Join requests in both directions, behind the Requests button on the Chats header. Pushed + * for the reason [ArchivedChats] is. + */ + @Serializable + @Parcelize + data object Requests : Messaging + /** * Another person's profile, opened on its own rather than from inside a chat — the * destination of a `flipcash.com/{username}` or `flipcash.com/{uuid}` link. @@ -412,14 +427,18 @@ sealed interface AppRoute : NavKey, Parcelable { data object FindByUsername : Messaging /** - * Node 10153:22901 — creating a public group, from the empty form to its invite link. + * Node 10153:22901 — creating a public group, from the empty form to its invite link; node + * 11203:65 is the same flow for a private one. * * The three screens share one draft, so they are steps of a flow rather than routes; see * [NewGroupStep]. */ @Serializable @Parcelize - data object NewGroup : Messaging, FlowRoute { + data class NewGroup( + /** Node 11203:65: approval to join, end-to-end encrypted, no balance requirement. */ + val isPrivate: Boolean = false, + ) : Messaging, FlowRoute { override val initialStack: List get() = listOf(NewGroupStep.Form) } @@ -516,6 +535,14 @@ sealed interface AppRoute : NavKey, Parcelable { Messaging, com.getcode.navigation.Sheet, com.getcode.navigation.WrapContentSheet + + /** [E2eeGroupInfo] for a private group, which is end-to-end encrypted (node 11210:71). */ + @Serializable + @Parcelize + data object E2eePrivateGroupInfo : + Messaging, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet } @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 694031433e..5b37ae5731 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -101,6 +101,23 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object EditGroup : ChatStep + /** + * Node 11206:60 — who is waiting to join a private group, for its creator to approve or deny. + * Carries nothing for the same reason [GroupProfile] does not; the lobby itself comes from the + * shared host-lobby store. + */ + @Parcelize + @Serializable + data object JoinRequests : ChatStep + + /** + * Node 11210:70 — every member of a private group, the creator pinned first with a Host tag. + * The roster comes from the shared roster store. + */ + @Parcelize + @Serializable + data object Members : ChatStep + /** The group's title, on the same entry screen shape the profile name uses. */ @Parcelize @Serializable diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index e6b013469a..6fb8e6c0dc 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -931,6 +931,8 @@ Add %1$s to Chat 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 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 @@ -1019,6 +1021,10 @@ Messages sent before encryption became available Messages and media in 1:1 chats Public group chats, including this one + + Messages and media in this group are end-to-end encrypted. Only its members can read them, and the host approves everyone who joins. + The group’s name, photo, description and cover + Who is in the group, and how many Cash @@ -1103,6 +1109,80 @@ (node 10127:117987). --> New Chat Create a Public Group + Create a Private Group + New Private Group + Request to Join + Request Sent + Cancel Request + Private group · %1$s + The host approves who joins + Requested %1$dm ago + Requested %1$dh ago + Requested %1$dd ago + You\'ll be able to open the chat once %1$s approves + You\'ll be able to open the chat once the host approves + %1$s isn\'t taking requests right now + Too many people are waiting for the host to respond. Try again later + You have too many pending requests. Cancel one to request another + Couldn\'t Send Request + Something went wrong. Please try again + Couldn\'t Cancel Request + Something went wrong. Please try again + Couldn\'t Approve + Something went wrong. Please try again + Couldn\'t Deny + Something went wrong. Please try again + Join Requests + + Requests + Waiting on you + Sent + No requests right now + Requests to join your private groups, and the ones you send, show up here + + %1$d person is waiting + %1$d people are waiting + + More, people waiting to join + Requests, people waiting to join + requested just now + requested %1$dm ago + requested %1$dh ago + requested yesterday + requested %1$dd ago + Deny + Approve + Approving shares the group key with them from this device. + No requests right now + When someone asks to join %1$s, they\'ll show up here + Share Group + + %1$s wants to join + + %1$d person wants to join + %1$d people want to join + + and + Requested just now + Requested yesterday + Review + + Members + + %1$d MEMBER + %1$d MEMBERS + + Host + Joined just now + Joined %1$dm ago + Joined %1$dh ago + Joined yesterday + Joined %1$dd ago + Joined last week + Joined %1$d weeks ago + Joined last month + Joined %1$d months ago + Only people you approve can join. Messages are end-to-end encrypted. Find Someone by Username @@ -1133,6 +1213,7 @@ Try a different balance requirement Can\'t Create This Group Your account isn\'t able to create public groups + Your account isn\'t able to create private groups Something Went Wrong diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index bb5a6a679e..cb4c7ef8e3 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -38,6 +38,8 @@ import com.flipcash.app.messenger.internal.screens.profile.ChatProfileViewModel import com.flipcash.app.messenger.internal.screens.profile.GroupProfileScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupCoverScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupDescriptionScreen +import com.flipcash.app.messenger.internal.screens.profile.JoinRequestsScreen +import com.flipcash.app.messenger.internal.screens.profile.MembersScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen @@ -119,6 +121,12 @@ private fun chatEntryProvider( annotatedEntry { FlowEditGroupScreen() } + annotatedEntry { + JoinRequestsScreen(flowSharedViewModel()) + } + annotatedEntry { + MembersScreen(flowSharedViewModel()) + } annotatedEntry { FlowEditGroupNameScreen() } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt index e9606e3d95..b4dbbda6bc 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/E2eeLearnMoreScreen.kt @@ -6,14 +6,19 @@ import com.flipcash.app.messenger.internal.screens.E2eeSheetKind import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher /** - * The DM or group encryption explainer, behind `AppRoute.Messaging.E2eeDmInfo` and `E2eeGroupInfo`. + * The DM or group encryption explainer, behind `AppRoute.Messaging.E2eeDmInfo`, `E2eeGroupInfo` and + * `E2eePrivateGroupInfo`. */ @Composable -fun E2eeLearnMoreScreen(forGroup: Boolean) { +fun E2eeLearnMoreScreen(forGroup: Boolean, isPrivate: Boolean = false) { // Exit through the sheet so it animates down rather than having its scene deleted mid-frame. val dismissSheet = LocalBottomSheetDismissDispatcher.current E2eeLearnMoreSheet( - kind = if (forGroup) E2eeSheetKind.Group else E2eeSheetKind.Dm, + kind = when { + !forGroup -> E2eeSheetKind.Dm + isPrivate -> E2eeSheetKind.PrivateGroup + else -> E2eeSheetKind.Group + }, onDismiss = dismissSheet, ) } 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 8513ef3a90..99a53439ae 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 @@ -62,14 +62,21 @@ import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.models.EnterLobbyError import com.flipcash.services.models.JoinChatError import com.flipcash.services.models.chat.SampledChatter import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequirements import com.flipcash.app.messenger.internal.screens.profile.GroupProfileStanding +import com.flipcash.app.messenger.internal.screens.profile.JoinRequestCall +import com.flipcash.app.messenger.internal.screens.profile.LobbyCall +import com.flipcash.app.messenger.internal.screens.profile.knownPrivateGroupMembers +import com.flipcash.app.messenger.internal.screens.profile.postersNewestFirst +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.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType @@ -92,6 +99,9 @@ import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatHydration import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.FeaturedGroupsStore +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.LobbyRequests import com.flipcash.shared.chat.media.ChatMediaUploadState import com.flipcash.shared.chat.media.ChatMediaUploads import com.flipcash.shared.chat.media.MediaSendProgress @@ -169,6 +179,7 @@ import kotlin.math.min import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant import java.util.Optional import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope @@ -257,6 +268,9 @@ internal class ChatViewModel @AssistedInject constructor( private val userProfileDataSource: UserProfileDataSource, private val rosterSearch: RosterSearchSource, private val featuredGroups: FeaturedGroupsStore, + private val lobbyRequests: LobbyRequests, + private val hostLobbies: HostLobbies, + private val groupRosters: GroupRosters, private val dispatchers: DispatcherProvider, // Last and defaulted so the transcript tests, which draw no pictures, need not supply one. @WebPreviewImages val webPreviewImageLoader: ImageLoader? = null, @@ -459,6 +473,32 @@ internal class ChatViewModel @AssistedInject constructor( val joinProgress: LoadingSuccessState = LoadingSuccessState(), /** A leave is out, so the profile's Leave Chat shows a spinner and cannot be tapped again. */ val leaving: Boolean = false, + /** + * When this account asked to join this private group, while the request is waiting on the + * host. Null when there is no request, or none this device knows of. + */ + val lobbyRequestedAt: Instant? = null, + /** + * The lobby call that is out, so only its own button spins. Kept apart because the + * Requested bar appears before a request settles, and a Cancel button first drawn as a + * spinner keeps the spinner's size. + */ + val lobbyCall: LobbyCall? = null, + /** A private group's host, by name, for "once approves"; null until known. */ + val hostName: String? = null, + /** The viewer created this private group, so its lobby is theirs to answer. */ + val isLobbyHost: Boolean = false, + /** Who is waiting to join, newest first. Empty unless [isLobbyHost]. */ + val joinRequests: List = emptyList(), + /** A private group's full roster, creator first, for its Members grid and list. */ + val members: List = emptyList(), + /** + * A private group's creator, the viewer and its posters, from this device: the Members + * grid's stand-in while the server won't list [members]. + */ + val knownMembers: List = emptyList(), + /** Approve or Deny calls out, by the requester's user id, so only that row waits. */ + val joinRequestCalls: Map = emptyMap(), /** * What the group's profile pins and states about the viewer's balance, decided with the * live balance and rates. Null until they arrive, and for anything that is not a group. @@ -557,6 +597,13 @@ internal class ChatViewModel @AssistedInject constructor( val obscuresTranscript: Boolean get() = isOutsideGroup && !readsFromOutside + /** + * The host's Join Requests anchor under the title bar (node 11354:70): only while someone is + * waiting, and not while a message action has taken the bar over. + */ + val showsJoinRequestsAnchor: Boolean + get() = isLobbyHost && joinRequests.isNotEmpty() && selection == null && editing == null + /** Whether the chat's speaker rules let this viewer speak. See [speakerBlock]. */ val canSpeak: Boolean get() = speakerBlock == null @@ -733,7 +780,39 @@ internal class ChatViewModel @AssistedInject constructor( /** The group's profile came on screen: sample its chatters and fill in a missing cover. */ data object GroupProfileOpened : Event + + /** A private group's Request to Join: EnterLobby. */ + data object RequestToJoin : Event + + /** A private group's Cancel Request: LeaveLobby. */ + data object CancelJoinRequest : Event + + /** A lobby call went out ([busy]) or came back. */ + data class LobbyCallChanged(val call: LobbyCall?) : Event + + /** This chat's waiting request, from [LobbyRequests]; null when there is none. */ + data class OnLobbyRequestResolved(val requestedAt: Instant?) : Event + + data class OnHostNameResolved(val name: String?) : Event + + /** The viewer's host standing and this group's lobby, from [HostLobbies]. */ + data class OnJoinRequestsResolved(val isHost: Boolean, val members: List) : Event + + /** Join Requests came on screen: refetch, since LobbyUpdate is best-effort. */ + data object JoinRequestsOpened : Event + data class ApproveJoinRequest(val member: LobbyMember) : Event + data class DenyJoinRequest(val member: LobbyMember) : Event + data class JoinRequestCallChanged(val userId: ID, val call: JoinRequestCall?) : Event data class OnChattersLoaded(val chatters: List) : Event + + /** A private group's roster from [GroupRosters], already in display order. */ + data class OnMembersResolved(val members: List) : Event + + /** A private group's members as this device knows them, for the grid until the roster loads. */ + data class OnKnownMembersResolved(val members: List) : Event + + /** The Members list came on screen: refetch, since a roster read can lag the stream. */ + data object MembersOpened : Event data class OnProfileStandingResolved(val standing: GroupProfileStanding?) : Event data class OnRuleTokensResolved(val tokens: Map, RuleCurrency>) : Event @@ -1241,9 +1320,10 @@ internal class ChatViewModel @AssistedInject constructor( undecryptableHint = undecryptableHint( encryption = message.encryption, isFromSelf = message.isFromSelf, - // Only a DM is encrypted, so the sender of an incoming one is the - // participant. - senderName = stateFlow.value.participant?.name + // A group has no participant: its sender is this message's author, + // and a DM's is the participant. + senderName = sender?.displayName + ?: stateFlow.value.participant?.name ?: resources.getString(R.string.title_unnamedUser), ) ?: UndecryptableHint.UpdateApp, ) @@ -1600,6 +1680,30 @@ internal class ChatViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + /** + * EnterLobby's refusals that the viewer can act on get the leave dialog's sheet: a full lobby + * and too many pending requests. Already a member says nothing; the synced feed flips the + * profile to the member layout. Anything else is a plain failure. + */ + private fun showLobbyError(group: ChatSubject.Group, error: Throwable) { + val message = when (error) { + is EnterLobbyError.LobbyFull -> R.string.prompt_description_lobbyFull + is EnterLobbyError.TooManyLobbies -> R.string.prompt_description_tooManyLobbies + is EnterLobbyError.AlreadyMember -> return + else -> { + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToRequestJoin), + message = resources.getString(R.string.error_description_failedToRequestJoin), + ) + return + } + } + BottomBarManager.showInfo( + title = resources.getString(R.string.prompt_title_lobbyClosed, group.title), + message = resources.getString(message), + ) + } + /** * Replies to the voucher with a thank-you, once a link tapped here has actually been collected. * @@ -2473,7 +2577,14 @@ internal class ChatViewModel @AssistedInject constructor( R.string.prompt_title_leaveChat, group.title, ), - message = resources.getString(R.string.prompt_description_leaveChat), + // A private group has no invite link back in: rejoining is a new request. + message = resources.getString( + if (group.isPrivate) { + R.string.prompt_description_leavePrivateChat + } else { + R.string.prompt_description_leaveChat + } + ), actions = listOf( BottomBarAction(text = resources.getString(R.string.action_leaveChat)) { dispatchEvent(Event.LeaveConfirmed) @@ -2484,6 +2595,202 @@ internal class ChatViewModel @AssistedInject constructor( } .launchIn(viewModelScope) + // A private group's request, as LobbyRequests keeps it. Observed so a cancel elsewhere, or a + // refresh that finds the request answered, puts Request to Join back. + stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> lobbyRequests.observe().map { it[chatId] } } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnLobbyRequestResolved(it)) } + .launchIn(viewModelScope) + + // A private group's request line names its host; only the creator's id travels with the + // metadata, so the name comes from the profiles the transcript also reads. + stateFlow.map { (it.subject as? ChatSubject.Group)?.takeIf { group -> group.isPrivate }?.creator } + .distinctUntilChanged() + .flatMapLatest { creator -> + if (creator == null) { + flowOf(null) + } else { + chatCoordinator.requestSenderProfile(creator) + chatCoordinator.observeSenderProfiles().map { profiles -> + profiles[creator.hexEncodedString()]?.let { profile -> + profile.displayName.takeIf { it.isNotBlank() } ?: profile.username + } + } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnHostNameResolved(it)) } + .launchIn(viewModelScope) + + // The host's side of a private group: its lobby, kept by HostLobbies and loaded whenever the + // viewer turns out to be the creator, so the profile card and the in-chat anchor have it. + stateFlow.map { state -> + (state.subject as? ChatSubject.Group) + ?.takeIf { it.isPrivate && it.creator != null && it.creator == userManager.accountId } + ?.chatId + } + .distinctUntilChanged() + .flatMapLatest { chatId -> + if (chatId == null) { + flowOf(Event.OnJoinRequestsResolved(isHost = false, members = emptyList())) + } else { + viewModelScope.launch { hostLobbies.load(chatId) } + hostLobbies.state.map { Event.OnJoinRequestsResolved(isHost = true, members = it[chatId].orEmpty()) } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + // A private group's roster, for its members only (GetRoster is denied to a requester). + // Loaded when the viewer turns out to be a member and again each time the profile or the + // Members list opens; RosterUpdate keeps it current in between. + stateFlow.map { state -> + (state.subject as? ChatSubject.Group) + ?.takeIf { it.isPrivate && it.isMember == true } + ?.let { it.chatId to it.creator } + } + .distinctUntilChanged() + .flatMapLatest { group -> + if (group == null) { + flowOf(Event.OnMembersResolved(emptyList())) + } else { + val (chatId, creator) = group + viewModelScope.launch { + groupRosters.load(chatId) + .onFailure { trace("failed to load roster - ${it.localizedMessage}") } + } + groupRosters.state.map { + Event.OnMembersResolved(privateGroupMembers(it[chatId].orEmpty(), creator)) + } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + // Until roster reads open (GetRoster off, SampleChatters DENIED for a private group), the + // grid draws who this device knows: the creator, the viewer and everyone who has posted. + stateFlow.map { state -> + (state.subject as? ChatSubject.Group) + ?.takeIf { it.isPrivate && it.isMember == true } + ?.let { it.chatId to it.creator } + } + .distinctUntilChanged() + .flatMapLatest { group -> + if (group == null) { + flowOf(Event.OnKnownMembersResolved(emptyList())) + } else { + val (chatId, creator) = group + val selfId = userManager.accountId + combine( + chatCoordinator.observeMessages(chatId).map(::postersNewestFirst).distinctUntilChanged(), + senderProfiles.filterNotNull(), + ) { posters, profiles -> + (listOfNotNull(creator, selfId) + posters) + .filter { profiles[it.hexEncodedString()] == null } + .forEach(chatCoordinator::requestSenderProfile) + Event.OnKnownMembersResolved(knownPrivateGroupMembers(creator, selfId, posters, profiles)) + } + } + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + + merge( + eventFlow.filterIsInstance(), + eventFlow.filterIsInstance(), + ).onEach { + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + if (group.isPrivate && group.isMember == true) groupRosters.load(group.chatId) + }.launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + if (stateFlow.value.isLobbyHost) hostLobbies.load(chatId) + } + .launchIn(viewModelScope) + + // Each row's call runs on its own, so one slow Approve does not hold up the rest. + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + val userId = event.member.userProfile.userId ?: return@onEach + if (userId in stateFlow.value.joinRequestCalls) return@onEach + dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Approve)) + viewModelScope.launch { + hostLobbies.approve(chatId, event.member).onFailure { + trace("failed to admit lobby member - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToApproveJoinRequest), + message = resources.getString(R.string.error_description_failedToApproveJoinRequest), + ) + } + dispatchEvent(Event.JoinRequestCallChanged(userId, null)) + } + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { event -> + val chatId = stateFlow.value.chatId ?: return@onEach + val userId = event.member.userProfile.userId ?: return@onEach + if (userId in stateFlow.value.joinRequestCalls) return@onEach + dispatchEvent(Event.JoinRequestCallChanged(userId, JoinRequestCall.Deny)) + viewModelScope.launch { + hostLobbies.deny(chatId, userId).onFailure { + trace("failed to deny lobby member - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToDenyJoinRequest), + message = resources.getString(R.string.error_description_failedToDenyJoinRequest), + ) + } + dispatchEvent(Event.JoinRequestCallChanged(userId, null)) + } + } + .launchIn(viewModelScope) + + // No push says a request was answered, so the profile asks each time it comes up. + eventFlow.filterIsInstance() + .onEach { + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + if (group.isPrivate && group.isMember != true) lobbyRequests.refresh(listOf(group.chatId)) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + if (stateFlow.value.lobbyCall != null) return@onEach + dispatchEvent(Event.LobbyCallChanged(LobbyCall.Request)) + lobbyRequests.request(group.chatId).onFailure { error -> + trace("failed to enter lobby - ${error.localizedMessage}") + showLobbyError(group, error) + } + dispatchEvent(Event.LobbyCallChanged(null)) + } + .launchIn(viewModelScope) + + eventFlow.filterIsInstance() + .onEach { + val chatId = stateFlow.value.chatId ?: return@onEach + if (stateFlow.value.lobbyCall != null) return@onEach + dispatchEvent(Event.LobbyCallChanged(LobbyCall.Cancel)) + lobbyRequests.cancel(chatId).onFailure { + trace("failed to leave lobby - ${it.localizedMessage}") + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToCancelJoinRequest), + message = resources.getString(R.string.error_description_failedToCancelJoinRequest), + ) + } + dispatchEvent(Event.LobbyCallChanged(null)) + } + .launchIn(viewModelScope) + eventFlow.filterIsInstance() .onEach { val chatId = stateFlow.value.chatId ?: return@onEach @@ -3143,8 +3450,25 @@ internal class ChatViewModel @AssistedInject constructor( is Event.GateFundingTapped, Event.GroupInfoOpened, Event.InviteCardFollowed, - Event.LeaveChat, Event.GroupProfileOpened -> { state -> state } + Event.LeaveChat, Event.GroupProfileOpened, + Event.RequestToJoin, Event.CancelJoinRequest, + Event.JoinRequestsOpened, is Event.ApproveJoinRequest, is Event.DenyJoinRequest -> { state -> state } + is Event.OnJoinRequestsResolved -> { state -> + state.copy(isLobbyHost = event.isHost, joinRequests = event.members) + } + is Event.JoinRequestCallChanged -> { state -> + val calls = state.joinRequestCalls.toMutableMap() + val call = event.call + if (call == null) calls.remove(event.userId) else calls[event.userId] = call + state.copy(joinRequestCalls = calls) + } + is Event.LobbyCallChanged -> { state -> state.copy(lobbyCall = event.call) } + is Event.OnLobbyRequestResolved -> { state -> state.copy(lobbyRequestedAt = event.requestedAt) } + is Event.OnHostNameResolved -> { state -> state.copy(hostName = event.name) } is Event.OnChattersLoaded -> { state -> state.copy(chatters = event.chatters) } + is Event.OnMembersResolved -> { state -> state.copy(members = event.members) } + is Event.OnKnownMembersResolved -> { state -> state.copy(knownMembers = event.members) } + Event.MembersOpened -> { state -> state } is Event.OnProfileStandingResolved -> { state -> state.copy(profileStanding = event.standing) } is Event.OnRuleTokensResolved -> { state -> state.copy(ruleTokens = event.tokens) } Event.LeaveConfirmed -> { state -> state.copy(leaving = true) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt index e2bbef3862..a3d4f3b7a0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/E2eeLearnMoreSheet.kt @@ -37,35 +37,47 @@ import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.BulletRow /** Which chat kind the learn-more sheet explains. */ -internal enum class E2eeSheetKind { Dm, Group } +internal enum class E2eeSheetKind { Dm, Group, PrivateGroup } /** * What end-to-end encryption does and doesn't cover, opened from the profile footer. One layout - * for both kinds, with the DM's and the group's own words (nodes 10416:1533, 10557:1412). + * for every kind, with the DM's, the public group's and the private group's own words (nodes + * 10416:1533, 10557:1412, 11210:71). */ @Composable internal fun E2eeLearnMoreSheet( kind: E2eeSheetKind, onDismiss: () -> Unit, ) { - val isDm = kind == E2eeSheetKind.Dm - val encrypted = if (isDm) { + val isEncrypted = kind != E2eeSheetKind.Group + val title = if (isEncrypted) R.string.title_e2eeSheet_dm else R.string.title_e2eeSheet_group + val subtitle = when (kind) { + E2eeSheetKind.Dm -> R.string.subtitle_e2eeSheet_dm + E2eeSheetKind.Group -> R.string.subtitle_e2eeSheet_group + E2eeSheetKind.PrivateGroup -> R.string.subtitle_e2eeSheet_privateGroup + } + val encrypted = if (isEncrypted) { listOf(R.string.item_e2eeSheet_allMessageText, R.string.item_e2eeSheet_allMedia) } else { listOf(R.string.item_e2eeSheet_messagesAndMediaInDms) } - val notEncrypted = if (isDm) { - listOf( + val notEncrypted = when (kind) { + E2eeSheetKind.Dm -> listOf( R.string.item_e2eeSheet_payments, R.string.item_e2eeSheet_reactions, R.string.item_e2eeSheet_publicGroupChats, R.string.item_e2eeSheet_beforeEncryption, ) - } else { - listOf( + E2eeSheetKind.Group -> listOf( R.string.item_e2eeSheet_publicGroupChatsIncludingThis, R.string.item_e2eeSheet_payments, ) + E2eeSheetKind.PrivateGroup -> listOf( + R.string.item_e2eeSheet_groupDetails, + R.string.item_e2eeSheet_groupMembers, + R.string.item_e2eeSheet_reactions, + R.string.item_e2eeSheet_payments, + ) } Column( @@ -87,7 +99,7 @@ internal fun E2eeLearnMoreSheet( ) { Icon( modifier = Modifier.size(32.dp), - imageVector = if (isDm) Icons.Filled.Lock else Icons.Filled.LockOpen, + imageVector = if (isEncrypted) Icons.Filled.Lock else Icons.Filled.LockOpen, contentDescription = null, tint = Color.White.copy(alpha = 0.7f), ) @@ -97,9 +109,7 @@ internal fun E2eeLearnMoreSheet( modifier = Modifier .padding(horizontal = CodeTheme.dimens.inset) .padding(top = 20.dp), - text = stringResource( - if (isDm) R.string.title_e2eeSheet_dm else R.string.title_e2eeSheet_group - ), + text = stringResource(title), style = CodeTheme.typography.textLarge.copy( fontSize = 22.sp, fontWeight = FontWeight.SemiBold, @@ -111,9 +121,7 @@ internal fun E2eeLearnMoreSheet( modifier = Modifier .padding(horizontal = 24.dp) .padding(top = 16.dp), - text = stringResource( - if (isDm) R.string.subtitle_e2eeSheet_dm else R.string.subtitle_e2eeSheet_group - ), + text = stringResource(subtitle), style = CodeTheme.typography.textSmall.copy( fontSize = 15.sp, lineHeight = 21.sp, @@ -196,3 +204,10 @@ private fun Preview_E2eeSheet_Dm() { private fun Preview_E2eeSheet_Group() { E2eeLearnMoreSheet(kind = E2eeSheetKind.Group, onDismiss = {}) } + +@Preview(heightDp = 817) +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_E2eeSheet_PrivateGroup() { + E2eeLearnMoreSheet(kind = E2eeSheetKind.PrivateGroup, onDismiss = {}) +} 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 98ac8dfee0..b066dc624f 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 @@ -1,12 +1,20 @@ package com.flipcash.app.messenger.internal.screens +import android.os.Parcelable import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import com.flipcash.shared.chat.ui.media.ChatPhotoOverlay @@ -39,6 +47,7 @@ import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.chat.ChatParticipant import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep +import com.getcode.navigation.flow.rememberFlowNavigator import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.links.ExternalLinkUriHandler import com.flipcash.app.core.links.LocalTrustedWebsites @@ -50,8 +59,10 @@ import com.flipcash.app.messenger.internal.link.CashCardTap import com.flipcash.app.messenger.internal.screens.components.ChatTopBar import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge import com.flipcash.app.messenger.internal.screens.components.ChatTopEdge.softTopEdge +import com.flipcash.app.messenger.internal.screens.components.JoinRequestsAnchor import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.app.messenger.internal.screens.components.UserControlBottomBar +import com.getcode.theme.CodeTheme import com.getcode.ui.components.BlurredContent import com.flipcash.shared.chat.models.ChatAction import com.flipcash.app.core.LocalUserManager @@ -104,6 +115,15 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val photoSources = remember { ChatPhotoSources() } var openPhotoId by remember { mutableStateOf(null) } + // A private group the viewer is not in has no transcript to show: no key envelope, so nothing + // to decrypt. A share link or deep link only knows the chat id and lands here first, so once the + // group resolves as private, swap to its profile, where the viewer can ask in. + val flowNavigator = rememberFlowNavigator() + val privateOutsider = (state.subject as? ChatSubject.Group)?.let { it.isPrivate && it.isMember != true } == true + LaunchedEffect(privateOutsider) { + if (privateOutsider) flowNavigator.replaceStack(listOf(ChatStep.GroupProfile)) + } + // Here rather than in the action handler because the lookup comes first: the tap only knows a // handle, and where it leads is the view model's to decide once the handle has an answer. LaunchedEffect(viewModel) { @@ -176,7 +196,11 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } ChatAction.OpenEncryptionInfo -> keyboard.hideIfVisible { - navigator.push(AppRoute.Messaging.E2eeDmInfo) + // The marker shows in DMs and in private groups; each has its own copy. + val privateGroup = (state.subject as? ChatSubject.Group)?.isPrivate == true + navigator.push( + if (privateGroup) AppRoute.Messaging.E2eePrivateGroupInfo else AppRoute.Messaging.E2eeDmInfo + ) } ChatAction.AddCash -> { @@ -365,13 +389,35 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { // against the background at its own edge. barPlacement = ScaffoldBarPlacement.Overlay, topBar = { - ChatTopBar( - navigator = navigator, - state = state, - onBarHeightChange = { barHeight = it }, - chatActionHandler = chatActionHandler, - dispatch = viewModel::dispatchEvent, - ) + // The anchor rides in the top slot rather than over the list, so the transcript's top + // inset grows by its height and the oldest message clears it. barHeight stays the bar's + // own: the soft top edge belongs to the bar, and the anchor is glass over the scroll. + Column { + ChatTopBar( + navigator = navigator, + state = state, + onBarHeightChange = { barHeight = it }, + chatActionHandler = chatActionHandler, + dispatch = viewModel::dispatchEvent, + ) + // Hold the last list through the exit, so the card doesn't empty as it slides away. + var lastRequests by remember { mutableStateOf(state.joinRequests) } + if (state.joinRequests.isNotEmpty()) lastRequests = state.joinRequests + AnimatedVisibility( + visible = state.showsJoinRequestsAnchor, + enter = slideInVertically { -it } + fadeIn(), + exit = slideOutVertically { -it } + fadeOut(), + ) { + JoinRequestsAnchor( + requests = lastRequests, + onReview = { keyboard.hideIfVisible { navigator.push(ChatStep.JoinRequests) } }, + hazeState = hazeState, + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2), + ) + } + } }, bottomBar = { // Behind the backdrop with the transcript while a message is raised, dimmed and blurred diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt new file mode 100644 index 0000000000..dfd58a3f8c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt @@ -0,0 +1,189 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.chat.RequestAge +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInputDefaults +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur +import dev.chrisbanes.haze.blur.materials.HazeMaterials +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Nodes 11354:70 / 11354:81 / 11354:83 — the host's way into Join Requests from inside the chat: up + * to three requesters' avatars, "3 people want to join" over their names (or "Jasper wants to join" + * over the request's age), and a Review capsule. The whole card is the tap target. + * + * The ground is [com.flipcash.shared.chat.ui.ComposerReplyStrip]'s: the composer's ultra-thin haze + * over the background with the divider hairline, so the transcript reads through it as it scrolls + * under, and the same flat stand-in when there is no [HazeState] to sample. + */ +@Composable +internal fun JoinRequestsAnchor( + requests: List, + onReview: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val shape = CodeTheme.shapes.medium + val ground = if (hazeState != null) { + Modifier + .hazeBlur( + HazeInput.Sources(hazeState), + HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background), + ) + .background(ChatInputDefaults.ContainerColor) + } else { + Modifier.background(Color.White.copy(alpha = 0.1f)) + } + + val unknownName = stringResource(R.string.title_unnamedUser) + val names = requests.map { chatterName(it.userProfile.displayName, it.userProfile.username, unknownName) } + val andWord = stringResource(R.string.subtitle_namesAnd) + + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .then(ground) + .border(CodeTheme.dimens.border, CodeTheme.colors.divider, shape) + .clickable(onClick = onReview) + .heightIn(min = AnchorMinHeight) + .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x1) + .testTag("join_requests_anchor"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + AvatarStack(requests.take(MaxAvatars)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (requests.size == 1) { + stringResource(R.string.title_wantsToJoin, names.first()) + } else { + pluralStringResource(R.plurals.title_peopleWantToJoin, requests.size, requests.size) + }, + style = CodeTheme.typography.textMedium.copy(fontWeight = FontWeight.SemiBold), + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (requests.size == 1) { + requestedAgo(requests.first()) + } else { + joinNames(names) { head, last -> "$head $andWord $last" } + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Approve's capsule on Join Requests. The card is the target, so this one only shows where. + CodeButton( + text = stringResource(R.string.action_review), + buttonState = ButtonState.Filled, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp), + overrideContentPadding = true, + onClick = onReview, + ) + } +} + +/** Newest first, overlapping, each ringed in the background so the overlap reads as a cut. */ +@Composable +private fun AvatarStack(members: List) { + val ring = CodeTheme.colors.background + Box { + members.forEachIndexed { index, member -> + ContactAvatar( + userProfile = member.userProfile, + modifier = Modifier + .offset(x = (AvatarSize - AvatarOverlap) * index) + // The newest sits on top of the ones after it. + .zIndex((members.size - index).toFloat()) + .size(AvatarSize) + .border(AvatarRing, ring, CircleShape) + .padding(AvatarRing) + .clip(CircleShape), + ) + } + // The offsets don't take up width in a Box, so reserve what the stack spans. + Box(Modifier.size(width = AvatarSize + (AvatarSize - AvatarOverlap) * (members.size - 1).coerceAtLeast(0), height = AvatarSize)) + } +} + +@Composable +private fun requestedAgo(member: LobbyMember): String { + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + return when (val age = RequestAge.of(member.enteredAt, now)) { + RequestAge.JustNow -> stringResource(R.string.title_requestedJustNow) + is RequestAge.Minutes -> stringResource(R.string.action_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.action_requestedHoursAgo, age.count) + is RequestAge.Days -> if (age.count == 1L) { + stringResource(R.string.title_requestedYesterday) + } else { + stringResource(R.string.action_requestedDaysAgo, age.count) + } + } +} + +/** "Jasper", "Jasper and Tam", "Jasper, Vincent and Tam": the last name joined by [and]. */ +internal fun joinNames(names: List, and: (head: String, last: String) -> String): String = + when (names.size) { + 0 -> "" + 1 -> names.first() + else -> and(names.dropLast(1).joinToString(", "), names.last()) + } + +private const val MaxAvatars = 3 +private val AvatarSize = 30.dp +private val AvatarRing = 2.dp +private val AvatarOverlap = 10.dp +private val AnchorMinHeight = 50.dp diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt index 8e05d43951..fe8815403b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileMenuItems.kt @@ -2,6 +2,7 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.ui.graphics.vector.ImageVector @@ -28,8 +29,9 @@ internal enum class GroupProfileMenuAction { Report -> R.string.title_report } - fun icon(): ImageVector = when (this) { - Encryption -> Icons.Outlined.LockOpen + /** A private group is end-to-end encrypted, so its Encryption row shows a closed lock. */ + fun icon(isPrivate: Boolean = false): ImageVector = when (this) { + Encryption -> if (isPrivate) Icons.Filled.Lock else Icons.Outlined.LockOpen Mute -> Icons.Outlined.NotificationsOff Report -> Icons.Outlined.Feedback } 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 54251d24dd..9e20349caf 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 @@ -1,6 +1,15 @@ package com.flipcash.app.messenger.internal.screens.profile import android.os.Parcelable +import com.flipcash.shared.chat.RequestAge +import com.getcode.theme.White10 +import com.getcode.theme.White05 +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.expandVertically @@ -8,6 +17,15 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material.icons.filled.Lock +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.Dp +import dev.chrisbanes.haze.HazeState +import androidx.compose.ui.text.style.TextAlign +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Clock +import androidx.compose.runtime.produceState +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -106,6 +124,13 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val group = state.subject as? ChatSubject.Group val isMember = group?.isMember == true + // A private group's non-member has no chat to open: no envelope, no transcript. The profile is + // where they ask in, and where they wait. + val privateJoin = PrivateGroupJoin.resolve( + isPrivate = group?.isPrivate == true, + isMember = group?.isMember, + requestedAt = state.lobbyRequestedAt, + ) // Each time the screen is entered, not once per flow: the view model outlives this screen, and // who is chatting moves between visits. @@ -158,7 +183,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { var pinnedHeight by remember { mutableStateOf(0.dp) } // Opened from the chat, back already returns to it, so nothing is pinned: Leave Chat sits at // the foot of the page instead. - val clearance = if (openedFromChat) 0.dp else pinnedHeight + val clearance = if (openedFromChat && privateJoin == null) 0.dp else pinnedHeight val hazeState = rememberHazeState() CodeScaffold { padding -> @@ -227,6 +252,32 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { }, ) + if (group?.isPrivate == true) { + PrivateGroupNote( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x3), + memberCount = group.memberCount, + ) + } + + // The host's way into Join Requests; gone when nobody is waiting. + AnimatedVisibility( + visible = state.isLobbyHost && state.joinRequests.isNotEmpty(), + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + ) { + var shownCount by remember { mutableStateOf(state.joinRequests.size) } + if (state.joinRequests.isNotEmpty()) shownCount = state.joinRequests.size + JoinRequestsCard( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + count = shownCount, + onClick = { flowNavigator.navigateTo(ChatStep.JoinRequests) }, + ) + } + val unknownName = stringResource(R.string.title_unnamedUser) val chatters = remember(state.chatters, unknownName) { state.chatters.mapNotNull { chatter -> @@ -240,6 +291,51 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { ) } } + // A private group's members see its roster instead of a sample: creator first, + // then the newest joins, with the count opening the full list (node 11210:68). + // Until the server lists the roster, the grid shows who this device knows, + // without a count: there is no full list to open. + val hasRoster = state.members.isNotEmpty() + val gridMembers = if (hasRoster) state.members else state.knownMembers + val members = remember(gridMembers, unknownName) { + gridMembers.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, + ) + } + } + AnimatedVisibility( + visible = group?.isPrivate == true && members.isNotEmpty(), + enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + ) { + GroupChattingGrid( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x6), + chatters = members, + title = stringResource(R.string.title_members), + count = if (hasRoster) { + (group?.memberCount ?: state.members.size.toLong()).toString() + } else null, + onShowAll = { flowNavigator.navigateTo(ChatStep.Members) }, + onOpen = { item -> + val member = gridMembers.firstOrNull { it.userId == item.userId } + ?: return@GroupChattingGrid + flowNavigator.navigateTo( + ChatStep.Profile( + contact = ChatParticipant.TipUser(item.userId, member.userProfile), + origin = ProfileOrigin.GroupMember, + ) + ) + }, + ) + } + // The sample lands after the header; opening the grid in place moves the // requirements card down with it rather than jumping it. AnimatedVisibility( @@ -268,7 +364,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { } AnimatedVisibility( - visible = requirements != null && namesReady, + visible = requirements != null && namesReady && group?.isPrivate != true, enter = fadeIn() + expandVertically(expandFrom = Alignment.Top), exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), ) { @@ -355,12 +451,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { GroupProfileMenu( expanded = menuOpen, items = groupProfileMenuActions(isMember), + isPrivate = group?.isPrivate == true, anchorHeight = anchorHeight, onDismiss = { menuOpen = false }, onItem = { item -> when (item) { - GroupProfileMenuAction.Encryption -> - navigator.push(AppRoute.Messaging.E2eeGroupInfo) + GroupProfileMenuAction.Encryption -> navigator.push( + if (group?.isPrivate == true) { + AppRoute.Messaging.E2eePrivateGroupInfo + } else { + AppRoute.Messaging.E2eeGroupInfo + } + ) // The picker handles muting and unmuting both; pushed on the // outer navigator, as it is a top-level route. GroupProfileMenuAction.Mute -> state.chatId?.let { chatId -> @@ -378,7 +480,18 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { // Open Chat in every standing, as in the design: a viewer short of a minimum is told // so, and buys, on the chat's own gate. - if (!openedFromChat) { + if (privateJoin != null) { + PrivateJoinBar( + modifier = Modifier.align(Alignment.BottomCenter), + join = privateJoin, + hostName = state.hostName, + lobbyCall = state.lobbyCall, + onRequest = { viewModel.dispatchEvent(ChatViewModel.Event.RequestToJoin) }, + onCancel = { viewModel.dispatchEvent(ChatViewModel.Event.CancelJoinRequest) }, + hazeState = hazeState, + onHeightChanged = { pinnedHeight = it }, + ) + } else if (!openedFromChat) { ProfilePinnedActionBar( modifier = Modifier.align(Alignment.BottomCenter), text = stringResource(R.string.action_openChat), @@ -399,6 +512,7 @@ internal fun GroupProfileScreen(viewModel: ChatViewModel) { private fun GroupProfileMenu( expanded: Boolean, items: List, + isPrivate: Boolean, anchorHeight: androidx.compose.ui.unit.Dp, onDismiss: () -> Unit, onItem: (GroupProfileMenuAction) -> Unit, @@ -416,7 +530,7 @@ private fun GroupProfileMenu( } val tint = if (item.isDestructive) CodeTheme.colors.errorText else CodeTheme.colors.textMain DropdownMenuItem( - leadingIcon = { Icon(imageVector = item.icon(), contentDescription = null, tint = tint) }, + leadingIcon = { Icon(imageVector = item.icon(isPrivate), contentDescription = null, tint = tint) }, text = { Text( text = stringResource(item.labelRes()), @@ -435,3 +549,155 @@ private fun GroupProfileMenu( /** How long the requirement amounts wait for their token's name before showing without it. */ private val RuleNameHold = 1500.milliseconds + +/** Four rows of portraits; the count beside the title opens the rest. */ +private const val MembersGridLimit = 16 + +/** "Private group · 48 members" over "The host approves who joins", behind a lock. */ +@Composable +private fun PrivateGroupNote(memberCount: Long, modifier: Modifier = Modifier) { + Column(modifier = modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(14.dp), + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + Text( + text = stringResource( + R.string.subtitle_privateGroupMembers, + pluralStringResource(R.plurals.subtitle_memberCount, memberCount.toInt(), memberCount.toString()), + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Text( + text = stringResource(R.string.subtitle_hostApprovesJoins), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } +} + +/** + * A private group's pinned action for a non-member: Request to Join, or a disabled Request Sent + * with Cancel Request under it and how long the request has waited above it. + */ +@Composable +private fun PrivateJoinBar( + join: PrivateGroupJoin, + hostName: String?, + lobbyCall: LobbyCall?, + onRequest: () -> Unit, + onCancel: () -> Unit, + hazeState: HazeState, + onHeightChanged: (Dp) -> Unit, + modifier: Modifier = Modifier, +) { + when (join) { + PrivateGroupJoin.Request -> ProfilePinnedActionBar( + modifier = modifier, + text = stringResource(R.string.action_requestToJoin), + onClick = onRequest, + isLoading = lobbyCall == LobbyCall.Request, + enabled = lobbyCall == null, + hazeState = hazeState, + onHeightChanged = onHeightChanged, + ) + is PrivateGroupJoin.Requested -> { + // Ticks so "2m ago" does not stand still while the screen stays up. + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + // "Request Sent" for the first minute, then how long the request has waited. + val label = when (val age = RequestAge.of(join.at, now)) { + RequestAge.JustNow -> stringResource(R.string.action_requestSent) + is RequestAge.Minutes -> stringResource(R.string.action_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.action_requestedHoursAgo, age.count) + is RequestAge.Days -> stringResource(R.string.action_requestedDaysAgo, age.count) + } + ProfilePinnedActionBar( + modifier = modifier, + text = label, + onClick = {}, + enabled = false, + above = { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + text = if (hostName != null) { + stringResource(R.string.subtitle_requestPendingHost, hostName) + } else { + stringResource(R.string.subtitle_requestPendingHostUnnamed) + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + }, + secondaryText = stringResource(R.string.action_cancelRequest), + onSecondaryClick = onCancel, + isSecondaryLoading = lobbyCall == LobbyCall.Cancel, + hazeState = hazeState, + onHeightChanged = onHeightChanged, + ) + } + } +} + +/** Node 11205:68 — the host's Join Requests row: title, "3 people are waiting", grey count, chevron. */ +@Composable +private fun JoinRequestsCard(count: Int, onClick: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(White05) + .clickable(onClick = onClick) + .padding(horizontal = CodeTheme.dimens.grid.x3, vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.title_joinRequests), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = pluralStringResource(R.plurals.subtitle_joinRequestsWaiting, count, count), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + // Grey rather than the unread badge's colour: a count of people waiting, not unread messages. + Box( + modifier = Modifier + .heightIn(min = 20.dp) + .clip(CircleShape) + .background(White10) + .padding(horizontal = 7.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = count.toString(), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + ) + } + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt index e406b275b4..704a4a7ba8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt @@ -1,5 +1,8 @@ package com.flipcash.app.messenger.internal.screens.profile +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.SampledChatter @@ -14,8 +17,14 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.solana.keys.Mint +import com.getcode.utils.hexEncodedString import java.math.BigDecimal import java.math.RoundingMode +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant /** * The one action pinned to the bottom of a group's profile. @@ -234,3 +243,88 @@ internal fun gateFunding( } private const val MICRO_SCALE = 6 + +/** + * What a private group's profile offers a viewer who is not in it. Null for a public group and for + * a member, who get the usual layout. + */ +internal sealed interface PrivateGroupJoin { + data object Request : PrivateGroupJoin + data class Requested(val at: Instant) : PrivateGroupJoin + + companion object { + fun resolve(isPrivate: Boolean, isMember: Boolean?, requestedAt: Instant?): PrivateGroupJoin? = when { + !isPrivate || isMember == true -> null + requestedAt != null -> Requested(requestedAt) + else -> Request + } + } +} + +/** How long ago a member joined, as a private group's Members list words it (node 11210:70). */ +internal sealed interface JoinAge { + data object JustNow : JoinAge + data class Minutes(val count: Long) : JoinAge + data class Hours(val count: Long) : JoinAge + data object Yesterday : JoinAge + data class Days(val count: Long) : JoinAge + data object LastWeek : JoinAge + data class Weeks(val count: Long) : JoinAge + data object LastMonth : JoinAge + data class Months(val count: Long) : JoinAge + + companion object { + fun of(at: Instant, now: Instant): JoinAge { + val elapsed = (now - at).coerceAtLeast(Duration.ZERO) + val days = elapsed.inWholeDays + return when { + elapsed < 1.minutes -> JustNow + elapsed < 1.hours -> Minutes(elapsed.inWholeMinutes) + elapsed < 1.days -> Hours(elapsed.inWholeHours) + days < 2 -> Yesterday + days < 7 -> Days(days) + days < 14 -> LastWeek + days < 30 -> Weeks(days / 7) + days < 60 -> LastMonth + else -> Months(days / 30) + } + } + } +} + +/** + * A private group's roster in the order its grid and list show it: the creator first, then the + * most recently joined. A member with no join time sorts last. + */ +internal fun privateGroupMembers(members: List, creator: ID?): List { + val (host, rest) = members.partition { creator != null && it.userId == creator } + return host + rest.sortedByDescending { it.joinedAt } +} + +/** The distinct senders in [messages], the most recent poster first. */ +internal fun postersNewestFirst(messages: List): List = messages + .sortedByDescending { it.timestamp } + .mapNotNull { it.senderId } + .distinct() + +/** + * A private group's members as this device knows them, for its Members grid while the server + * won't list the roster: the creator, then the viewer, then everyone who has posted, newest + * first, each once. Anyone [profiles] can't name is left out rather than shown unnamed. + */ +internal fun knownPrivateGroupMembers( + creator: ID?, + selfId: ID?, + posters: List, + profiles: Map, +): List = (listOfNotNull(creator, selfId) + posters) + .distinct() + .mapNotNull { userId -> + profiles[userId.hexEncodedString()]?.let { ChatMember(userId, it, pointers = emptyList()) } + } + +/** A lobby call in flight from a private group's profile. */ +enum class LobbyCall { Request, Cancel } + +/** An Approve or Deny out for one row of Join Requests. */ +enum class JoinRequestCall { Approve, Deny } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt new file mode 100644 index 0000000000..7f4b9c771d --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/JoinRequestsScreen.kt @@ -0,0 +1,255 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import android.os.Parcelable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.GroupInviteSheetSource +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.openGroupInvite +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.chat.RequestAge +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Node 11206:60 — who is waiting to join a private group the viewer created, newest first, each + * with Deny and Approve. Node 11214:67 is the empty state: two lines and Share Group, no icon. + * + * Reads the flow's shared [ChatViewModel], which holds the lobby from HostLobbies, so this list, + * the profile's card and the in-chat anchor all count the same people. + */ +@Composable +internal fun JoinRequestsScreen(viewModel: ChatViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + val navigator = LocalCodeNavigator.current + val group = state.subject as? ChatSubject.Group + + // LobbyUpdate is best-effort, so the list refetches whenever it comes on screen. + LaunchedEffect(Unit) { viewModel.dispatchEvent(ChatViewModel.Event.JoinRequestsOpened) } + + // One clock for every row's "requested 2h ago". + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_joinRequests), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + ) { innerPadding -> + if (state.joinRequests.isEmpty()) { + NoJoinRequests( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + groupName = group?.groupTitle.orEmpty(), + onShare = { + viewModel.dispatchEvent(ChatViewModel.Event.InviteSheetOpened(GroupInviteSheetSource.PROFILE)) + navigator.openGroupInvite() + }, + ) + } else { + val unknownName = stringResource(R.string.title_unnamedUser) + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(vertical = CodeTheme.dimens.grid.x2), + ) { + items(state.joinRequests, key = { it.publicKey.byteArray.toList() }) { member -> + val userId = member.userProfile.userId + JoinRequestRow( + member = member, + name = chatterName(member.userProfile.displayName, member.userProfile.username, unknownName), + age = RequestAge.of(member.enteredAt, now), + call = userId?.let { state.joinRequestCalls[it] }, + onOpen = { + userId ?: return@JoinRequestRow + flowNavigator.navigateTo( + ChatStep.Profile( + contact = ChatParticipant.TipUser(userId, member.userProfile), + origin = ProfileOrigin.GroupMember, + ) + ) + }, + onDeny = { viewModel.dispatchEvent(ChatViewModel.Event.DenyJoinRequest(member)) }, + onApprove = { viewModel.dispatchEvent(ChatViewModel.Event.ApproveJoinRequest(member)) }, + ) + } + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x10), + text = stringResource(R.string.subtitle_joinRequestsFooter), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +@Composable +private fun JoinRequestRow( + member: LobbyMember, + name: String, + age: RequestAge, + call: JoinRequestCall?, + onOpen: () -> Unit, + onDeny: () -> Unit, + onApprove: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Row( + modifier = Modifier + .weight(1f) + .clickable(onClick = onOpen), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar(userProfile = member.userProfile, modifier = Modifier.size(40.dp).clip(CircleShape)) + Column { + Text( + text = name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = when (age) { + RequestAge.JustNow -> stringResource(R.string.subtitle_requestedJustNow) + is RequestAge.Minutes -> stringResource(R.string.subtitle_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.subtitle_requestedHoursAgo, age.count) + is RequestAge.Days -> if (age.count == 1L) { + stringResource(R.string.subtitle_requestedYesterday) + } else { + stringResource(R.string.subtitle_requestedDaysAgo, age.count) + } + }, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + // Deny greys out while Approve runs, and Approve stays its own size with the spinner in it. + CodeButton( + text = stringResource(R.string.action_deny), + buttonState = ButtonState.Filled10, + enabled = call == null, + isLoading = call == JoinRequestCall.Deny, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PillPadding, + overrideContentPadding = true, + onClick = onDeny, + ) + CodeButton( + text = stringResource(R.string.action_approve), + buttonState = ButtonState.Filled, + enabled = call == null || call == JoinRequestCall.Approve, + isLoading = call == JoinRequestCall.Approve, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PillPadding, + overrideContentPadding = true, + onClick = onApprove, + ) + } +} + +@Composable +private fun NoJoinRequests(groupName: String, onShare: () -> Unit, modifier: Modifier = Modifier) { + Box(modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_noJoinRequests), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + // The frame wraps this at about two-thirds of the screen width. + modifier = Modifier.widthIn(max = 280.dp), + text = stringResource(R.string.subtitle_noJoinRequests, groupName), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + CodeButton( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_shareGroup), + buttonState = ButtonState.Filled10, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PillPadding, + overrideContentPadding = true, + onClick = onShare, + ) + } + } +} + +private val PillPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt new file mode 100644 index 0000000000..31a97d9ec4 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MembersScreen.kt @@ -0,0 +1,176 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import android.os.Parcelable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.core.chat.ProfileOrigin +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.chatterName +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes + +/** + * Node 11210:70 — every member of a private group: the creator pinned first with a Host tag, then + * the newest joins. Tap opens the person's profile; there is no remove action. + * + * Reads the flow's shared [ChatViewModel], which holds the roster from GroupRosters, so this list + * and the profile's grid show the same people. + */ +@Composable +internal fun MembersScreen(viewModel: ChatViewModel) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val flowNavigator = rememberFlowNavigator() + val group = state.subject as? ChatSubject.Group + + // RosterUpdate can skip versions, so the list refetches whenever it comes on screen. + LaunchedEffect(Unit) { viewModel.dispatchEvent(ChatViewModel.Event.MembersOpened) } + + val now by produceState(Clock.System.now()) { + while (true) { + delay(1.minutes) + value = Clock.System.now() + } + } + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_members), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + ) { innerPadding -> + val unknownName = stringResource(R.string.title_unnamedUser) + val count = (group?.memberCount ?: state.members.size.toLong()).toInt() + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(bottom = CodeTheme.dimens.grid.x4), + ) { + item { + Text( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2, bottom = CodeTheme.dimens.grid.x1), + text = pluralStringResource(R.plurals.header_memberCount, count, count), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + items(state.members, key = { it.userId }) { member -> + MemberRow( + member = member, + name = chatterName(member.userProfile.displayName, member.userProfile.username, unknownName), + isHost = member.userId == group?.creator, + age = member.joinedAt?.let { JoinAge.of(it, now) }, + onOpen = { + flowNavigator.navigateTo( + ChatStep.Profile( + contact = ChatParticipant.TipUser(member.userId, member.userProfile), + origin = ProfileOrigin.GroupMember, + ) + ) + }, + ) + } + } + } +} + +@Composable +private fun MemberRow( + member: ChatMember, + name: String, + isHost: Boolean, + age: JoinAge?, + onOpen: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen) + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + ContactAvatar(userProfile = member.userProfile, modifier = Modifier.size(40.dp).clip(CircleShape)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = name, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isHost) { + Text( + modifier = Modifier + .background(Color.White.copy(alpha = 0.1f), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + text = stringResource(R.string.label_host), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + ) + } else if (age != null) { + Text( + text = joinAgeLabel(age), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + maxLines = 1, + ) + } + } + } +} + +@Composable +private fun joinAgeLabel(age: JoinAge): String = when (age) { + JoinAge.JustNow -> stringResource(R.string.subtitle_joinedJustNow) + is JoinAge.Minutes -> stringResource(R.string.subtitle_joinedMinutesAgo, age.count) + is JoinAge.Hours -> stringResource(R.string.subtitle_joinedHoursAgo, age.count) + JoinAge.Yesterday -> stringResource(R.string.subtitle_joinedYesterday) + is JoinAge.Days -> stringResource(R.string.subtitle_joinedDaysAgo, age.count) + JoinAge.LastWeek -> stringResource(R.string.subtitle_joinedLastWeek) + is JoinAge.Weeks -> stringResource(R.string.subtitle_joinedWeeksAgo, age.count) + JoinAge.LastMonth -> stringResource(R.string.subtitle_joinedLastMonth) + is JoinAge.Months -> stringResource(R.string.subtitle_joinedMonthsAgo, age.count) +} 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 2a47a8cb38..b89a503d7d 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 @@ -157,6 +157,9 @@ class ChatEventStallTest { userProfileDataSource = userProfileDataSource, rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) 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 c31c9fe650..2f231485a2 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 @@ -166,6 +166,9 @@ class ChatGroupAnalyticsTest { userProfileDataSource = userProfileDataSource, rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) 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 adf5f45d5f..60c7185bbc 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 @@ -170,6 +170,9 @@ class ChatGroupCashLinkTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) 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 63914757da..0d58626af3 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 @@ -114,6 +114,9 @@ class ChatMentionPickerTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = rosterSearch, featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) 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 5087bbce1f..df06aa8659 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 @@ -119,6 +119,9 @@ class ChatOpenTranscriptTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = featuredGroups, + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = identifier, ) 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 4514def554..64e81fc788 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 @@ -104,6 +104,9 @@ class ChatPreviewsReadyTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = featuredGroups, + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, linkCardMemory = memory, 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 0c2d4ebea1..5963249765 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 @@ -157,6 +157,9 @@ class ChatSendFailureAnalyticsTest { userProfileDataSource = userProfileDataSource, rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) 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 e8c512ffab..b697912ee6 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 @@ -118,6 +118,9 @@ class ChatTypingGateTest { userProfileDataSource = mockk(relaxed = true), rosterSearch = mockk(relaxed = true), featuredGroups = FeaturedGroupsStore(mockk(relaxed = true)), + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + groupRosters = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), identifier = null, ) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchorTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchorTest.kt new file mode 100644 index 0000000000..cdad5893ee --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchorTest.kt @@ -0,0 +1,49 @@ +package com.flipcash.app.messenger.internal.screens.components + +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.services.models.chat.LobbyMember +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JoinRequestsAnchorTest { + + private val and = { head: String, last: String -> "$head and $last" } + + @Test + fun `no names is empty`() = assertEquals("", joinNames(emptyList(), and)) + + @Test + fun `one name stands alone`() = assertEquals("Jasper", joinNames(listOf("Jasper"), and)) + + @Test + fun `two names are joined by and`() = + assertEquals("Jasper and Tam", joinNames(listOf("Jasper", "Tam"), and)) + + @Test + fun `the last of several is joined by and, the rest by commas`() = + assertEquals( + "Jasper, Vincentjeezus24 and Tam", + joinNames(listOf("Jasper", "Vincentjeezus24", "Tam"), and), + ) + + @Test + fun `the host sees the anchor while someone is waiting`() { + val state = ChatViewModel.State(isLobbyHost = true, joinRequests = listOf(mockk())) + assertTrue(state.showsJoinRequestsAnchor) + } + + @Test + fun `the anchor hides at zero`() { + val state = ChatViewModel.State(isLobbyHost = true, joinRequests = emptyList()) + assertFalse(state.showsJoinRequestsAnchor) + } + + @Test + fun `only the host sees the anchor`() { + val state = ChatViewModel.State(isLobbyHost = false, joinRequests = listOf(mockk())) + assertFalse(state.showsJoinRequestsAnchor) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/KnownPrivateGroupMembersTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/KnownPrivateGroupMembersTest.kt new file mode 100644 index 0000000000..3e9092ab1e --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/KnownPrivateGroupMembersTest.kt @@ -0,0 +1,59 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatMessage +import com.getcode.opencode.model.core.ID +import com.getcode.utils.hexEncodedString +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +class KnownPrivateGroupMembersTest { + + private val host: ID = listOf(1) + private val me: ID = listOf(2) + private val poster: ID = listOf(3) + private val stranger: ID = listOf(4) + + private fun profiles(vararg ids: ID): Map = + ids.associate { it.hexEncodedString() to mockk(relaxed = true) } + + private fun message(id: Long, sender: ID?) = ChatMessage( + messageId = id, + senderId = sender, + content = emptyList(), + timestamp = Instant.fromEpochSeconds(id), + unreadSeq = 0, + ) + + @Test + fun `posters are distinct, newest first, skipping messages with no sender`() { + val alice: ID = listOf(10) + val bob: ID = listOf(11) + val messages = listOf(message(1, alice), message(2, bob), message(3, null), message(4, alice)) + assertEquals(listOf(alice, bob), postersNewestFirst(messages)) + } + + @Test + fun `host first, then the viewer, then posters, each once`() { + val members = knownPrivateGroupMembers( + creator = host, + selfId = me, + posters = listOf(poster, host, me), + profiles = profiles(host, me, poster), + ) + assertEquals(listOf(host, me, poster), members.map { it.userId }) + } + + @Test + fun `anyone without a profile is left out`() { + val members = knownPrivateGroupMembers( + creator = null, + selfId = me, + posters = listOf(stranger), + profiles = profiles(me), + ) + assertEquals(listOf(me), members.map { it.userId }) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt new file mode 100644 index 0000000000..19dddb7654 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupJoinTest.kt @@ -0,0 +1,55 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant +import com.flipcash.shared.chat.RequestAge + +class PrivateGroupJoinTest { + + private val at = Instant.fromEpochSeconds(1_700_000_000) + + @Test + fun `public group has no join action`() { + assertNull(PrivateGroupJoin.resolve(isPrivate = false, isMember = false, requestedAt = null)) + } + + @Test + fun `member of a private group has no join action`() { + assertNull(PrivateGroupJoin.resolve(isPrivate = true, isMember = true, requestedAt = at)) + } + + @Test + fun `non-member without a request can ask`() { + assertEquals(PrivateGroupJoin.Request, PrivateGroupJoin.resolve(isPrivate = true, isMember = false, requestedAt = null)) + } + + @Test + fun `unresolved membership is treated as not a member`() { + assertEquals(PrivateGroupJoin.Request, PrivateGroupJoin.resolve(isPrivate = true, isMember = null, requestedAt = null)) + } + + @Test + fun `non-member with a request is waiting`() { + assertEquals(PrivateGroupJoin.Requested(at), PrivateGroupJoin.resolve(isPrivate = true, isMember = false, requestedAt = at)) + } + + @Test + fun `request age buckets`() { + assertEquals(RequestAge.JustNow, RequestAge.of(at, at + 59.seconds)) + assertEquals(RequestAge.Minutes(1), RequestAge.of(at, at + 1.minutes)) + assertEquals(RequestAge.Minutes(59), RequestAge.of(at, at + 59.minutes + 59.seconds)) + assertEquals(RequestAge.Hours(2), RequestAge.of(at, at + 2.hours + 30.minutes)) + assertEquals(RequestAge.Days(3), RequestAge.of(at, at + 3.days + 5.hours)) + } + + @Test + fun `a clock behind the server reads as just now`() { + assertEquals(RequestAge.JustNow, RequestAge.of(at, at - 5.minutes)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupMembersTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupMembersTest.kt new file mode 100644 index 0000000000..f5d6dc6618 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/PrivateGroupMembersTest.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.outlined.LockOpen +import com.flipcash.services.models.chat.ChatMember +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class PrivateGroupMembersTest { + + private val now = Instant.fromEpochSeconds(1_800_000_000) + + private fun member(id: Byte, joinedAgo: kotlin.time.Duration?) = ChatMember( + userId = listOf(id), + userProfile = mockk(relaxed = true), + pointers = emptyList(), + joinedAt = joinedAgo?.let { now - it }, + ) + + @Test + fun `creator is pinned first, then newest joins, unknown join time last`() { + val creator = member(1, 30.days) + val old = member(2, 10.days) + val unknown = member(3, null) + val recent = member(4, 2.hours) + + val ordered = privateGroupMembers(listOf(old, unknown, creator, recent), creator = listOf(1)) + + assertEquals(listOf(1, 4, 2, 3), ordered.map { it.userId.single() }) + } + + @Test + fun `no creator known keeps newest first`() { + val a = member(1, 3.days) + val b = member(2, 1.hours) + + assertEquals(listOf(b, a), privateGroupMembers(listOf(a, b), creator = null)) + } + + @Test + fun `join age buckets match the members list wording`() { + fun age(ago: kotlin.time.Duration) = JoinAge.of(now - ago, now) + + assertEquals(JoinAge.JustNow, age(30.seconds)) + assertEquals(JoinAge.Minutes(5), age(5.minutes)) + assertEquals(JoinAge.Hours(2), age(2.hours)) + assertEquals(JoinAge.Yesterday, age(30.hours)) + assertEquals(JoinAge.Days(3), age(3.days)) + assertEquals(JoinAge.LastWeek, age(8.days)) + assertEquals(JoinAge.Weeks(3), age(21.days)) + assertEquals(JoinAge.LastMonth, age(40.days)) + assertEquals(JoinAge.Months(3), age(95.days)) + } + + @Test + fun `a join time ahead of the clock reads just now`() { + assertEquals(JoinAge.JustNow, JoinAge.of(now + 1.minutes, now)) + } + + @Test + fun `encryption shows a closed lock only for a private group`() { + assertEquals(Icons.Filled.Lock, GroupProfileMenuAction.Encryption.icon(isPrivate = true)) + assertEquals(Icons.Outlined.LockOpen, GroupProfileMenuAction.Encryption.icon(isPrivate = false)) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index a955a3dfcd..d5e8aff1f7 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,6 +1,10 @@ package com.flipcash.app.tipping import androidx.compose.animation.AnimatedVisibility +import androidx.compose.ui.graphics.Color +import com.getcode.ui.components.CircularIconButton +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image @@ -49,9 +53,22 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.offset +import com.getcode.ui.components.NotificationDot +import com.getcode.theme.extraLarge import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -84,6 +101,7 @@ import com.getcode.theme.CodeTheme import com.getcode.ui.components.toast.LocalFloatingToastHost import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.WithNotificationDot import com.getcode.ui.components.TopScrollEdge import com.getcode.ui.components.SwipeAction import com.getcode.ui.components.SwipeActionRow @@ -168,6 +186,13 @@ fun ChatsScreen() { // Node 9442:5779 — the only way to start a chat with someone who has never paid // you. A pushed route rather than an in-place screen: this list is a tab home, and // anything drawn inside it would leave the tab bar over the entry screen. + // Always shown: Requests has its own empty state. The dot only while someone waits + // on the viewer. + leadingContent = { + RequestsMenu(needsDecision = state.needsDecision) { + navigator.push(AppRoute.Messaging.Requests) + } + }, endContent = { AppBarDefaults.Add { navigator.push(AppRoute.Messaging.NewChat) } }, @@ -497,6 +522,91 @@ private fun ChatFilterRow( } } +/** + * The header's leading overflow menu, whose one item is the way into Requests. The dot, on the + * menu and on its Requests item, marks someone waiting on the viewer. + */ +@Composable +private fun RequestsMenu(needsDecision: Boolean, onRequests: () -> Unit) { + var expanded by remember { mutableStateOf(false) } + val description = if (needsDecision) { + stringResource(R.string.content_description_moreWithRequests) + } else { + stringResource(R.string.action_moreProfileActions) + } + val density = LocalDensity.current + var anchorHeight by remember { mutableStateOf(0.dp) } + Box(modifier = Modifier.onSizeChanged { anchorHeight = with(density) { it.height.toDp() } }) { + // The group info screen's ⋯ button and menu, so the two overflow menus look the same. + WithNotificationDot(isShown = needsDecision, offset = DpOffset(0.dp, 0.dp)) { + CircularIconButton( + modifier = Modifier.clearAndSetSemantics { + contentDescription = description + role = Role.Button + }, + onClick = { expanded = true }, + testTag = "chats_overflow_menu", + ) { size -> + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + } + // Opens over the button, as the group info menu does. + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = CodeTheme.colors.brandLight, + shape = CodeTheme.shapes.extraLarge, + offset = DpOffset(x = 0.dp, y = -anchorHeight), + ) { + val requestsDescription = if (needsDecision) { + stringResource(R.string.content_description_requestsWaiting) + } else { + stringResource(R.string.title_requests) + } + DropdownMenuItem( + modifier = Modifier.clearAndSetSemantics { + contentDescription = requestsDescription + role = Role.Button + }, + leadingIcon = { + Box { + Icon( + imageVector = Icons.Outlined.PersonAdd, + contentDescription = null, + tint = CodeTheme.colors.textMain, + ) + if (needsDecision) { + // Ringed in the menu's surface, so it reads as cut out of the glyph. + NotificationDot( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 4.dp, y = (-4).dp), + ring = CodeTheme.colors.brandLight, + ) + } + } + }, + text = { + Text( + text = stringResource(R.string.title_requests), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + }, + onClick = { + expanded = false + onRequests() + }, + ) + } + } +} + /** * The way into the archived chats. Secondary throughout (icon, label, count, chevron), so it reads * as a folder rather than one more chat; the count is the unread archived chats that aren't muted. diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt index c10ed9295a..4b5291a301 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewChatScreen.kt @@ -53,9 +53,14 @@ fun NewChatScreen() { icon = R.drawable.ic_group_3, onClick = { analytics.track(GroupEvents.newOpened()) - navigator.push(AppRoute.Messaging.NewGroup) + navigator.push(AppRoute.Messaging.NewGroup()) }, ) + ChoiceRow( + label = stringResource(R.string.action_createPrivateGroup), + icon = R.drawable.ic_lock, + onClick = { navigator.push(AppRoute.Messaging.NewGroup(isPrivate = true)) }, + ) } } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt index 29bd185e8b..a648159ec6 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/NewGroupFlowScreen.kt @@ -2,6 +2,7 @@ package com.flipcash.app.tipping import android.os.Parcelable import androidx.compose.runtime.Composable +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -59,6 +60,12 @@ fun NewGroupFlowScreen( // to reach it. var createdChat by remember { mutableStateOf(null) } + // Created here, before any step asks for it, so the steps' flowSharedViewModel resolves this + // instance — the flow's store owner is this entry's — and it is born knowing which form it is. + hiltViewModel( + creationCallback = { factory -> factory.create(route.isPrivate) }, + ) + FlowHost( initialStack = route.rememberInitialStack(), resultStateRegistry = resultStateRegistry, @@ -78,7 +85,14 @@ fun NewGroupFlowScreen( navigator.popUntil { it !is AppRoute.Messaging.NewGroup && it !is AppRoute.Messaging.NewChat } - navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) + // A private group opens on its profile (surface 6): nothing can happen in the + // chat until the host approves someone, and the profile is where requests are. + navigator.push( + AppRoute.Messaging.Chat( + identifier = ChatIdentifier.ByChatId(chatId), + openOnProfile = route.isPrivate, + ) + ) } } }, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt new file mode 100644 index 0000000000..b433b7b304 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/RequestsScreen.kt @@ -0,0 +1,274 @@ +package com.flipcash.app.tipping + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +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.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.tipping.internal.HostedRequest +import com.flipcash.app.tipping.internal.RequestsViewModel +import com.flipcash.app.tipping.internal.SentRequest +import com.flipcash.features.tipping.R +import com.flipcash.shared.chat.RequestAge +import com.flipcash.shared.chat.ui.ChatListRow +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.SubtitleText +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.text.SectionHeader +import com.getcode.ui.theme.CodeScaffold +import kotlinx.coroutines.delay +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * Join requests in both directions, behind the Requests button on the Chats header: private groups + * the viewer hosts with people waiting ("Waiting on you"), and groups the viewer asked to join + * ("Sent"). Sent knows only about requests made on this device; the server can't list them. + */ +@Composable +fun RequestsScreen() { + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_requests), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { navigator.pop() }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("requests_list"), + contentPadding = PaddingValues(top = padding.calculateTopPadding()), + ) { + if (state.isLoaded && state.isEmpty) { + item(key = "empty") { + Box( + modifier = Modifier + .animateItem() + .fillParentMaxSize() + .padding(bottom = padding.calculateBottomPadding()), + contentAlignment = Alignment.Center, + ) { + // Join Requests' empty state, without its Share Group button: there's no + // one group to share. + Column( + modifier = Modifier.padding(horizontal = 40.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_noRequests), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.subtitle_noRequests), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + } + + if (state.hosted.isNotEmpty()) { + item(key = "hosted_header") { + SectionHeader( + modifier = Modifier.animateItem(), + title = stringResource(R.string.header_waitingOnYou), + ) + } + itemsIndexed(state.hosted, key = { _, it -> "hosted_${it.chat.chatId}" }) { index, request -> + HostedRequestRow( + modifier = Modifier.animateItem(), + request = request, + showDivider = index < state.hosted.lastIndex, + ) { + navigator.push( + AppRoute.Messaging.Chat( + ChatIdentifier.ByChatId(request.chat.chatId), + openOnJoinRequests = true, + ) + ) + } + } + } + + if (state.sent.isNotEmpty()) { + item(key = "sent_header") { + SectionHeader( + modifier = Modifier.animateItem(), + title = stringResource(R.string.header_sentRequests), + ) + } + itemsIndexed(state.sent, key = { _, it -> "sent_${it.chat.chatId}" }) { index, request -> + SentRequestRow( + modifier = Modifier.animateItem(), + request = request, + showDivider = index < state.sent.lastIndex, + ) { + // The group's profile, where Cancel Request lives: the viewer isn't a member. + navigator.push( + AppRoute.Messaging.Chat( + ChatIdentifier.ByChatId(request.chat.chatId), + openOnProfile = true, + ) + ) + } + } + } + } + } +} + +/** A hosted group with people waiting: the count is secondary, like Archived's, not an unread badge. */ +@Composable +private fun HostedRequestRow( + request: HostedRequest, + showDivider: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + RequestRow( + modifier = modifier.testTag("requests_hosted_row"), + chat = request.chat, + subtitle = pluralStringResource(R.plurals.subtitle_joinRequestsWaiting, request.count, request.count), + count = request.count, + showDivider = showDivider, + onClick = onClick, + ) +} + +@Composable +private fun SentRequestRow( + request: SentRequest, + showDivider: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + RequestRow( + modifier = modifier.testTag("requests_sent_row"), + chat = request.chat, + subtitle = requestedAgo(request.requestedAt), + count = null, + showDivider = showDivider, + onClick = onClick, + ) +} + +@Composable +private fun RequestRow( + chat: ConversationReference, + subtitle: String, + count: Int?, + showDivider: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + ChatListRow( + modifier = modifier, + avatar = { + ContactAvatar( + image = chat.image, + displayName = chat.name.orEmpty(), + access = chat.avatarAccess, + modifier = Modifier + .requiredSize(CodeTheme.dimens.staticGrid.x8) + .clip(CircleShape), + ) + }, + title = { + Text( + modifier = Modifier.weight(1f), + text = chat.name.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + subtitle = { SubtitleText(subtitle) }, + endAction = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + if (count != null) { + Text( + text = count.toString(), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + }, + showDivider = showDivider, + onClick = onClick, + ) +} + +/** "Requested 2h ago", redrawn each minute, the only granularity it shows. */ +@Composable +private fun requestedAgo(at: Instant): String { + var now by remember { mutableStateOf(Clock.System.now()) } + LaunchedEffect(Unit) { + while (true) { + delay(1.minutes) + now = Clock.System.now() + } + } + return when (val age = RequestAge.of(at, now)) { + RequestAge.JustNow -> stringResource(R.string.title_requestedJustNow) + is RequestAge.Minutes -> stringResource(R.string.action_requestedMinutesAgo, age.count) + is RequestAge.Hours -> stringResource(R.string.action_requestedHoursAgo, age.count) + is RequestAge.Days -> if (age.count == 1L) { + stringResource(R.string.title_requestedYesterday) + } else { + stringResource(R.string.action_requestedDaysAgo, age.count) + } + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 45bfe79fcb..5767c4af18 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -10,6 +10,13 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.LobbyRequests +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.services.models.chat.LobbyMember +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatListEntry @@ -38,6 +45,8 @@ internal class ChatsViewModel @Inject constructor( private val resources: ResourceHelper, dispatchers: DispatcherProvider, private val archiveStore: ChatArchiveStore, + private val lobbyRequests: LobbyRequests, + private val hostLobbies: HostLobbies, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -54,7 +63,11 @@ internal class ChatsViewModel @Inject constructor( // Whether a pull from the top has brought the chips on. Held here rather than in the // screen so it survives opening a chat and coming back. val chipsRevealed: Boolean = false, + // Someone waits in a private group the viewer hosts: the dot on the overflow menu and on + // its Requests item. + val needsDecision: Boolean = false, ) { + /** The chips stay once revealed, and always show under a filter other than All. */ val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All @@ -85,6 +98,8 @@ internal class ChatsViewModel @Inject constructor( data class FilterSelected(val filter: ChatListFilter) : Event data object ChipsRevealed : Event + + data class RequestsUpdated(val needsDecision: Boolean) : Event } fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) @@ -100,6 +115,10 @@ internal class ChatsViewModel @Inject constructor( } init { + // No push says a join request was answered; an admitted group only shows up once the feed + // is synced, which refresh() does when GetChat says a request has left its lobby. + viewModelScope.launch { lobbyRequests.refresh() } + val selfId = { userManager.accountId } fun content( @@ -155,6 +174,21 @@ internal class ChatsViewModel @Inject constructor( .flowOn(dispatchers.Default) .onEach { dispatchEvent(it) } .launchIn(viewModelScope) + + // The viewer's hosted private groups, archived ones included; each one's lobby is fetched + // once per chat-list load, since LobbyUpdate only reaches a running stream. + val hostedGroups = chatCoordinator.chatListFeeds() + .map { feeds -> hostedPrivateGroups(feeds.main + feeds.archived, selfId()) } + .distinctUntilChanged() + hostedGroups + .onEach { groups -> groups.keys.forEach { chatId -> viewModelScope.launch { hostLobbies.load(chatId) } } } + .launchIn(viewModelScope) + combine(hostedGroups, hostLobbies.state) { hosted, lobbies -> + Event.RequestsUpdated(needsDecision = hostedRequests(hosted.keys, lobbies).isNotEmpty()) + } + .distinctUntilChanged() + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) } internal companion object { @@ -166,7 +200,30 @@ internal class ChatsViewModel @Inject constructor( // A chip can only be tapped once the row is on screen, so choosing one reveals it. is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } + is Event.RequestsUpdated -> { state -> + state.copy(needsDecision = event.needsDecision) + } } } } } + +/** Private groups [selfId] created, by id, with their titles. */ +internal fun hostedPrivateGroups(summaries: List, selfId: ID?): Map { + selfId ?: return emptyMap() + return summaries + .map { it.metadata } + .filter { it.isPrivate && it.creator == selfId } + .associate { it.chatId to it.title.orEmpty() } +} + +/** + * The hosted groups with someone waiting, each with its waiting list, the group whose newest + * request is the most recent first. + */ +internal fun hostedRequests( + hosted: Collection, + lobbies: Map>, +): List>> = hosted + .mapNotNull { chatId -> lobbies[chatId]?.takeIf { it.isNotEmpty() }?.let { chatId to it } } + .sortedByDescending { (_, waiting) -> waiting.maxOf { it.enteredAt } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt index 8f34303b26..c33cf2c271 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModel.kt @@ -14,6 +14,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.tipping.R import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.chat.GroupKeyEnvelopes import com.flipcash.services.models.BlobRejectedException import com.flipcash.services.models.StartChatError import com.flipcash.services.models.chat.BlobId @@ -41,7 +42,11 @@ import com.getcode.util.resources.ContentReader import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull @@ -50,7 +55,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import javax.inject.Inject /** * The three preset balance requirements on the form — node 10127:118014's `$10 / $50 / $100` chips. @@ -75,9 +79,14 @@ internal val BalancePresets: List = listOf(Fiat(10), Fiat(50), Fiat(100)) * `StartChat` would answer `RULES_NOT_SATISFIED`, which is a worse way to learn it. And the picture * has to be uploaded and `READY` first, because `PublicGroupChatParameters.picture` is a blob id, not * bytes. Both server results are still handled: a balance can move between the check and the call. + * + * [isPrivate] is the private group form (node 11203:65): the same draft without a requirement, and + * a second step after `StartChat` — the creator's key envelope — that has to land before anything + * can happen in the group. */ -@HiltViewModel -internal class CreateGroupViewModel @Inject constructor( +@HiltViewModel(assistedFactory = CreateGroupViewModel.Factory::class) +internal class CreateGroupViewModel @AssistedInject constructor( + @Assisted isPrivate: Boolean, exchange: Exchange, tokenCoordinator: TokenCoordinator, dispatchers: DispatcherProvider, @@ -87,13 +96,16 @@ internal class CreateGroupViewModel @Inject constructor( private val contentReader: ContentReader, private val resources: ResourceHelper, private val analytics: FlipcashAnalytics, + private val groupKeys: GroupKeyEnvelopes, ) : BaseViewModel( - initialState = State(), + initialState = State(isPrivate = isPrivate), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { data class State( + /** Fixed for the flow: privacy can't change after creation, so it is not a form field. */ + val isPrivate: Boolean = false, val titleFieldState: TextFieldState = TextFieldState(), /** The picked picture's re-encoded local copy, while it is being prepared and after. */ val image: Loadable = Loadable.Loading(), @@ -154,7 +166,7 @@ internal class CreateGroupViewModel @Inject constructor( * sets; `speaker` is left empty rather than null because [ChatRules] holds both as lists. */ val rules: ChatRules? - get() = amount?.let { rulesFor(currency, it) } + get() = if (isPrivate) null else amount?.let { rulesFor(currency, it) } /** * The creator measured against their own rule, by the same predicate the join gate uses. @@ -187,7 +199,7 @@ internal class CreateGroupViewModel @Inject constructor( get() = access.let { it == null || it is GroupAccess.Eligible } val canCreate: Boolean - get() = hasTitle && rules != null && selfSatisfied && processingState.isIdle + get() = hasTitle && processingState.isIdle && (isPrivate || (rules != null && selfSatisfied)) /** * The amount shown in the `…` chip's place, when one was entered there. @@ -256,6 +268,12 @@ internal class CreateGroupViewModel @Inject constructor( private val attempt = GroupCreateAttempt() + /** + * A private group `StartChat` created whose key envelope has not landed yet. Create resumes + * here rather than calling `StartChat` again: the chat exists, only its key is missing. + */ + private var awaitingKey: ChatMetadata? = null + init { exchange.observePreferredRate() .distinctUntilChanged() @@ -340,9 +358,14 @@ internal class CreateGroupViewModel @Inject constructor( private suspend fun create() { val state = stateFlow.value if (!state.processingState.isIdle) return + if (!state.hasTitle) return + + if (state.isPrivate) { + createPrivate(state) + return + } val amount = state.amount ?: return - if (!state.hasTitle) return // The self-satisfaction check the form already gates Create on, re-read here because the // balance behind it is a live flow and the tap is a separate moment from the render. @@ -403,6 +426,70 @@ internal class CreateGroupViewModel @Inject constructor( } } + /** + * `StartChat(private_group)`, then the creator's key envelope. Create stays loading through + * both. A failed envelope is retried a few times here, and after that by the next Create, which + * goes straight back to the envelope. + */ + private suspend fun createPrivate(state: State) { + dispatchEvent(Event.UpdateProcessingState(loading = true)) + + val chat = awaitingKey ?: run { + val draft = GroupDraft( + title = state.title, + picture = state.image.dataOrNull, + currency = state.currency, + amount = null, + ) + val idempotencyKey = attempt.keyFor(draft) + + val picture = if (draft.picture == null) { + null + } else { + attempt.picture ?: uploadPicture(draft.picture, state.imageMimeType) + ?.also { attempt.rememberPicture(it) } + ?: run { + dispatchEvent(Event.UpdateProcessingState()) + return + } + } + + chatCoordinator.create( + parameters = StartChatParameters.PrivateGroup(title = draft.title, picture = picture), + idempotencyKey = idempotencyKey, + ).also { analytics.trackCreated(null, hasPicture = picture != null, it) } + .onSuccess { attempt.clear() } + .getOrElse { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceCreateFailure(cause, currencyName = null) + return + } + } + + awaitingKey = chat + if (storeKeyWithRetry(chat)) { + awaitingKey = null + dispatchSuccessThen(Event.UpdateProcessingState(success = true)) { + dispatchEvent(Event.ChatCreated(chat)) + dispatchEvent(Event.UpdateProcessingState()) + } + } else { + dispatchEvent(Event.UpdateProcessingState()) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupCreateFailed), + message = resources.getString(R.string.error_description_groupCreateFailed), + ) + } + } + + private suspend fun storeKeyWithRetry(chat: ChatMetadata): Boolean { + repeat(KEY_ATTEMPTS) { index -> + if (groupKeys.storeNewKey(chat.chatId).isSuccess) return true + if (index < KEY_ATTEMPTS - 1) delay(KEY_RETRY_DELAY_MS * (index + 1)) + } + return false + } + /** Uploads the prepared picture and returns its READY blob, or null having reported why not. */ private suspend fun uploadPicture(uri: Uri, mimeType: String): BlobId? { val bytes = contentReader.readBytes(uri) @@ -490,7 +577,10 @@ internal class CreateGroupViewModel @Inject constructor( is StartChatError.Denied -> BottomBarManager.showAlert( title = resources.getString(R.string.error_title_groupCreateDenied), - message = resources.getString(R.string.error_description_groupCreateDenied), + message = resources.getString( + if (stateFlow.value.isPrivate) R.string.error_description_privateGroupCreateDenied + else R.string.error_description_groupCreateDenied + ), ) // The blob was accepted by storage and refused by StartChat, so the picture is what has @@ -510,7 +600,15 @@ internal class CreateGroupViewModel @Inject constructor( } } + @AssistedFactory + interface Factory { + fun create(isPrivate: Boolean): CreateGroupViewModel + } + companion object { + private const val KEY_ATTEMPTS = 3 + private const val KEY_RETRY_DELAY_MS = 500L + /** * The stored requirement for a keypad [entered] amount, or null when it falls below the * currency's minimum transfer value (one unit of its last decimal place). diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt index 4764314d03..8cdb64bb25 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/GroupCreateAttempt.kt @@ -21,7 +21,8 @@ internal data class GroupDraft( */ val currency: GroupCurrency, /** The required balance, in USD — the currency [com.flipcash.shared.chat.GroupAccess] compares in. */ - val amount: Fiat, + /** Null for a private group, which has no requirement. */ + val amount: Fiat?, ) /** diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt new file mode 100644 index 0000000000..537d6441cf --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/RequestsViewModel.kt @@ -0,0 +1,139 @@ +package com.flipcash.app.tipping.internal + +import androidx.lifecycle.viewModelScope +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.LobbyRequests +import com.flipcash.shared.chat.chatListFeeds +import com.flipcash.shared.chat.ui.ConversationReference +import com.flipcash.shared.chat.ui.toConversationReference +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Instant + +/** + * Backs the Requests screen: join requests in both directions. Private groups the viewer hosts with + * people waiting, and groups the viewer has asked to join. + */ +@HiltViewModel +internal class RequestsViewModel @Inject constructor( + chatCoordinator: ChatCoordinator, + private val chatController: ChatController, + userManager: UserManager, + resources: ResourceHelper, + private val lobbyRequests: LobbyRequests, + private val hostLobbies: HostLobbies, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State( + val hosted: List = emptyList(), + val sent: List = emptyList(), + // False until every sent request's group has been asked about, so the empty message does + // not flash over a Sent row that is about to arrive. + val isLoaded: Boolean = false, + ) { + val isEmpty: Boolean get() = hosted.isEmpty() && sent.isEmpty() + } + + sealed interface Event { + data class Updated(val hosted: List, val sent: List, val isLoaded: Boolean) : Event + } + + // Groups the viewer asked to join are not in the feed, so each one's metadata is fetched here. + // A failed fetch is remembered so the screen still settles, with that group left out. + private val fetched = MutableStateFlow>(emptyMap()) + + init { + // Drops requests that were answered while the app was away, as the Chats list does. + viewModelScope.launch { lobbyRequests.refresh() } + + val selfId = { userManager.accountId } + fun reference(metadata: ChatMetadata) = + ChatSummary(metadata, unreadCount = 0).toConversationReference(selfId(), emptyMap(), resources) + + val hostedGroups = chatCoordinator.chatListFeeds() + .map { feeds -> + val all = feeds.main + feeds.archived + val ids = hostedPrivateGroups(all, selfId()).keys + all.map { it.metadata }.filter { it.chatId in ids }.associateBy { it.chatId } + } + .distinctUntilChanged() + + // Fresh counts on open; LobbyUpdate only reaches a running stream. + viewModelScope.launch { + hostedGroups.first().keys.forEach { chatId -> launch { hostLobbies.load(chatId) } } + } + + lobbyRequests.observe() + .onEach { requests -> + requests.keys.filter { it !in fetched.value }.forEach { chatId -> + viewModelScope.launch { + val metadata = chatController.getChat(chatId).getOrNull() + fetched.update { it + (chatId to metadata) } + } + } + } + .launchIn(viewModelScope) + + combine(hostedGroups, hostLobbies.state, lobbyRequests.observe(), fetched) { hosted, lobbies, requests, fetched -> + val hostedRows = hostedRequests(hosted.keys, lobbies).mapNotNull { (chatId, waiting) -> + hosted[chatId]?.let { HostedRequest(reference(it), waiting.size) } + } + val sentRows = sentRequests(requests, fetched.filterValues { it != null }.mapValues { it.value!! }) + .map { (metadata, at) -> SentRequest(reference(metadata), at) } + Event.Updated(hostedRows, sentRows, isLoaded = requests.keys.all { it in fetched }) + } + .flowOn(dispatchers.Default) + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) + } + + internal companion object { + internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Updated -> { state -> + state.copy(hosted = event.hosted, sent = event.sent, isLoaded = event.isLoaded) + } + } + } + } +} + +/** A private group the viewer hosts, with how many people wait to join it. */ +internal data class HostedRequest(val chat: ConversationReference, val count: Int) + +/** A private group the viewer asked to join, with when the request went in. */ +internal data class SentRequest(val chat: ConversationReference, val requestedAt: Instant) + +/** + * The groups the viewer asked to join, newest request first. A group whose metadata isn't held is + * left out until it is fetched. + */ +internal fun sentRequests( + requests: Map, + metadata: Map, +): List> = requests + .mapNotNull { (chatId, at) -> metadata[chatId]?.let { it to at } } + .sortedByDescending { (_, at) -> at } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt index fb01f0c084..f7c25b072a 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/NewGroupFormScreen.kt @@ -88,7 +88,9 @@ internal fun NewGroupFormScreen(viewModel: CreateGroupViewModel) { Column(modifier = Modifier.fillMaxSize()) { AppBarWithTitle( - title = stringResource(R.string.title_newPublicGroup), + title = stringResource( + if (state.isPrivate) R.string.title_newPrivateGroup else R.string.title_newPublicGroup + ), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { keyboard.hideIfVisible { flowNavigator.exitCanceled() } }, endContent = { @@ -113,30 +115,44 @@ internal fun NewGroupFormScreen(viewModel: CreateGroupViewModel) { .imePadding(), verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x9), ) { - IdentityCard( - image = state.image, - titleField = { - DisplayTextInput( - state = state.titleFieldState, - placeholder = stringResource(R.string.hint_groupName), - modifier = Modifier.fillMaxWidth(), - style = CodeTheme.typography.screenTitle.copy( - color = CodeTheme.colors.textMain, - ), - placeholderStyle = CodeTheme.typography.screenTitle.copy( - color = CodeTheme.colors.textMain.copy(alpha = 0.2f), - ), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Words, - imeAction = ImeAction.Done, - ), - onKeyboardAction = { keyboard.hideIfVisible { } }, + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2)) { + IdentityCard( + image = state.image, + titleField = { + DisplayTextInput( + state = state.titleFieldState, + placeholder = stringResource(R.string.hint_groupName), + modifier = Modifier.fillMaxWidth(), + style = CodeTheme.typography.screenTitle.copy( + color = CodeTheme.colors.textMain, + ), + placeholderStyle = CodeTheme.typography.screenTitle.copy( + color = CodeTheme.colors.textMain.copy(alpha = 0.2f), + ), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done, + ), + onKeyboardAction = { keyboard.hideIfVisible { } }, + ) + }, + onPick = { pickPicture.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }, + ) + + // Node 11203:65: what a private group is, said once, where privacy is chosen, because + // it can't be undone. + if (state.isPrivate) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.subtitle_privateGroupFootnote), + style = CodeTheme.typography.caption.copy(fontSize = 13.sp, lineHeight = 17.sp), + color = White50, ) - }, - onPick = { pickPicture.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) }, - ) + } + } - RequirementSection( + // A private group has no rules (StartChat's private_group case carries none). + if (!state.isPrivate) RequirementSection( state = state, onChangeCurrency = { keyboard.hideIfVisible { diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index aba5953887..2dd6e4ea25 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -72,6 +72,8 @@ class ChatsViewModelArchiveTest { resources = FakeResourceHelper(R::class.java), dispatchers = TestDispatchers(testScheduler), archiveStore = ChatArchiveStore.None, + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, ) val states = mutableListOf() diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt index 2bb663b106..9a7903dcab 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreateGroupViewModelErrorTest.kt @@ -96,7 +96,8 @@ class CreateGroupViewModelErrorTest { BottomBarManager.clear() } - private fun createViewModel() = CreateGroupViewModel( + private fun createViewModel(isPrivate: Boolean = false) = CreateGroupViewModel( + isPrivate = isPrivate, exchange = exchange, tokenCoordinator = tokenCoordinator, dispatchers = dispatchers, @@ -106,6 +107,7 @@ class CreateGroupViewModelErrorTest { contentReader = contentReader, resources = resources, analytics = analytics, + groupKeys = mockk(relaxed = true), ) private fun token(seed: Byte, symbol: String) = MintMetadata( @@ -260,6 +262,24 @@ class CreateGroupViewModelErrorTest { assertTrue(titles().contains("error_title_groupCreateDenied")) } + @Test + fun `a denied private create says private groups`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel(isPrivate = true) + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + + failing(StartChatError.Denied()) + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + assertTrue( + BottomBarManager.messages.value.any { + it.subtitle == "error_description_privateGroupCreateDenied" + } + ) + } + @Test fun `an unrecognised failure falls back to the generic refusal`() = runTest(mainCoroutineRule.dispatcher) { diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreatePrivateGroupViewModelTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreatePrivateGroupViewModelTest.kt new file mode 100644 index 0000000000..57e114ec58 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/CreatePrivateGroupViewModelTest.kt @@ -0,0 +1,149 @@ +package com.flipcash.app.tipping.internal + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.blob.BlobStorageCoordinator +import com.flipcash.app.blob.ImageUploadPreparer +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.tipping.R +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.StartChatParameters +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.util.resources.ContentReader +import com.getcode.util.resources.FakeResourceHelper +import com.getcode.view.SuccessHoldDuration +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Creating a private group (node 11203:65): `StartChat(private_group)`, then the creator's key + * envelope, with only the second step retried. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CreatePrivateGroupViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val exchange = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val chatCoordinator = mockk(relaxed = true) + private val blobStorage = mockk(relaxed = true) + private val groupKeys = mockk() + + @Before + fun setUp() { + BottomBarManager.clear() + every { exchange.observePreferredRate() } returns emptyFlow() + every { tokenCoordinator.tokenBalances } returns flowOf(emptyList()) + every { blobStorage.policy } returns flowOf(null) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun TestDispatchers.viewModel() = CreateGroupViewModel( + isPrivate = true, + exchange = exchange, + tokenCoordinator = tokenCoordinator, + dispatchers = this, + chatCoordinator = chatCoordinator, + blobStorage = blobStorage, + imagePreparer = mockk(relaxed = true), + contentReader = mockk(relaxed = true), + resources = FakeResourceHelper(R::class.java), + analytics = RecordingAnalytics(), + groupKeys = groupKeys, + ) + + private val chat = ChatMetadata( + chatId = ChatId(ByteArray(16) { 3 }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = "Ballers", + latestEventSequence = 0, + ) + + @Test + fun `a name alone enables Create`() = runTest(mainCoroutineRule.dispatcher) { + val vm = TestDispatchers(testScheduler).viewModel() + assertFalse(vm.stateFlow.value.canCreate) + + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + + assertTrue(vm.stateFlow.value.canCreate) + assertNull(vm.stateFlow.value.rules) + } + + @Test + fun `create starts a private group then stores the key`() = runTest(mainCoroutineRule.dispatcher) { + val vm = TestDispatchers(testScheduler).viewModel() + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + val parameters = mutableListOf() + coEvery { chatCoordinator.create(capture(parameters), any()) } returns Result.success(chat) + coEvery { groupKeys.storeNewKey(chat.chatId) } returns Result.success(Unit) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceTimeBy(SuccessHoldDuration) + advanceUntilIdle() + + val sent = assertIs(parameters.single()) + assertEquals("Ballers", sent.title) + coVerify(exactly = 1) { groupKeys.storeNewKey(chat.chatId) } + assertEquals(chat, vm.stateFlow.value.created) + } + + @Test + fun `a failed key is retried without starting a second chat`() = runTest(mainCoroutineRule.dispatcher) { + val vm = TestDispatchers(testScheduler).viewModel() + vm.stateFlow.value.titleFieldState.setTextAndPlaceCursorAtEnd("Ballers") + coEvery { chatCoordinator.create(any(), any()) } returns Result.success(chat) + coEvery { groupKeys.storeNewKey(chat.chatId) } returns Result.failure(IllegalStateException()) + + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceUntilIdle() + + // The automatic retries ran out: the form is usable again and nothing was created. + coVerify(exactly = 3) { groupKeys.storeNewKey(chat.chatId) } + assertTrue(vm.stateFlow.value.processingState.isIdle) + assertNull(vm.stateFlow.value.created) + + coEvery { groupKeys.storeNewKey(chat.chatId) } returns Result.success(Unit) + vm.dispatchEvent(CreateGroupViewModel.Event.CreateRequested) + advanceTimeBy(SuccessHoldDuration) + advanceUntilIdle() + + coVerify(exactly = 1) { chatCoordinator.create(any(), any()) } + assertEquals(chat, vm.stateFlow.value.created) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt new file mode 100644 index 0000000000..e03414b1a8 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/RequestsProjectionTest.kt @@ -0,0 +1,106 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.shared.chat.ChatSummary +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Instant + +class RequestsProjectionTest { + + private val self = listOf(9) + private val other = listOf(8) + + private fun group(id: Byte, title: String, creator: List?, isPrivate: Boolean = true) = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(ByteArray(16) { id }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = title, + creator = creator, + isPrivate = isPrivate, + ), + unreadCount = 0, + ) + + private fun member(id: Byte, enteredAt: Long) = LobbyMember( + userProfile = UserProfile( + displayName = "user $id", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + userId = listOf(id), + ), + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = Instant.fromEpochSeconds(enteredAt), + ) + + private val moony = group(1, "Moony", self) + private val yotes = group(2, "Yotes", self) + + @Test + fun `only private groups the viewer created count as hosted`() { + val hosted = hostedPrivateGroups( + listOf(moony, group(3, "Theirs", other), group(4, "Public", self, isPrivate = false), group(5, "Rebuilt", null)), + self, + ) + assertEquals(mapOf(moony.metadata.chatId to "Moony"), hosted) + } + + @Test + fun `no viewer, nothing hosted`() { + assertTrue(hostedPrivateGroups(listOf(moony), null).isEmpty()) + } + + @Test + fun `nobody waiting, no hosted requests`() { + val hosted = hostedPrivateGroups(listOf(moony, yotes), self).keys + assertTrue(hostedRequests(hosted, emptyMap()).isEmpty()) + assertTrue(hostedRequests(hosted, mapOf(moony.metadata.chatId to emptyList())).isEmpty()) + } + + @Test + fun `hosted groups with someone waiting, newest request first`() { + val hosted = hostedPrivateGroups(listOf(moony, yotes), self).keys + val requests = hostedRequests( + hosted, + mapOf( + moony.metadata.chatId to listOf(member(1, 100), member(2, 150), member(3, 120)), + yotes.metadata.chatId to listOf(member(4, 300)), + ), + ) + assertEquals(listOf(yotes.metadata.chatId, moony.metadata.chatId), requests.map { it.first }) + assertEquals(listOf(1, 3), requests.map { it.second.size }) + } + + @Test + fun `a lobby for a group the viewer does not host is ignored`() { + val theirs = group(3, "Theirs", other) + val hosted = hostedPrivateGroups(listOf(moony, theirs), self).keys + assertTrue(hostedRequests(hosted, mapOf(theirs.metadata.chatId to listOf(member(1, 100)))).isEmpty()) + } + + @Test + fun `sent requests are newest first and skip a group not fetched yet`() { + val requested = mapOf( + moony.metadata.chatId to Instant.fromEpochSeconds(100), + yotes.metadata.chatId to Instant.fromEpochSeconds(300), + ChatId(ByteArray(16) { 7 }.toList()) to Instant.fromEpochSeconds(200), + ) + val sent = sentRequests( + requested, + mapOf(moony.metadata.chatId to moony.metadata, yotes.metadata.chatId to yotes.metadata), + ) + assertEquals(listOf(yotes.metadata, moony.metadata), sent.map { it.first }) + assertEquals(Instant.fromEpochSeconds(300), sent.first().second) + } +} diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 3764ae2786..fd45bb87cc 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(libs.bundles.kotlinx.serialization) implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.datastore) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:persistence:sources")) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRosters.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRosters.kt new file mode 100644 index 0000000000..a1de3e4c59 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRosters.kt @@ -0,0 +1,89 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +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.RosterChange +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** Where the event stream hands a group's roster changes, beside the list-level handling. */ +fun interface RosterUpdateSink { + fun apply(chatId: ChatId, changes: List) + + /** Drops everything held. For logout: the rosters belong to the account. */ + fun clear() = Unit + + object None : RosterUpdateSink { + override fun apply(chatId: ChatId, changes: List) = Unit + } +} + +/** + * Full rosters of the groups whose members are shown in full (a private group's Members grid and + * list), most recently joined first. + * + * [load] reads a roster to the end; the event stream's RosterUpdates are applied on top through + * [apply]. A join is kept per member by [ChatMember.version], the greater wins, as [RosterPage] + * documents for merging pages. + */ +@Singleton +class GroupRosters @Inject constructor( + private val chatController: ChatController, +) : RosterUpdateSink { + + private val rosters = MutableStateFlow>>(emptyMap()) + + /** Each loaded roster, most recently joined first. A chat never loaded is absent. */ + val state: StateFlow>> = rosters.asStateFlow() + + /** Fetches [chatId]'s whole roster, every page, and replaces what was held for it. */ + suspend fun load(chatId: ChatId): Result> = runCatching { + val members = mutableListOf() + var options = QueryOptions() + do { + val page = chatController.getRoster(chatId, options).getOrThrow() + members += page.members + options = options.copy(token = page.pagingToken) + } while (page.hasMore && page.pagingToken != null) + newestFirst(members.fold(emptyList(), ::merge)) + }.onSuccess { members -> + rosters.update { it + (chatId to members) } + } + + /** Applies changes only to a roster already loaded: a partial one would read as complete. */ + override fun apply(chatId: ChatId, changes: List) { + rosters.update { all -> + val current = all[chatId] ?: return@update all + val next = changes.fold(current) { members, change -> + when (change) { + is RosterChange.MemberJoined -> merge(members, change.member) + is RosterChange.MemberLeft -> members.filterNot { it.userId == change.userId } + is RosterChange.MembershipChanged -> members + } + } + all + (chatId to newestFirst(next)) + } + } + + override fun clear() { + rosters.value = emptyMap() + } + + private fun merge(members: List, member: ChatMember): List { + val held = members.firstOrNull { it.userId == member.userId } + return when { + held == null -> members + member + member.version > held.version -> members.map { if (it.userId == member.userId) member else it } + else -> members + } + } + + private fun newestFirst(members: List) = + members.sortedByDescending { it.joinedAt } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt new file mode 100644 index 0000000000..73713c3d78 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/HostLobbies.kt @@ -0,0 +1,117 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.DenyLobbyMemberError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.services.models.chat.LobbyUpdate +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** Where the event stream hands a private group's lobby changes. */ +fun interface LobbyUpdateSink { + fun apply(chatId: ChatId, updates: List) + + /** Drops everything held. For logout: the lobbies belong to the account. */ + fun clear() = Unit + + object None : LobbyUpdateSink { + override fun apply(chatId: ChatId, updates: List) = Unit + } +} + +/** + * The lobbies of the private groups this account created: who is waiting to join each, newest + * first. One copy for every host surface (profile card, chat-list row, Join Requests, in-chat + * anchor) so their counts agree. + * + * [load] fetches a lobby whole; the event stream's LobbyUpdates are applied on top through + * [apply]. Those updates are unversioned and best-effort, so a surface that needs the lobby to be + * right calls [load] again. + */ +@Singleton +class HostLobbies @Inject constructor( + private val chatController: ChatController, + private val keyEnvelopes: GroupKeyEnvelopes, +) : LobbyUpdateSink { + + private val lobbies = MutableStateFlow>>(emptyMap()) + + /** Each loaded lobby's waiting members, newest first. A chat never loaded is absent. */ + val state: StateFlow>> = lobbies.asStateFlow() + + /** Fetches [chatId]'s whole lobby, every page, and replaces what was held for it. */ + suspend fun load(chatId: ChatId): Result> = runCatching { + val members = mutableListOf() + var options = QueryOptions() + do { + val page = chatController.getLobbyMembers(chatId, options).getOrThrow() + members += page.members + options = options.copy(token = page.pagingToken) + } while (page.hasMore && page.pagingToken != null) + newestFirst(members.distinctBy { it.userProfile.userId }) + }.onSuccess { members -> + lobbies.update { it + (chatId to members) } + } + + override fun apply(chatId: ChatId, updates: List) { + lobbies.update { all -> + val current = all[chatId].orEmpty() + val next = updates.fold(current) { members, update -> + when (update) { + is LobbyUpdate.MemberEntered -> + members.filterNot { it.userProfile.userId == update.member.userProfile.userId } + + update.member + is LobbyUpdate.MemberLeft -> + members.filterNot { it.userProfile.userId == update.userId } + } + } + all + (chatId to newestFirst(next)) + } + } + + /** + * Wraps the chat key for [member] and admits them. A member who already left the lobby + * (NOT_IN_LOBBY) is dropped from the list and counts as done: there is nothing left to act on. + */ + suspend fun approve(chatId: ChatId, member: LobbyMember): Result { + val userId = member.userProfile.userId + ?: return Result.failure(IllegalArgumentException("Lobby member has no user id")) + val result = keyEnvelopes.wrapFor(chatId, member.publicKey.byteArray) + .mapCatching { envelope -> + chatController.admitLobbyMember(chatId, userId, envelope).getOrThrow() + } + .recoverCatching { if (it !is AdmitLobbyMemberError.NotInLobby) throw it } + if (result.isSuccess) remove(chatId, userId) + return result + } + + /** Turns [userId] away. One already gone from the lobby (NOT_FOUND) counts as done. */ + suspend fun deny(chatId: ChatId, userId: ID): Result { + val result = chatController.denyLobbyMember(chatId, userId) + .recoverCatching { if (it !is DenyLobbyMemberError.NotFound) throw it } + if (result.isSuccess) remove(chatId, userId) + return result + } + + override fun clear() { + lobbies.value = emptyMap() + } + + private fun remove(chatId: ChatId, userId: ID) { + lobbies.update { all -> + val current = all[chatId] ?: return@update all + all + (chatId to current.filterNot { it.userProfile.userId == userId }) + } + } + + private fun newestFirst(members: List) = members.sortedByDescending { it.enteredAt } +} 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 new file mode 100644 index 0000000000..639620c347 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/LobbyRequests.kt @@ -0,0 +1,84 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.EnterLobbyError +import com.flipcash.services.models.LeaveLobbyError +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +/** + * The private groups this account has asked to join and not yet seen an answer for, keyed by chat + * with the time the request went in. + * + * The contract has no "my requests" list and no push for an outcome: an admission or a denial only + * shows as `GetChat.in_lobby` going false. So the requests are kept on the device, and [refresh] + * asks GetChat about each one. A request that has left the lobby is dropped; if any did, the feed + * is synced, since an admitted chat arrives there and its row is what flips the viewer to member. + */ +@Singleton +class LobbyRequests @Inject constructor( + private val chatController: ChatController, + private val store: LobbyRequestStore, + private val chatCoordinator: ChatCoordinator, +) { + + /** Requests sent and not yet answered, re-emitted on every change. */ + fun observe(): Flow> = store.observe() + + /** + * EnterLobby. On success the request is kept with the server's `entered_at`. ALREADY_MEMBER + * drops any stale request and syncs the feed so the member's row arrives; it still fails, so + * the caller can tell it apart. Other failures change nothing. + */ + suspend fun request(chatId: ChatId): Result = + chatController.enterLobby(chatId) + .onSuccess { store.put(chatId, it.enteredAt) } + .onFailure { error -> + if (error is EnterLobbyError.AlreadyMember) { + store.remove(chatId) + chatCoordinator.refreshFeed() + } + } + .map { it.enteredAt } + + /** + * LeaveLobby. NOT_FOUND means the request is already gone (denied, or admitted and since + * left), so it also counts as cancelled. + */ + suspend fun cancel(chatId: ChatId): Result = + chatController.leaveLobby(chatId) + .recoverCatching { if (it !is LeaveLobbyError.NotFound) throw it } + .onSuccess { 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. + */ + suspend fun refresh(chatIds: Collection? = null) { + val stored = store.observe().first() + val asking = chatIds?.filter { it in stored } ?: stored.keys + var resolved = false + asking.forEach { chatId -> + val metadata = chatController.getChat(chatId).getOrNull() ?: return@forEach + if (!metadata.inLobby) { + store.remove(chatId) + resolved = true + } + } + if (resolved) chatCoordinator.refreshFeed() + } +} + +/** Where [LobbyRequests] keeps its requests, per signed-in account. */ +interface LobbyRequestStore { + /** The current account's requests; empty before sign-in. */ + fun observe(): Flow> + + suspend fun put(chatId: ChatId, enteredAt: Instant) + + suspend fun remove(chatId: ChatId) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RequestAge.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RequestAge.kt new file mode 100644 index 0000000000..6881898ef6 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RequestAge.kt @@ -0,0 +1,27 @@ +package com.flipcash.shared.chat + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** How long ago a join request went in, as "Requested 2h ago" states it. */ +sealed interface RequestAge { + data object JustNow : RequestAge + data class Minutes(val count: Long) : RequestAge + data class Hours(val count: Long) : RequestAge + data class Days(val count: Long) : RequestAge + + companion object { + fun of(at: Instant, now: Instant): RequestAge { + val elapsed = (now - at).coerceAtLeast(Duration.ZERO) + return when { + elapsed < 1.minutes -> JustNow + elapsed < 1.hours -> Minutes(elapsed.inWholeMinutes) + elapsed < 1.days -> Hours(elapsed.inWholeHours) + else -> Days(elapsed.inWholeDays) + } + } + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index d3d1c3c6b7..2bbbb0ec8a 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -3,7 +3,13 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.HostLobbies +import com.flipcash.shared.chat.LobbyRequestStore +import com.flipcash.shared.chat.LobbyUpdateSink +import com.flipcash.shared.chat.GroupRosters +import com.flipcash.shared.chat.RosterUpdateSink import com.flipcash.shared.chat.RosterSearchSource +import com.flipcash.shared.chat.internal.DataStoreLobbyRequestStore import com.flipcash.shared.chat.internal.DmOutgoingEncryption import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.flipcash.shared.chat.internal.MentionSuggestionPool @@ -44,6 +50,21 @@ abstract class ChatModule { impl: RealChatArchiveStore ): ChatArchiveStore + @Binds + internal abstract fun bindLobbyRequestStore( + impl: DataStoreLobbyRequestStore + ): LobbyRequestStore + + @Binds + abstract fun bindLobbyUpdateSink( + impl: HostLobbies + ): LobbyUpdateSink + + @Binds + abstract fun bindRosterUpdateSink( + impl: GroupRosters + ): RosterUpdateSink + @Binds @Singleton abstract fun bindChatMediaSending( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreLobbyRequestStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreLobbyRequestStore.kt new file mode 100644 index 0000000000..6978563b09 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/DataStoreLobbyRequestStore.kt @@ -0,0 +1,72 @@ +package com.flipcash.shared.chat.internal + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.LobbyRequestStore +import com.getcode.utils.hexEncodedString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Instant + +private val Context.lobbyRequestsDataStore: DataStore by preferencesDataStore(name = "lobby_requests") + +/** + * One string set per account, each entry `:`. Per account + * because one device serves several, and a request belongs to the account that sent it. + */ +@Singleton +internal class DataStoreLobbyRequestStore @Inject constructor( + @ApplicationContext context: Context, + private val userManager: UserManager, +) : LobbyRequestStore { + + private val dataStore = context.lobbyRequestsDataStore + + override fun observe(): Flow> = + combine( + userManager.state.map { it.accountId?.hexEncodedString() }.distinctUntilChanged(), + dataStore.data, + ) { owner, prefs -> + owner ?: return@combine emptyMap() + prefs[key(owner)].orEmpty().mapNotNull(::decode).toMap() + }.distinctUntilChanged() + + override suspend fun put(chatId: ChatId, enteredAt: Instant) = edit { entries -> + entries.filterKeys { it != chatId } + (chatId to enteredAt) + } + + override suspend fun remove(chatId: ChatId) = edit { entries -> entries - chatId } + + private suspend fun edit(change: (Map) -> Map) { + val owner = userManager.accountId?.hexEncodedString() ?: return + dataStore.edit { prefs -> + val key = key(owner) + val current = prefs[key].orEmpty().mapNotNull(::decode).toMap() + prefs[key] = change(current).map { (chatId, at) -> encode(chatId, at) }.toSet() + } + } + + private fun key(owner: String) = stringSetPreferencesKey("requests-$owner") + + @OptIn(ExperimentalStdlibApi::class) + private fun encode(chatId: ChatId, enteredAt: Instant) = "${chatId.bytes.toHexString()}:${enteredAt.epochSeconds}" + + @OptIn(ExperimentalStdlibApi::class) + private fun decode(raw: String): Pair? { + val separator = raw.indexOf(':').takeIf { it > 0 } ?: return null + val bytes = runCatching { raw.substring(0, separator).hexToByteArray() }.getOrNull() ?: return null + val seconds = raw.substring(separator + 1).toLongOrNull() ?: return null + return ChatId(bytes) to Instant.fromEpochSeconds(seconds) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt index c6494445f7..30724c484e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt @@ -7,6 +7,7 @@ import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.carriesMedia import com.flipcash.services.chat.checkBlobSealing import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.BlobId @@ -101,6 +102,7 @@ internal class DmOutgoingEncryption @Inject constructor( private val chatController: ChatController, private val metadataDataSource: ChatMetadataDataSource, private val memberDataSource: ChatMemberDataSource, + private val groupCrypto: GroupContentCrypto, ) : OutgoingEncryption { override suspend fun prepare( @@ -121,15 +123,19 @@ internal class DmOutgoingEncryption @Inject constructor( val selfId = userManager.accountId ?: return Result.failure(IllegalStateException("No account to encrypt from")) - val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId - ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) - // A DM message is one Text, one photo, or one Reply around either. Anything else can't be - // sealed, and sending it in plaintext would break the chat's promise. + // A sealed message is one Text, one photo, or one Reply around either. Anything else can't + // be sealed, and sending it in plaintext would break the chat's promise. val single = content.singleOrNull() ?: return Result.failure(IllegalArgumentException("Can't encrypt ${content.size} content items")) - return crypto.seal(chatId, peerId, single) - .map { sealed -> Outgoing(plaintext = content, wire = listOf(sealed), isSealed = true) } + val sealed = if (chat.isPrivate) { + groupCrypto.seal(chatId, selfId, single) + } else { + val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId + ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) + crypto.seal(chatId, peerId, single) + } + return sealed.map { Outgoing(plaintext = content, wire = listOf(it), isSealed = true) } } override suspend fun blobSealer(chatId: ChatId): Result { @@ -139,6 +145,13 @@ internal class DmOutgoingEncryption @Inject constructor( val selfId = userManager.accountId ?: return Result.failure(IllegalStateException("No account to encrypt from")) + if (chat.isPrivate) { + return Result.success( + BlobSealer(chatId) { blobId, plaintext -> + groupCrypto.sealBlob(chatId, selfId, blobId.bytes, plaintext).getOrThrow() + } + ) + } val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) return Result.success( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index b36bf841c5..50d0edce1c 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -11,6 +11,8 @@ import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.LobbyUpdateSink +import com.flipcash.shared.chat.RosterUpdateSink import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatState @@ -101,6 +103,8 @@ class RealChatCoordinator @Inject constructor( private val dispatchers: DispatcherProvider, private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, private val mediaSender: ChatMediaSending = ChatMediaSending.None, + private val lobbyUpdates: LobbyUpdateSink = LobbyUpdateSink.None, + private val rosterUpdates: RosterUpdateSink = RosterUpdateSink.None, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -219,8 +223,12 @@ class RealChatCoordinator @Inject constructor( syncFeeds(fresh = true) is EventStreamDelegate.Event.LoadMessages -> messagingDelegate.loadMessages(event.chatId) - is EventStreamDelegate.Event.RosterChanged -> + is EventStreamDelegate.Event.RosterChanged -> { groupFeedDelegate.applyRosterChanges(event.chatId, event.changes) + rosterUpdates.apply(event.chatId, event.changes) + } + is EventStreamDelegate.Event.LobbyChanged -> + lobbyUpdates.apply(event.chatId, event.updates) } }.launchIn(scope) @@ -418,6 +426,8 @@ class RealChatCoordinator @Inject constructor( stateHolder.reset() eventStreamDelegate.clearAll() reactionsDelegate.clearAll() + lobbyUpdates.clear() + rosterUpdates.clear() cluster.value = null supervisorJob.cancel() trace(tag = TAG, message = "teardown complete", type = TraceType.Process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index 5888150319..5859f26b7d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -19,6 +19,7 @@ import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.ReactionSummary import com.flipcash.services.models.chat.ReactionUpdate import com.flipcash.services.models.chat.Reactor +import com.flipcash.services.models.chat.LobbyUpdate import com.flipcash.services.models.chat.RosterChange import com.flipcash.shared.chat.reactions.ReactionState import com.flipcash.services.models.GetDeltaError @@ -115,6 +116,9 @@ class EventStreamDelegate @Inject constructor( * delegate's, and this delegate has no business knowing what a membership means. */ data class RosterChanged(val chatId: ChatId, val changes: List) : Event + + /** [chatId]'s lobby changed. Sent only to the group's creator; unversioned. */ + data class LobbyChanged(val chatId: ChatId, val updates: List) : Event } private val _events = Channel(Channel.UNLIMITED) @@ -458,6 +462,12 @@ class EventStreamDelegate @Inject constructor( _events.send(Event.RosterChanged(chatId, update.rosterUpdates)) } + // --- Lobby changes --- + + if (update.lobbyUpdates.isNotEmpty()) { + _events.send(Event.LobbyChanged(chatId, update.lobbyUpdates)) + } + // --- Process reaction updates --- if (update.reactionUpdates.isNotEmpty()) { diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt index ed3b9deae4..aec7b4359d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoader.kt @@ -1,8 +1,10 @@ package com.flipcash.shared.chat.media import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.OpenedBlob import com.flipcash.services.controllers.BlobStorageController import com.flipcash.services.models.chat.BlobAccessContext @@ -64,6 +66,8 @@ class ChatPhotoLoader internal constructor( private val crypto: ChatContentCrypto, private val userManager: UserManager, private val members: ChatMemberDataSource, + private val groupCrypto: GroupContentCrypto, + private val metadata: ChatMetadataDataSource, private val now: () -> kotlin.time.Instant, ) { @Inject @@ -72,7 +76,9 @@ class ChatPhotoLoader internal constructor( crypto: ChatContentCrypto, userManager: UserManager, members: ChatMemberDataSource, - ) : this(blobStorage, crypto, userManager, members, { Clock.System.now() }) + groupCrypto: GroupContentCrypto, + metadata: ChatMetadataDataSource, + ) : this(blobStorage, crypto, userManager, members, groupCrypto, metadata, { Clock.System.now() }) /** * A URL to download [photo] from: the stored one while it is usable, else a freshly minted one. @@ -97,23 +103,34 @@ class ChatPhotoLoader internal constructor( */ suspend fun open(photo: ChatPhoto, downloaded: ByteArray): ByteArray { if (!photo.sealed) return downloaded - val selfId = userManager.accountId ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) - val peerId = members.getMembersForChat(photo.chatId).firstOrNull { it.userId != selfId }?.userId - ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) val expected = photo.rendition.blob?.sizeBytes ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.Length) - return when ( - val opened = crypto.openBlob( + // A chat not stored yet can't say which key opens it; guessing DM would fail a group photo for good. + val isPrivate = metadata.privacyOf(photo.chatId) ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) + if (isPrivate) { + return groupCrypto.openBlob( chatId = photo.chatId, - selfId = selfId, - peerId = peerId, senderId = photo.senderId, blobId = photo.rendition.blobId.bytes, expectedSize = expected, sealed = downloaded, - ) - ) { - is OpenedBlob.Plaintext -> opened.bytes - is OpenedBlob.Failed -> throw ChatPhotoUnavailable.Open(opened.reason) + ).bytesOrThrow() } + val selfId = userManager.accountId ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) + val peerId = members.getMembersForChat(photo.chatId).firstOrNull { it.userId != selfId }?.userId + ?: throw ChatPhotoUnavailable.Open(BlobOpenFailure.KeyPending) + return crypto.openBlob( + chatId = photo.chatId, + selfId = selfId, + peerId = peerId, + senderId = photo.senderId, + blobId = photo.rendition.blobId.bytes, + expectedSize = expected, + sealed = downloaded, + ).bytesOrThrow() + } + + private fun OpenedBlob.bytesOrThrow(): ByteArray = when (this) { + is OpenedBlob.Plaintext -> bytes + is OpenedBlob.Failed -> throw ChatPhotoUnavailable.Open(reason) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRostersTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRostersTest.kt new file mode 100644 index 0000000000..8f91c60beb --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRostersTest.kt @@ -0,0 +1,112 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.PagingToken +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.RosterChange +import com.flipcash.services.models.chat.RosterPage +import com.flipcash.services.models.chat.RosterSummary +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +class GroupRostersTest { + + private val chatController = mockk() + private val rosters = GroupRosters(chatController) + + private val chat = ChatId("aa") + private val summary = RosterSummary(memberCount = 2, version = 5) + + private fun member(id: Byte, joinedAt: Long, version: Long = joinedAt, name: String = "user $id") = + ChatMember( + userId = listOf(id), + userProfile = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + userId = listOf(id), + ), + pointers = emptyList(), + joinedAt = Instant.fromEpochSeconds(joinedAt), + version = version, + ) + + private val host = member(1, 100) + private val joiner = member(2, 200) + + private fun ids() = rosters.state.value[chat]?.map { it.userId } + + private suspend fun loaded(vararg members: ChatMember) { + coEvery { chatController.getRoster(chat, any()) } returns + Result.success(RosterPage(members.toList(), summary, null, hasMore = false)) + rosters.load(chat).getOrThrow() + } + + @Test + fun `load reads every page and orders most recently joined first`() = runTest { + val token: PagingToken = listOf(7) + coEvery { chatController.getRoster(chat, match { it.token == null }) } returns + Result.success(RosterPage(listOf(host), summary, token, hasMore = true)) + coEvery { chatController.getRoster(chat, match { it.token == token }) } returns + Result.success(RosterPage(listOf(joiner), summary, null, hasMore = false)) + + rosters.load(chat).getOrThrow() + + assertEquals(listOf(listOf(2), listOf(1)), ids()) + } + + @Test + fun `a member on two pages keeps the greater version`() = runTest { + val renamed = member(2, 200, version = 300, name = "renamed") + coEvery { chatController.getRoster(chat, any()) } returns + Result.success(RosterPage(listOf(renamed, joiner), summary, null, hasMore = false)) + + rosters.load(chat).getOrThrow() + + assertEquals("renamed", rosters.state.value[chat]?.single()?.userProfile?.displayName) + } + + @Test + fun `changes before a load are ignored`() { + rosters.apply(chat, listOf(RosterChange.MemberJoined(joiner, null, summary))) + + assertNull(ids()) + } + + @Test + fun `joins and leaves apply to a loaded roster`() = runTest { + loaded(host) + + rosters.apply(chat, listOf(RosterChange.MemberJoined(joiner, null, summary))) + assertEquals(listOf(listOf(2), listOf(1)), ids()) + + rosters.apply(chat, listOf(RosterChange.MemberLeft(listOf(2), summary))) + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `an older join does not replace a newer one`() = runTest { + loaded(member(2, 200, version = 9, name = "current")) + + rosters.apply(chat, listOf(RosterChange.MemberJoined(member(2, 200, version = 3, name = "stale"), null, summary))) + + assertEquals("current", rosters.state.value[chat]?.single()?.userProfile?.displayName) + } + + @Test + fun `clear drops every roster`() = runTest { + loaded(host) + + rosters.clear() + + assertNull(ids()) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt new file mode 100644 index 0000000000..d2c48bfd32 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/HostLobbiesTest.kt @@ -0,0 +1,139 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.DenyLobbyMemberError +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.LobbyMember +import com.flipcash.services.models.chat.LobbyPage +import com.flipcash.services.models.chat.LobbyUpdate +import com.getcode.solana.keys.PublicKey +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +class HostLobbiesTest { + + private val chatController = mockk() + private val keyEnvelopes = mockk() + private val lobbies = HostLobbies(chatController, keyEnvelopes) + + private val chat = ChatId("aa") + private val envelope = KeyEnvelope(scheme = 1, nonce = ByteArray(12), ciphertext = ByteArray(48)) + + private fun member(id: Byte, enteredAt: Long) = LobbyMember( + userProfile = UserProfile( + displayName = "user $id", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + userId = listOf(id), + ), + publicKey = PublicKey("11111111111111111111111111111111"), + enteredAt = Instant.fromEpochSeconds(enteredAt), + ) + + private val older = member(1, 100) + private val newer = member(2, 200) + + private fun ids() = lobbies.state.value[chat].orEmpty().map { it.userProfile.userId } + + @Test + fun `load reads every page and orders newest first`() = runTest { + val token: PagingToken = listOf(7) + coEvery { chatController.getLobbyMembers(chat, match { it.token == null }) } returns + Result.success(LobbyPage(listOf(older), token, hasMore = true)) + coEvery { chatController.getLobbyMembers(chat, match { it.token == token }) } returns + Result.success(LobbyPage(listOf(newer), null, hasMore = false)) + + lobbies.load(chat).getOrThrow() + + assertEquals(listOf(listOf(2), listOf(1)), ids()) + } + + @Test + fun `a failed load keeps what was held`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { chatController.getLobbyMembers(chat, any()) } returns Result.failure(IllegalStateException()) + + assertTrue(lobbies.load(chat).isFailure) + + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `stream updates add once and remove by user`() { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older), LobbyUpdate.MemberEntered(newer))) + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(newer))) + assertEquals(listOf(listOf(2), listOf(1)), ids()) + + lobbies.apply(chat, listOf(LobbyUpdate.MemberLeft(listOf(2)))) + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `approve wraps the key for the member and admits them`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { keyEnvelopes.wrapFor(chat, any()) } returns Result.success(envelope) + coEvery { chatController.admitLobbyMember(chat, listOf(1), envelope) } returns Result.success(Unit) + + lobbies.approve(chat, older).getOrThrow() + + coVerify { chatController.admitLobbyMember(chat, listOf(1), envelope) } + assertEquals(emptyList(), ids()) + } + + @Test + fun `approving someone who already left drops them without an error`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { keyEnvelopes.wrapFor(chat, any()) } returns Result.success(envelope) + coEvery { chatController.admitLobbyMember(chat, any(), any()) } returns + Result.failure(AdmitLobbyMemberError.NotInLobby()) + + assertTrue(lobbies.approve(chat, older).isSuccess) + assertEquals(emptyList(), ids()) + } + + @Test + fun `a key that cannot be wrapped admits no one and keeps the row`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { keyEnvelopes.wrapFor(chat, any()) } returns Result.failure(IllegalStateException()) + + assertTrue(lobbies.approve(chat, older).isFailure) + + coVerify(exactly = 0) { chatController.admitLobbyMember(any(), any(), any()) } + assertEquals(listOf(listOf(1)), ids()) + } + + @Test + fun `deny removes the row, and one already gone counts as done`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older), LobbyUpdate.MemberEntered(newer))) + coEvery { chatController.denyLobbyMember(chat, listOf(1)) } returns Result.success(Unit) + coEvery { chatController.denyLobbyMember(chat, listOf(2)) } returns + Result.failure(DenyLobbyMemberError.NotFound()) + + assertTrue(lobbies.deny(chat, listOf(1)).isSuccess) + assertTrue(lobbies.deny(chat, listOf(2)).isSuccess) + + assertEquals(emptyList(), ids()) + } + + @Test + fun `a failed deny keeps the row`() = runTest { + lobbies.apply(chat, listOf(LobbyUpdate.MemberEntered(older))) + coEvery { chatController.denyLobbyMember(chat, any()) } returns + Result.failure(DenyLobbyMemberError.Denied()) + + assertTrue(lobbies.deny(chat, listOf(1)).isFailure) + assertEquals(listOf(listOf(1)), ids()) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LobbyRequestsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LobbyRequestsTest.kt new file mode 100644 index 0000000000..03db2cd3be --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/LobbyRequestsTest.kt @@ -0,0 +1,138 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.EnterLobbyError +import com.flipcash.services.models.LeaveLobbyError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.Lobby +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Instant + +class LobbyRequestsTest { + + private class MemoryStore : LobbyRequestStore { + val entries = MutableStateFlow>(emptyMap()) + override fun observe(): Flow> = entries + override suspend fun put(chatId: ChatId, enteredAt: Instant) { entries.value += chatId to enteredAt } + override suspend fun remove(chatId: ChatId) { entries.value -= chatId } + } + + private val chatController = mockk() + private val chatCoordinator = mockk(relaxed = true) + private val store = MemoryStore() + private val requests = LobbyRequests(chatController, store, chatCoordinator) + + private val aa = ChatId("aa") + private val bb = ChatId("bb") + private val enteredAt = Instant.fromEpochSeconds(1_000) + + private fun group(chatId: ChatId, inLobby: Boolean) = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochMilliseconds(0), + title = "group", + isPrivate = true, + inLobby = inLobby, + ) + + @Test + fun `a request that goes in is kept with the server's entered_at`() = runTest { + coEvery { chatController.enterLobby(aa) } returns Result.success(Lobby(group(aa, true), enteredAt)) + + assertEquals(enteredAt, requests.request(aa).getOrThrow()) + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + } + + @Test + fun `a full lobby keeps nothing`() = runTest { + coEvery { chatController.enterLobby(aa) } returns Result.failure(EnterLobbyError.LobbyFull()) + + assertIs(requests.request(aa).exceptionOrNull()) + + assertEquals(emptyMap(), requests.observe().first()) + verify(exactly = 0) { chatCoordinator.refreshFeed() } + } + + @Test + fun `already a member drops the request and syncs the feed`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.enterLobby(aa) } returns Result.failure(EnterLobbyError.AlreadyMember()) + + assertIs(requests.request(aa).exceptionOrNull()) + + assertEquals(emptyMap(), requests.observe().first()) + verify { chatCoordinator.refreshFeed() } + } + + @Test + fun `cancel drops the request, and not found counts as cancelled`() = runTest { + store.put(aa, enteredAt) + store.put(bb, enteredAt) + coEvery { chatController.leaveLobby(aa) } returns Result.success(Unit) + coEvery { chatController.leaveLobby(bb) } returns Result.failure(LeaveLobbyError.NotFound()) + + assertTrue(requests.cancel(aa).isSuccess) + assertTrue(requests.cancel(bb).isSuccess) + + assertEquals(emptyMap(), requests.observe().first()) + } + + @Test + fun `a failed cancel keeps the request`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.leaveLobby(aa) } returns Result.failure(LeaveLobbyError.Other()) + + assertTrue(requests.cancel(aa).isFailure) + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + } + + @Test + fun `refresh drops requests no longer in the lobby and syncs the feed`() = runTest { + store.put(aa, enteredAt) + store.put(bb, enteredAt) + coEvery { chatController.getChat(aa, any()) } returns Result.success(group(aa, inLobby = false)) + coEvery { chatController.getChat(bb, any()) } returns Result.success(group(bb, inLobby = true)) + + requests.refresh() + + assertEquals(mapOf(bb to enteredAt), requests.observe().first()) + verify(exactly = 1) { chatCoordinator.refreshFeed() } + } + + @Test + fun `refresh keeps a request GetChat could not answer for`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.getChat(aa, any()) } returns Result.failure(Throwable("offline")) + + requests.refresh() + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + verify(exactly = 0) { chatCoordinator.refreshFeed() } + } + + @Test + fun `refresh of one chat asks only about that chat, and only if it was requested`() = runTest { + store.put(aa, enteredAt) + coEvery { chatController.getChat(aa, any()) } returns Result.success(group(aa, inLobby = true)) + + requests.refresh(listOf(aa, bb)) + + assertEquals(mapOf(aa to enteredAt), requests.observe().first()) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt index d71a63ea67..c1fe7fd242 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -5,6 +5,8 @@ import com.flipcash.app.persistence.sources.IncomingMessageOpener import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeySource import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.chat.ChatId @@ -25,7 +27,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.time.Instant -/** What a DM push shows: the opened plaintext, or `null` so the server's body stays. */ +/** What a DM or private group push shows: the opened plaintext, or `null` so the server's body stays. */ class MessagingPushDecryptionTest { private val chatId = ChatId(ByteArray(32) { 7 }) @@ -43,6 +45,9 @@ class MessagingPushDecryptionTest { private val selfSide = Keys(selfKeys, peerKeys.publicKey) private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val groupKey = ByteArray(32) { 5 } + private var groupKeyResult: Result = Result.success(groupKey) + private val group = GroupContentCrypto(FakeChatCipher, GroupKeySource { groupKeyResult }) private val messageDataSource = mockk(relaxed = true) private val messagingController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) { @@ -60,7 +65,7 @@ class MessagingPushDecryptionTest { stateHolder = mockk(relaxed = true), analytics = mockk(relaxed = true), senderResolver = mockk(relaxed = true), - incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)), + incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide), group), ) private suspend fun sealedFromPeer(content: MessageContent) = @@ -157,4 +162,19 @@ class MessagingPushDecryptionTest { fun `nothing to open without a message or its id`() = runTest { assertNull(delegate.openPushedMessage(chatId, message = null, messageId = null)) } + + @Test + fun `a private group message shows its plaintext`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group hello")).getOrThrow() + + assertEquals("group hello", delegate.openPushedMessage(chatId, message(sealed), messageId = null)) + } + + @Test + fun `a private group message keeps the server's body until the key is available`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group hello")).getOrThrow() + groupKeyResult = Result.failure(IOException("offline")) + + assertNull(delegate.openPushedMessage(chatId, message(sealed), messageId = null)) + } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt index 3c0a859384..9d3adc01d3 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -9,6 +9,9 @@ import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.E2eePolicy import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.OpenedBlob import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.chat.OpenedContent import com.flipcash.services.controllers.ChatController @@ -73,6 +76,10 @@ class MessagingSendEncryptionTest { private val keys = Keys(selfKeys, peerKeys.publicKey) private val peerCrypto = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val groupKey = ByteArray(32) { 5 } + private var groupKeyResult: Result = Result.success(groupKey) + private val groupCrypto = GroupContentCrypto(FakeChatCipher, GroupKeySource { groupKeyResult }) + private val controller = mockk(relaxed = true) private val chatController = mockk(relaxed = true) private val messages = mockk(relaxed = true) @@ -82,7 +89,12 @@ class MessagingSendEncryptionTest { every { it.accountId } returns selfId } - private fun chat(type: ChatType = ChatType.CONTACT_DM, useE2ee: Boolean = true, peer: ID = peerId) = + private fun chat( + type: ChatType = ChatType.CONTACT_DM, + useE2ee: Boolean = true, + peer: ID = peerId, + isPrivate: Boolean = false, + ) = ChatMetadata( chatId = chatId, type = type, @@ -90,6 +102,7 @@ class MessagingSendEncryptionTest { lastMessage = null, lastActivity = Instant.fromEpochSeconds(0), useE2ee = useE2ee, + isPrivate = isPrivate, ) private fun member(id: ID) = ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList()) @@ -130,6 +143,7 @@ class MessagingSendEncryptionTest { chatController = chatController, metadataDataSource = metadata, memberDataSource = members, + groupCrypto = groupCrypto, ), ) } @@ -209,6 +223,7 @@ class MessagingSendEncryptionTest { chatController = chatController, metadataDataSource = metadata, memberDataSource = members, + groupCrypto = groupCrypto, ) @Test @@ -393,4 +408,58 @@ class MessagingSendEncryptionTest { coVerify(exactly = 0) { controller.editMessage(any(), any(), any(), any()) } assertEquals(emptyMap(), delegate.observePendingMutations(chatId).first()) } + + private fun privateGroup() = chat(type = ChatType.GROUP, isPrivate = true) + + private suspend fun openAsGroupMember(wire: List): MessageContent { + val sealed = assertIs(wire.single()) + assertEquals(GroupContentCrypto.SCHEME_CHAT_KEY_XCHACHA20POLY1305, sealed.scheme) + return assertIs(groupCrypto.open(chatId, selfId, sealed)).content + } + + @Test + fun `text in a private group goes out sealed under the group key`() = runTest { + storedChat(privateGroup()) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello group", replyToMessageId = null).getOrThrow() + + assertEquals(MessageContent.Text("hello group"), openAsGroupMember(wires.single())) + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("hello group")), stored.captured.content) + } + + @Test + fun `a photo in a private group goes out sealed under the group key`() = runTest { + storedChat(privateGroup()) + + val result = outgoing().prepare(chatId, listOf(photo), blobSealedFor = chatId).getOrThrow() + + assertEquals(true, result.isSealed) + assertEquals(photo, openAsGroupMember(result.wire)) + } + + @Test + fun `a private group's photo bytes are sealed under the group key`() = runTest { + storedChat(privateGroup()) + val blobId = BlobId(ByteArray(16) { 7 }) + val bytes = byteArrayOf(1, 2, 3) + + val sealer = checkNotNull(outgoing().blobSealer(chatId).getOrThrow()) + val sealed = sealer.sealing(bytes).seal(blobId) + + val opened = groupCrypto.openBlob(chatId, selfId, blobId.bytes, bytes.size.toLong(), sealed) + assertEquals(bytes.toList(), assertIs(opened).bytes.toList()) + } + + @Test + fun `a private group send without the group key fails so it can be retried`() = runTest { + storedChat(privateGroup()) + groupKeyResult = Result.failure(IOException("offline")) + answerSends() + + assertEquals(true, delegate.sendMessage(chatId, "hello group", replyToMessageId = null).isFailure) + coVerify(exactly = 0) { controller.sendMessage(chatId, any(), any()) } + } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt index b32b58005e..c94c0f6f7c 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/media/ChatPhotoLoaderTest.kt @@ -1,8 +1,10 @@ package com.flipcash.shared.chat.media import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.chat.BlobOpenFailure import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.OpenedBlob import com.flipcash.services.controllers.BlobStorageController import com.flipcash.services.models.UserProfile @@ -41,7 +43,9 @@ class ChatPhotoLoaderTest { ChatMember(it, UserProfile(displayName = "n", socialAccounts = emptyList(), phoneNumber = null, email = null), emptyList()) } } - private val loader = ChatPhotoLoader(storage, crypto, user, members) { now } + private val groupCrypto = mockk() + private val metadata = mockk { coEvery { privacyOf(any()) } returns false } + private val loader = ChatPhotoLoader(storage, crypto, user, members, groupCrypto, metadata) { now } private fun metadata(url: String, expiresAt: Instant? = null) = BlobMetadata( mimeType = "image/jpeg", sizeBytes = 3, downloadUrl = url, expiresAtMillis = expiresAt?.toEpochMilliseconds(), @@ -109,4 +113,21 @@ class ChatPhotoLoaderTest { val failure = assertFailsWith { loader.open(photo("u", sealed = true), byteArrayOf(1)) } assertEquals(BlobOpenFailure.Authentication, failure.reason) } + + @Test + fun `a sealed photo in a private group opens with the group key`() = runTest { + coEvery { metadata.privacyOf(chatId) } returns true + coEvery { groupCrypto.openBlob(chatId, peer, blobId.bytes, 3, any()) } returns + OpenedBlob.Plaintext(byteArrayOf(4, 5, 6)) + + assertEquals(listOf(4, 5, 6), loader.open(photo("u", sealed = true), byteArrayOf(1)).toList()) + } + + @Test + fun `a sealed photo in a chat not stored yet waits for its key`() = runTest { + coEvery { metadata.privacyOf(chatId) } returns null + + val failure = assertFailsWith { loader.open(photo("u", sealed = true), byteArrayOf(1)) } + assertEquals(BlobOpenFailure.KeyPending, failure.reason) + } } diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt index 46eb7b0023..74b889aa96 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt @@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.outlined.WavingHand import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -68,6 +70,9 @@ fun chatterName(displayName: String?, username: String?, fallback: String): Stri * Who has been talking in a public group: a "Chatting" heading over portraits four to a row, the * host badged. Draws nothing for an empty list, heading included. * + * A private group's Members grid passes its own [title] and a [count] that opens the full list + * through [onShowAll]. + * * Rows are built by hand rather than as a lazy grid so it can sit inside the profile's own scroll. * A short last row leaves its empty cells blank to keep the columns aligned. The order is the * caller's; the grid does not sort. @@ -77,6 +82,9 @@ fun GroupChattingGrid( chatters: List, onOpen: (ChattingGridItem) -> Unit, modifier: Modifier = Modifier, + title: String = stringResource(R.string.title_chatting), + count: String? = null, + onShowAll: () -> Unit = {}, ) { if (chatters.isEmpty()) return @@ -84,11 +92,35 @@ fun GroupChattingGrid( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(SectionSpacing), ) { - Text( - text = stringResource(R.string.title_chatting), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Spacer(modifier = Modifier.weight(1f)) + if (count != null) { + Row( + modifier = Modifier.clickable(role = Role.Button, onClick = onShowAll), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = count, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + } + } Column(verticalArrangement = Arrangement.spacedBy(RowSpacing)) { chatters.chunked(Columns).forEach { row -> Row(horizontalArrangement = Arrangement.spacedBy(ColumnSpacing)) { 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 5e8bde3782..49f3a67e3c 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 @@ -478,9 +478,10 @@ class NotificationService : FirebaseMessagingService(), ?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) } ?: NotificationCompat.MessagingStyle(selfPerson) - // The server can't read an end-to-end encrypted DM, so its body is a stand-in. The opened - // text is the message itself, with no sender prefix to strip. - val opened = if (!styling.isGroupConversation) openPushedMessage(chatId, metadata) else null + // The server can't read an end-to-end encrypted DM or private group, so its body is a + // 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. + val opened = openPushedMessage(chatId, metadata) style.addMessage(opened ?: planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) // After the extract above, not before: a re-post rebuilds the style from the notification diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/45.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/45.json new file mode 100644 index 0000000000..741745ffcb --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/45.json @@ -0,0 +1,1055 @@ +{ + "formatVersion": 1, + "database": { + "version": 45, + "identityHash": "3547b6f22b17c4e3391862d768a46e41", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `cover_picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, `is_private` INTEGER NOT NULL DEFAULT 0, `description` TEXT, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "coverPictureJson", + "columnName": "cover_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isPrivate", + "columnName": "is_private", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "pending_media", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`client_id_hex` TEXT NOT NULL, `chat_id_hex` TEXT NOT NULL, `file_name` TEXT NOT NULL, `caption` TEXT, `reply_to_message_id` INTEGER, `stored_blob_id_hex` TEXT, `sealed_for_hex` TEXT, `width` INTEGER NOT NULL, `height` INTEGER NOT NULL, `blurhash` TEXT, `size_bytes` INTEGER, `created_at` INTEGER NOT NULL, PRIMARY KEY(`client_id_hex`))", + "fields": [ + { + "fieldPath": "clientIdHex", + "columnName": "client_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caption", + "columnName": "caption", + "affinity": "TEXT" + }, + { + "fieldPath": "replyToMessageId", + "columnName": "reply_to_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "storedBlobIdHex", + "columnName": "stored_blob_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "sealedForHex", + "columnName": "sealed_for_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "width", + "columnName": "width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blurhash", + "columnName": "blurhash", + "affinity": "TEXT" + }, + { + "fieldPath": "sizeBytes", + "columnName": "size_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "client_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3547b6f22b17c4e3391862d768a46e41')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index bff0da7a7c..309f74ac3d 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -125,8 +125,9 @@ import com.getcode.utils.subByteArray // dropped by the destructive fallback is a photo the viewer queued and never sent. AutoMigration(from = 42, to = 43), // pending_media table AutoMigration(from = 43, to = 44), // chat_metadata.cover_picture_json (nullable) + AutoMigration(from = 44, to = 45), // chat_metadata.is_private (default 0) ], - version = 44, + version = 45, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 0b383376ed..6f102728c1 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -94,7 +94,9 @@ interface ChatMetadataDao { "cover_picture_json = COALESCE(:coverPictureJson, cover_picture_json), " + "rules_json = :rulesJson, " + "is_member = :isMember, " + - "use_e2ee = :useE2ee " + + "use_e2ee = :useE2ee, " + + // Never cleared: privacy is fixed at creation, so a row rebuilt without it keeps it. + "is_private = (is_private OR :isPrivate) " + "WHERE chat_id_hex = :chatIdHex" ) suspend fun updateServerOwnedFields( @@ -110,6 +112,7 @@ interface ChatMetadataDao { rulesJson: ChatRulesSerialized?, isMember: Boolean, useE2ee: Boolean, + isPrivate: Boolean, ) /** @@ -212,6 +215,7 @@ interface ChatMetadataDao { rulesJson = entity.rulesJson, isMember = entity.isMember, useE2ee = entity.useE2ee, + isPrivate = entity.isPrivate, ) updateRosterIfNewer( chatIdHex = entity.chatIdHex, diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index 5726c30d09..882e208ef7 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -73,6 +73,10 @@ data class ChatMetadataEntity( // behaviourally for now; carried so a chat rebuilt from Room agrees with the network value. @ColumnInfo(name = "use_e2ee", defaultValue = "0") val useE2ee: Boolean = false, + // A private group: the host admits each member and messages are end-to-end encrypted. Set at + // creation and never changed, so the upsert only ever turns it on. + @ColumnInfo(name = "is_private", defaultValue = "0") + val isPrivate: Boolean = false, // Group description, up to 160 characters; null when unset or on a DM. @ColumnInfo(name = "description") val description: String? = null, diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 628a752b70..b4b8e38944 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -65,6 +65,7 @@ class ChatMetadataDaoTest { viewerStateVersion: Long = 0, canEdit: Boolean = false, useE2ee: Boolean = false, + isPrivate: Boolean = false, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -86,6 +87,7 @@ class ChatMetadataDaoTest { viewerStateVersion = viewerStateVersion, canEdit = canEdit, useE2ee = useE2ee, + isPrivate = isPrivate, ) @Test @@ -187,6 +189,24 @@ class ChatMetadataDaoTest { assertEquals(true, dao.getById(CHAT_HEX)?.useE2ee) } + @Test + fun `upsert keeps a private group private when a write does not carry it`() = runTest { + dao.upsert(entity(isPrivate = true)) + + dao.upsert(entity(isPrivate = false)) + + assertEquals(true, dao.getById(CHAT_HEX)?.isPrivate) + } + + @Test + fun `upsert picks up is_private for a stored chat`() = runTest { + dao.upsert(entity(isPrivate = false)) + + dao.upsert(entity(isPrivate = true)) + + assertEquals(true, dao.getById(CHAT_HEX)?.isPrivate) + } + @Test fun `upsert picks up use_e2ee turning off for a stored chat`() = runTest { dao.upsert(entity(useE2ee = true)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 8bc485cb35..4e3a7f8442 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -12,6 +12,7 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.models.chat.ViewerState import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -147,6 +148,13 @@ class ChatMetadataDataSource @Inject constructor( return ChatType.entries.firstOrNull { it.name == stored } ?: ChatType.UNKNOWN } + /** Whether [chatId] is a stored private group, whose content is sealed under the chat key. */ + suspend fun isPrivate(chatId: ChatId): Boolean = privacyOf(chatId) == true + + /** Whether [chatId] is a private group, or null when it is not stored on this device. */ + suspend fun privacyOf(chatId: ChatId): Boolean? = + observeById(chatId).firstOrNull()?.isPrivate + /** [chatId]'s group name, or null when the chat is a DM or is not stored on this device. */ suspend fun getTitle(chatId: ChatId): String? = db?.chatMetadataDao()?.getTitle(mapper.chatIdHex(chatId)) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt index da18daaefc..57a649594c 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt @@ -1,6 +1,7 @@ package com.flipcash.app.persistence.sources import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.GroupContentCrypto import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.chat.OpenedContent import com.flipcash.services.models.chat.ChatId @@ -16,6 +17,7 @@ import javax.inject.Inject */ class IncomingMessageOpener @Inject constructor( private val crypto: ChatContentCrypto, + private val groupCrypto: GroupContentCrypto, ) { /** * [messages] with each encrypted one opened and its [ChatMessage.encryption] set. A message @@ -23,7 +25,8 @@ class IncomingMessageOpener @Inject constructor( * opened again. * * [peerId] is the other member of the DM when it's stored; a message from the viewer can't be - * opened without it, and is left [MessageEncryption.KeyPending] to be opened once it is. + * opened without it, and is left [MessageEncryption.KeyPending] to be opened once it is. A + * private group's message is sealed under the chat key and needs no peer. */ suspend fun open( chatId: ChatId, @@ -56,6 +59,9 @@ class IncomingMessageOpener @Inject constructor( message: ChatMessage, sealed: MessageContent.Encrypted, ): ChatMessage { + if (sealed.scheme == GroupContentCrypto.SCHEME_CHAT_KEY_XCHACHA20POLY1305) { + return message.withOpened(sealed, groupCrypto.open(chatId, message.senderId, sealed)) + } val peer = when (val sender = message.senderId) { null -> null selfId -> peerId @@ -65,17 +71,20 @@ class IncomingMessageOpener @Inject constructor( return message.copy(content = listOf(sealed), encryption = MessageEncryption.KeyPending) } - return when (val opened = crypto.open(chatId, selfId, peer, message.senderId, sealed)) { - is OpenedContent.Plaintext -> message.copy( + return message.withOpened(sealed, crypto.open(chatId, selfId, peer, message.senderId, sealed)) + } + + private fun ChatMessage.withOpened(sealed: MessageContent.Encrypted, opened: OpenedContent): ChatMessage = + when (opened) { + is OpenedContent.Plaintext -> copy( content = listOf(opened.content), encryption = MessageEncryption.Decrypted(sealed), ) - OpenedContent.KeyPending -> message.copy(encryption = MessageEncryption.KeyPending) - is OpenedContent.Undecryptable -> message.copy( + OpenedContent.KeyPending -> copy(encryption = MessageEncryption.KeyPending) + is OpenedContent.Undecryptable -> copy( encryption = MessageEncryption.Undecryptable(opened.reason), ) } - } /** * The ciphertext of a message as the server sent it. A message that already carries an diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 1053b1d317..7032aab9c4 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -100,6 +100,7 @@ class ChatEntityMapper @Inject constructor() { canEdit = metadata.viewerState?.permissions?.canEdit ?: false, creatorHex = metadata.creator?.hexEncodedString(), useE2ee = metadata.useE2ee, + isPrivate = metadata.isPrivate, ) } @@ -133,6 +134,7 @@ class ChatEntityMapper @Inject constructor() { viewerState = entity.toViewerState(), creator = entity.creatorHex?.hexToIdExt(), useE2ee = entity.useE2ee, + isPrivate = entity.isPrivate, ) } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt index 34c53b06c8..5d808ede08 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt @@ -3,6 +3,9 @@ package com.flipcash.app.persistence.sources import com.flipcash.services.chat.ChatContentCrypto import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.GroupContentCrypto +import com.flipcash.services.chat.GroupKeySource +import com.flipcash.services.chat.UntrustedEnvelope import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.chat.UndecryptableReason import com.flipcash.services.models.chat.ChatId @@ -35,7 +38,10 @@ class IncomingMessageOpenerTest { } private val selfSide = Keys(selfKeys, peerKeys.publicKey) - private val opener = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)) + private val groupKey = ByteArray(32) { 5 } + private var groupKeyResult: Result = Result.success(groupKey) + private val group = GroupContentCrypto(FakeChatCipher, GroupKeySource { groupKeyResult }) + private val opener = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide), group) private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) private val mine = ChatContentCrypto(FakeChatCipher, Keys(selfKeys, peerKeys.publicKey)) @@ -106,7 +112,7 @@ class IncomingMessageOpenerTest { @Test fun `an unknown scheme is unsupported`() = runTest { - val opened = open(message(fromPeer("hi").copy(scheme = 2))).single() + val opened = open(message(fromPeer("hi").copy(scheme = 3))).single() assertEquals(MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), opened.encryption) } @@ -152,4 +158,43 @@ class IncomingMessageOpenerTest { assertEquals(listOf(MessageContent.Text("hi")), reopened.content) assertEquals(MessageEncryption.Decrypted(sealed), reopened.encryption) } + + @Test + fun `a private group message opens under the chat key with no peer stored`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + + val opened = open(message(sealed), peerId = null).single() + + assertEquals(listOf(MessageContent.Text("group")), opened.content) + assertEquals(MessageEncryption.Decrypted(sealed), opened.encryption) + } + + @Test + fun `a private group message waits when the chat key can't be fetched`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + groupKeyResult = Result.failure(IOException("offline")) + + assertEquals(MessageEncryption.KeyPending, open(message(sealed)).single().encryption) + } + + @Test + fun `a private group message under an untrusted envelope is unsupported`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + groupKeyResult = Result.failure(UntrustedEnvelope("wrapped by a stranger")) + + assertEquals( + MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), + open(message(sealed)).single().encryption, + ) + } + + @Test + fun `a private group message is bound to its sender`() = runTest { + val sealed = group.seal(chatId, senderId = peer, content = MessageContent.Text("group")).getOrThrow() + + assertEquals( + MessageEncryption.Undecryptable(UndecryptableReason.Authentication), + open(message(sealed, senderId = self)).single().encryption, + ) + } } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index 504bfe72c2..bafb310cb3 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -276,6 +276,15 @@ class ChatEntityMapperTest { assertEquals(true, restored.useE2ee) } + @Test + fun `a private group stays private through the row`() { + val entity = mapper.toEntity(groupMetadata().copy(isPrivate = true)) + assertEquals(true, entity.isPrivate) + + val restored = mapper.toMetadata(entity, members = emptyList(), lastMessage = null) + assertEquals(true, restored.isPrivate) + } + @Test fun `group description survives the round trip through the row`() { val entity = mapper.toEntity(groupMetadata().copy(description = "Weekly tips")) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt index 86bb6749b6..8530815f65 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt @@ -11,6 +11,9 @@ interface ChatKeySource { /** The viewer's account key pair, or null when no account is loaded. */ fun ownKeyPair(): KeyPair? + /** The viewer's Flipcash user id, or null when no account is loaded. */ + fun ownUserId(): ID? = null + /** The Ed25519 public key [userId] registered their account with. */ suspend fun peerPublicKey(userId: ID): Result } @@ -31,6 +34,8 @@ internal class DefaultChatKeySource @Inject constructor( KeyPair(publicKey = keyPair.publicKeyBytes, privateKey = keyPair.privateKeyBytes) } + override fun ownUserId(): ID? = userManager.accountId + override suspend fun peerPublicKey(userId: ID): Result = resolver.resolve(userId).map { it.byteArray } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt index 6ed1f8f9af..373d8e63a6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt @@ -11,11 +11,13 @@ import javax.inject.Inject * * This is the only reader of [ChatMetadata.useE2ee] in the app. It maps a chat onto * [ChatEncryptionPolicy], which both apps share, so the two platforms can't disagree on when a DM - * is encrypted. The @flipcash exemption lives there. + * is encrypted. The @flipcash exemption lives there. A private group is always encrypted. */ class E2eePolicy @Inject constructor() { fun shouldEncrypt(chat: ChatMetadata): Boolean { + // A private group is always encrypted, under its chat key; the server refuses plaintext. + if (chat.isPrivate) return true // CONTACT_DM and TIP_DM, matching the server's IsDmChatType. val isDm = chat.type == ChatType.CONTACT_DM || chat.type == ChatType.TIP_DM // Every member is checked rather than picking out the peer, so the answer doesn't depend diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt new file mode 100644 index 0000000000..d4b40f982c --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupContentCrypto.kt @@ -0,0 +1,141 @@ +package com.flipcash.services.chat + +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.toMessageContent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.opencode.model.core.ID +import com.google.protobuf.InvalidProtocolBufferException +import javax.inject.Inject +import javax.inject.Singleton + +/** The 32-byte chat key of a private group the viewer belongs to. */ +fun interface GroupKeySource { + suspend fun key(chatId: ChatId): Result +} + +/** + * Seals and opens private-group content as `messaging.v1.EncryptedContent` scheme + * `CHAT_KEY_XCHACHA20POLY1305`, under the chat key every member holds. The DM counterpart is + * [ChatContentCrypto]. + * + * A key that could not be fetched leaves a message [OpenedContent.KeyPending]; an envelope that + * cannot be trusted, or a payload that fails under the key held, is undecryptable. + */ +@Singleton +class GroupContentCrypto @Inject constructor( + private val cipher: ChatCipher, + private val keys: GroupKeySource, +) { + /** Encrypts [content] that [senderId], the viewer, sends in [chatId]. */ + suspend fun seal(chatId: ChatId, senderId: ID, content: MessageContent): Result { + val outbound = content.withoutDownloadUrls() + if (!outbound.isSealable()) { + return Result.failure(IllegalArgumentException("Cannot encrypt ${content::class.simpleName}")) + } + val groupKey = keys.key(chatId).getOrElse { return Result.failure(it) } + return runCatching { + val payload = cipher.encryptGroup( + content = outbound.asContent().toByteArray(), + groupKey = groupKey, + chatId = chatId.bytes, + senderId = senderId.toByteArray(), + ) + MessageContent.Encrypted( + scheme = SCHEME_CHAT_KEY_XCHACHA20POLY1305, + nonce = payload.nonce, + ciphertext = payload.ciphertext, + ) + } + } + + /** Decrypts [encrypted], a message [senderId] sent in [chatId]. */ + suspend fun open(chatId: ChatId, senderId: ID?, encrypted: MessageContent.Encrypted): OpenedContent { + if (encrypted.scheme != SCHEME_CHAT_KEY_XCHACHA20POLY1305) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + senderId ?: return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + val groupKey = keys.key(chatId).getOrElse { cause -> + // No resend opens a key wrapped by a stranger, so this matches iOS's "update the app". + return if (cause is UntrustedEnvelope) { + OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } else { + OpenedContent.KeyPending + } + } + + val plaintext = try { + cipher.decryptGroup( + payload = EncryptedPayload(nonce = encrypted.nonce, ciphertext = encrypted.ciphertext), + groupKey = groupKey, + chatId = chatId.bytes, + senderId = senderId.toByteArray(), + ) + } catch (_: ChatCipherException) { + return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } + + val content = try { + MessagingModel.Content.parseFrom(plaintext) + } catch (_: InvalidProtocolBufferException) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + return if (content.isRenderable()) { + OpenedContent.Plaintext(content.toMessageContent()) + } else { + OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + } + + /** Seals [plaintext], the stripped JPEG of photo [blobId] that the viewer [senderId] uploads. */ + suspend fun sealBlob(chatId: ChatId, senderId: ID, blobId: ByteArray, plaintext: ByteArray): Result { + val groupKey = keys.key(chatId).getOrElse { return Result.failure(it) } + return runCatching { + cipher.encryptGroupBlob( + image = plaintext, + groupKey = groupKey, + chatId = chatId.bytes, + blobId = blobId, + senderId = senderId.toByteArray(), + ) + } + } + + /** Opens [sealed], blob [blobId] that [senderId] uploaded, and checks it is [expectedSize] bytes. */ + suspend fun openBlob( + chatId: ChatId, + senderId: ID?, + blobId: ByteArray, + expectedSize: Long, + sealed: ByteArray, + ): OpenedBlob { + senderId ?: return OpenedBlob.Failed(BlobOpenFailure.Authentication) + val groupKey = keys.key(chatId).getOrElse { cause -> + return OpenedBlob.Failed( + if (cause is UntrustedEnvelope) BlobOpenFailure.Authentication else BlobOpenFailure.KeyPending, + ) + } + val plaintext = try { + cipher.decryptGroupBlob( + blob = sealed, + groupKey = groupKey, + chatId = chatId.bytes, + blobId = blobId, + senderId = senderId.toByteArray(), + ) + } catch (_: ChatCipherException) { + return OpenedBlob.Failed(BlobOpenFailure.Authentication) + } + if (plaintext.size.toLong() != expectedSize) return OpenedBlob.Failed(BlobOpenFailure.Length) + return OpenedBlob.Plaintext(plaintext) + } + + companion object { + /** `EncryptedContent.Scheme.CHAT_KEY_XCHACHA20POLY1305`. */ + const val SCHEME_CHAT_KEY_XCHACHA20POLY1305 = 2 + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt new file mode 100644 index 0000000000..594726e814 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/GroupKeyEnvelopes.kt @@ -0,0 +1,149 @@ +package com.flipcash.services.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetKeyEnvelopeError +import com.flipcash.services.models.SetKeyEnvelopeError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A private group's chat key, carried to each member as a `chat.v1.KeyEnvelope`. + * + * The creator generates the key once the chat exists ([storeNewKey]) and wraps it for each user it + * admits ([wrapFor]). Wrapping reuses the DM cipher's key agreement under the group-wrap label; the + * server only ever sees envelopes. + */ +@Singleton +class GroupKeyEnvelopes @Inject constructor( + private val cipher: ChatCipher, + private val keys: ChatKeySource, + private val chatController: ChatController, +) : GroupKeySource { + // Own public key in the key so a different account on this device never reuses a group key. + private data class CacheKey(val chatId: ID, val ownPk: ID) + + private val groupKeys = ConcurrentHashMap() + private val locks = ConcurrentHashMap() + + /** + * Generates [chatId]'s chat key and stores the creator's own envelope: the second step of + * creating a private group. `ALREADY_SET` is success, since the envelope stored first stands + * and a retry after a lost response lands there. + */ + suspend fun storeNewKey(chatId: ChatId): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val groupKey = cipher.newGroupKey() + val envelope = runCatching { + cipher.wrapGroupKey(own, own.publicKey, chatId.bytes, groupKey).toKeyEnvelope() + }.getOrElse { return Result.failure(it) } + + val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList()) + return chatController.setKeyEnvelope(chatId, envelope) + .onSuccess { groupKeys[cacheKey] = groupKey } + .recoverCatching { error -> + if (error is SetKeyEnvelopeError.AlreadySet) groupKeys.remove(cacheKey) else throw error + } + } + + /** Wraps [chatId]'s chat key for [recipientPk], for the creator to admit them. */ + suspend fun wrapFor(chatId: ChatId, recipientPk: ByteArray): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val groupKey = key(chatId).getOrElse { return Result.failure(it) } + return runCatching { cipher.wrapGroupKey(own, recipientPk, chatId.bytes, groupKey).toKeyEnvelope() } + } + + /** + * The viewer's copy of [chatId]'s chat key, fetched once per chat per launch and kept in memory. + * + * A self-wrapped envelope opens under the viewer's own key. One the creator wrapped when + * admitting the viewer opens under the creator's key, which comes from the Resolver for + * `Metadata.creator`; the member then wraps the key for itself and stores that, so its other + * devices depend only on its own key. An envelope wrapped by anyone else is refused, and so is + * one that fails to open: the caller must not send in the chat without this key. + * + * `NO_ENVELOPE` means a creator whose setup stopped before [storeNewKey] landed; only the + * creator regenerates the key there. + */ + override suspend fun key(chatId: ChatId): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList()) + groupKeys[cacheKey]?.let { return Result.success(it) } + + return locks.getOrPut(cacheKey) { Mutex() }.withLock { + groupKeys[cacheKey]?.let { return@withLock Result.success(it) } + fetchKey(chatId, own) + .onSuccess { groupKeys[cacheKey] = it } + } + } + + private suspend fun fetchKey(chatId: ChatId, own: KeyPair, retried: Boolean = false): Result { + val stored = chatController.getKeyEnvelope(chatId).getOrElse { error -> + if (error is GetKeyEnvelopeError.NoEnvelope && !retried && viewerIsCreator(chatId)) { + // storeNewKey caches the key it stored; ALREADY_SET leaves nothing cached, so read that one. + return storeNewKey(chatId).mapCatching { + groupKeys[CacheKey(chatId.bytes.toList(), own.publicKey.toList())] + ?: fetchKey(chatId, own, retried = true).getOrThrow() + } + } + return Result.failure(error) + } + if (stored.envelope.scheme != KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305) { + return Result.failure(UntrustedEnvelope("Unsupported envelope scheme ${stored.envelope.scheme}")) + } + val payload = EncryptedPayload(stored.envelope.nonce, stored.envelope.ciphertext) + val wrapper = stored.wrappedBy + + if (wrapper == null || wrapper == keys.ownUserId()) { + return runCatching { cipher.unwrapGroupKey(own, own.publicKey, chatId.bytes, payload) } + .recoverCatching { throw UntrustedEnvelope("Self-wrapped envelope did not open", it) } + } + + val creator = chatController.getChat(chatId).getOrElse { return Result.failure(it) }.creator + if (creator == null || wrapper != creator) { + return Result.failure(UntrustedEnvelope("Envelope wrapped by someone other than the creator")) + } + val creatorPk = keys.peerPublicKey(creator).getOrElse { return Result.failure(it) } + val groupKey = runCatching { cipher.unwrapGroupKey(own, creatorPk, chatId.bytes, payload) } + .getOrElse { return Result.failure(UntrustedEnvelope("Creator-wrapped envelope did not open", it)) } + + // The self-wrapped copy only saves the next launch a trip through the creator's envelope, so + // failing to store it doesn't cost the key that just opened. + val selfWrapped = runCatching { + cipher.wrapGroupKey(own, own.publicKey, chatId.bytes, groupKey).toKeyEnvelope() + }.getOrElse { return Result.success(groupKey) } + return chatController.setKeyEnvelope(chatId, selfWrapped).fold( + onSuccess = { Result.success(groupKey) }, + onFailure = { error -> + // Another device re-wrapped first; its envelope stands, so read that one. + if (error is SetKeyEnvelopeError.AlreadySet && !retried) fetchKey(chatId, own, retried = true) + else Result.success(groupKey) + }, + ) + } + + private suspend fun viewerIsCreator(chatId: ChatId): Boolean { + val self = keys.ownUserId() ?: return false + return chatController.getChat(chatId).getOrNull()?.creator == self + } + + private fun EncryptedPayload.toKeyEnvelope() = KeyEnvelope( + scheme = KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305, + nonce = nonce, + ciphertext = ciphertext, + ) +} + +/** A key envelope this client will not trust: wrong wrapper, unknown scheme, or it failed to open. */ +class UntrustedEnvelope(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt index 7c3317d7f4..76c4cc2d78 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt @@ -2,6 +2,8 @@ package com.flipcash.services.inject import com.flipcash.services.chat.ChatKeySource import com.flipcash.services.chat.DefaultChatKeySource +import com.flipcash.services.chat.GroupKeyEnvelopes +import com.flipcash.services.chat.GroupKeySource import com.getcode.chatcipher.ChatCipher import com.getcode.chatcipher.DefaultChatCipher import dagger.Binds @@ -17,6 +19,9 @@ internal abstract class ChatCryptoModule { @Binds abstract fun bindChatKeySource(source: DefaultChatKeySource): ChatKeySource + @Binds + abstract fun bindGroupKeySource(source: GroupKeyEnvelopes): GroupKeySource + companion object { @Provides fun provideChatCipher(): ChatCipher = DefaultChatCipher diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt new file mode 100644 index 0000000000..6b69ee92a8 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupContentCryptoTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** Private-group content sealed under the chat key: bound to its chat, sender and blob id. */ +class GroupContentCryptoTest { + + private val chatId = ChatId(ByteArray(32) { 3 }) + private val otherChat = ChatId(ByteArray(32) { 4 }) + private val sender: ID = List(16) { 1 } + private val someoneElse: ID = List(16) { 2 } + private val blobId = ByteArray(16) { 7 } + + private var key: Result = Result.success(ByteArray(32) { 5 }) + private val crypto = GroupContentCrypto(FakeChatCipher, GroupKeySource { key }) + + private suspend fun sealText(text: String) = + crypto.seal(chatId, sender, MessageContent.Text(text)).getOrThrow() + + @Test + fun `text sealed under the chat key opens to itself`() = runTest { + val sealed = sealText("hi") + + assertEquals(GroupContentCrypto.SCHEME_CHAT_KEY_XCHACHA20POLY1305, sealed.scheme) + assertEquals(OpenedContent.Plaintext(MessageContent.Text("hi")), crypto.open(chatId, sender, sealed)) + } + + @Test + fun `a message claimed by another sender or chat doesn't open`() = runTest { + val sealed = sealText("hi") + + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Authentication), + crypto.open(chatId, someoneElse, sealed), + ) + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Authentication), + crypto.open(otherChat, sender, sealed), + ) + } + + @Test + fun `a key that can't be fetched leaves the message pending`() = runTest { + val sealed = sealText("hi") + key = Result.failure(IOException("offline")) + + assertEquals(OpenedContent.KeyPending, crypto.open(chatId, sender, sealed)) + } + + @Test + fun `a key wrapped by a stranger marks the message unsupported`() = runTest { + val sealed = sealText("hi") + key = Result.failure(UntrustedEnvelope("wrapped by a stranger")) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Unsupported), crypto.open(chatId, sender, sealed)) + } + + @Test + fun `sealing without the key fails`() = runTest { + key = Result.failure(IOException("offline")) + + assertTrue(crypto.seal(chatId, sender, MessageContent.Text("hi")).isFailure) + } + + @Test + fun `a photo's bytes open for the same blob and are refused for another`() = runTest { + val bytes = byteArrayOf(1, 2, 3) + val sealed = crypto.sealBlob(chatId, sender, blobId, bytes).getOrThrow() + + val opened = crypto.openBlob(chatId, sender, blobId, bytes.size.toLong(), sealed) + assertContentEquals(bytes, assertIs(opened).bytes) + + val wrongBlob = crypto.openBlob(chatId, sender, ByteArray(16) { 8 }, bytes.size.toLong(), sealed) + assertEquals(OpenedBlob.Failed(BlobOpenFailure.Authentication), wrongBlob) + } + + @Test + fun `a photo that opens to the wrong length is refused`() = runTest { + val sealed = crypto.sealBlob(chatId, sender, blobId, byteArrayOf(1, 2, 3)).getOrThrow() + + assertEquals(OpenedBlob.Failed(BlobOpenFailure.Length), crypto.openBlob(chatId, sender, blobId, 4, sealed)) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt new file mode 100644 index 0000000000..8ce70c32b2 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/GroupKeyEnvelopesTest.kt @@ -0,0 +1,203 @@ +package com.flipcash.services.chat + +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetKeyEnvelopeError +import com.flipcash.services.models.SetKeyEnvelopeError +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.KeyEnvelope +import com.flipcash.services.models.chat.StoredKeyEnvelope +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GroupKeyEnvelopesTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val creatorKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val memberPk = ByteArray(32) { 21 } + + private val keys = object : ChatKeySource { + override fun ownKeyPair() = creatorKeys + override suspend fun peerPublicKey(userId: ID): Result = error("unused") + } + private val controller = mockk() + private val envelopes = GroupKeyEnvelopes(FakeChatCipher, keys, controller) + + @Test + fun `storeNewKey stores a self-wrapped envelope`() = runTest { + val stored = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(stored)) } returns Result.success(Unit) + + assertTrue(envelopes.storeNewKey(chatId).isSuccess) + + assertEquals(KeyEnvelope.SCHEME_X25519_XCHACHA20POLY1305, stored.captured.scheme) + val opened = FakeChatCipher.unwrapGroupKey( + creatorKeys, creatorKeys.publicKey, chatId.bytes, + EncryptedPayload(stored.captured.nonce, stored.captured.ciphertext), + ) + assertContentEquals(FakeChatCipher.newGroupKey(), opened) + } + + @Test + fun `ALREADY_SET counts as stored`() = runTest { + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(SetKeyEnvelopeError.AlreadySet()) + + assertTrue(envelopes.storeNewKey(chatId).isSuccess) + } + + @Test + fun `any other failure is returned`() = runTest { + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(IOException("offline")) + + assertTrue(envelopes.storeNewKey(chatId).isFailure) + } + + @Test + fun `wrapFor reuses the key just stored`() = runTest { + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + envelopes.storeNewKey(chatId) + + val envelope = envelopes.wrapFor(chatId, memberPk).getOrThrow() + + val member = KeyPair(publicKey = memberPk, privateKey = ByteArray(64)) + val opened = FakeChatCipher.unwrapGroupKey( + member, creatorKeys.publicKey, chatId.bytes, EncryptedPayload(envelope.nonce, envelope.ciphertext), + ) + assertContentEquals(FakeChatCipher.newGroupKey(), opened) + coVerify(exactly = 0) { controller.getKeyEnvelope(any()) } + } + + @Test + fun `wrapFor opens the stored envelope when nothing is cached`() = runTest { + val self = FakeChatCipher.wrapGroupKey(creatorKeys, creatorKeys.publicKey, chatId.bytes, ByteArray(32) { 5 }) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success( + StoredKeyEnvelope(KeyEnvelope(1, self.nonce, self.ciphertext), wrappedBy = null), + ) + + val envelope = envelopes.wrapFor(chatId, memberPk).getOrThrow() + + assertContentEquals(creatorKeys.publicKey + memberPk + ByteArray(32) { 5 }, envelope.ciphertext) + } + + // Member path: the viewer is memberKeys, admitted by the creator. + + private val creatorId: ID = List(16) { 1 } + private val memberId: ID = List(16) { 2 } + private val memberKeys = KeyPair(publicKey = memberPk, privateKey = ByteArray(64) { 22 }) + private val memberSource = object : ChatKeySource { + override fun ownKeyPair() = memberKeys + override fun ownUserId() = memberId + override suspend fun peerPublicKey(userId: ID): Result = + if (userId == creatorId) Result.success(creatorKeys.publicKey) else Result.failure(IOException("unknown")) + } + private val memberEnvelopes = GroupKeyEnvelopes(FakeChatCipher, memberSource, controller) + private val groupKey = ByteArray(32) { 5 } + + private fun stored(wrapper: KeyPair, recipient: ByteArray, wrappedBy: ID?): StoredKeyEnvelope { + val sealed = FakeChatCipher.wrapGroupKey(wrapper, recipient, chatId.bytes, groupKey) + return StoredKeyEnvelope(KeyEnvelope(1, sealed.nonce, sealed.ciphertext), wrappedBy) + } + + private fun metadata(creator: ID?) = mockk { every { this@mockk.creator } returns creator } + + @Test + fun `a member opens the creator's envelope and stores a self-wrapped copy`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(creatorKeys, memberPk, creatorId)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + val rewrapped = slot() + coEvery { controller.setKeyEnvelope(chatId, capture(rewrapped)) } returns Result.success(Unit) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + + assertContentEquals(memberPk + memberPk + groupKey, rewrapped.captured.ciphertext) + } + + @Test + fun `a failed re-wrap still returns the key`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(creatorKeys, memberPk, creatorId)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(IOException("offline")) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + } + + @Test + fun `the key is fetched once`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(memberKeys, memberPk, memberId)) + + memberEnvelopes.key(chatId).getOrThrow() + memberEnvelopes.key(chatId).getOrThrow() + + coVerify(exactly = 1) { controller.getKeyEnvelope(chatId) } + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `an envelope wrapped by someone other than the creator is refused`() = runTest { + val stranger: ID = List(16) { 9 } + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(creatorKeys, memberPk, stranger)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `an envelope that does not open under the creator's key is refused`() = runTest { + val impostor = KeyPair(publicKey = ByteArray(32) { 99.toByte() }, privateKey = ByteArray(64)) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.success(stored(impostor, memberPk, creatorId)) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).exceptionOrNull() is UntrustedEnvelope) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `ALREADY_SET on the re-wrap reads the envelope another device stored`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returnsMany listOf( + Result.success(stored(creatorKeys, memberPk, creatorId)), + Result.success(stored(memberKeys, memberPk, memberId)), + ) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.failure(SetKeyEnvelopeError.AlreadySet()) + + assertContentEquals(groupKey, memberEnvelopes.key(chatId).getOrThrow()) + coVerify(exactly = 2) { controller.getKeyEnvelope(chatId) } + } + + @Test + fun `NO_ENVELOPE regenerates the key only for the creator`() = runTest { + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + + assertTrue(memberEnvelopes.key(chatId).isFailure) + coVerify(exactly = 0) { controller.setKeyEnvelope(any(), any()) } + } + + @Test + fun `NO_ENVELOPE for the creator stores a new key and uses it`() = runTest { + val creatorSource = object : ChatKeySource { + override fun ownKeyPair() = creatorKeys + override fun ownUserId() = creatorId + override suspend fun peerPublicKey(userId: ID): Result = error("unused") + } + val creatorEnvelopes = GroupKeyEnvelopes(FakeChatCipher, creatorSource, controller) + coEvery { controller.getKeyEnvelope(chatId) } returns Result.failure(GetKeyEnvelopeError.NoEnvelope()) + coEvery { controller.getChat(chatId, any()) } returns Result.success(metadata(creatorId)) + coEvery { controller.setKeyEnvelope(chatId, any()) } returns Result.success(Unit) + + assertContentEquals(FakeChatCipher.newGroupKey(), creatorEnvelopes.key(chatId).getOrThrow()) + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/NotificationDot.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/NotificationDot.kt new file mode 100644 index 0000000000..795a0a9332 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/NotificationDot.kt @@ -0,0 +1,59 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme + +/** + * Node 11440:189 — a small filled dot that marks an icon as needing attention, ringed in the + * background colour so it reads as cut out of the icon. No count. + */ +@Composable +fun NotificationDot( + modifier: Modifier = Modifier, + color: Color = CodeTheme.colors.success, + ring: Color = CodeTheme.colors.background, +) { + // The ring sits outside the fill: the design's 11dp dot plus a 2dp stroke. + Box( + modifier = modifier + .background(ring, CircleShape) + .padding(2.dp) + .size(11.dp) + .background(color, CircleShape), + ) +} + +/** + * Draws [content] with a [NotificationDot] on its top-end corner while [isShown]. The default + * [offset] hangs the dot off a bare glyph's corner, as iOS does; a circular button, whose corner is + * already empty, wants [DpOffset.Zero]. + */ +@Composable +fun WithNotificationDot( + isShown: Boolean, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(5.dp, (-5).dp), + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + content() + if (isShown) { + NotificationDot( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = offset.x, y = offset.y), + ) + } + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt index 1d123d761f..ba5feae264 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/TitleBar.kt @@ -253,6 +253,10 @@ fun AppBarWithTitle( // the sheet-root Close, which sits on the trailing edge. leadingDismiss: Boolean = false, hazeState: HazeState? = null, + // A screen's own leading control, for a root screen with no back or close to put there. Ignored + // when [onBackIconClicked] asks for the nav control. A centred title keeps its place whether + // this draws anything or not: the base reserves a back button's width either way. + leadingContent: @Composable () -> Unit = { }, endContent: @Composable RowScope.() -> Unit = { }, ) { val navigator = LocalCodeNavigator.current @@ -286,6 +290,8 @@ fun AppBarWithTitle( } else if (!showClose) { AppBarDefaults.UpNavigation(hazeState = hazeState) { onBackIconClicked() } } + } else { + leadingContent() } }, titleRegion = {