diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 8cb0ed7c..79932dde 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -367,33 +367,38 @@ fun ChatRoomDetailScreen( Text("Shared Key") } } + } - // Next to the key, because that is what they sign with. - // The transcript carries a proposal past as it happens; - // this is where a member goes to find one that has - // scrolled away, or to see what the group has signed. - item { - TextButton( - onClick = { - onNavigateToRoute.invoke( - ProposalListRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId - ) + // Every room, not only the one that holds the key. A + // ceremony needs a NIP-17 group, but a signing message is + // a marmot inner event -- see `FrostSigningManager.broadcast` + // -- so the room a group actually proposes in is the MLS + // one, which is the last place this should be missing from. + // + // The transcript carries a proposal past as it happens; + // this is where a member goes to find one that has scrolled + // away, or to see what the group has signed. + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + ProposalListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId ) - } - ) { - Icon( - Icons.Default.Draw, - contentDescription = "Proposals" ) - - Spacer( - modifier = Modifier.width(10.dp) - ) - - Text("Proposals") } + ) { + Icon( + Icons.Default.Draw, + contentDescription = "Proposals" + ) + + Spacer( + modifier = Modifier.width(10.dp) + ) + + Text("Proposals") } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index b023c243..83c55c4e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -44,6 +44,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute @@ -65,6 +66,7 @@ fun ChatRoomMessagingScreen( initialChatRoomMessagingUIState: ChatRoomMessagingUIState = ChatRoomMessagingUIState.Loading, nostrRepository: NostrRepository, chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository, onNavigateToRouteAndPopUpInclusive: (Route) -> Unit, onNavigateToRoute: (Route) -> Unit, ) { @@ -100,7 +102,8 @@ fun ChatRoomMessagingScreen( factory = press.mantra.compose.ui.view.model.ChatMessageListViewModel.factory( localChatRoom = chatRoomDetailUIState.localChatRoom, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository ) ) @@ -149,13 +152,21 @@ fun ChatRoomMessagingScreen( ) }, onOpenSigning = { sessionId -> - // A line written before chat rows named their - // session cannot say which proposal it is about, - // and the room may have several. The list is the - // honest answer: it shows all of them with their - // own state, rather than guessing at one. + // Two reasons to open the queue instead of the one + // proposal. A line written before chat rows named + // their session cannot say which proposal it is + // about, and the room may have several -- the list + // is the honest answer there, showing all of them + // with their own state rather than guessing at one. + // + // The other is that the proposal they tapped is + // not the only one waiting on them, in which case + // it is not the whole of what is being asked and a + // screen showing only it would say it was. onNavigateToRoute.invoke( - if (sessionId == null) { + if (sessionId == null || + chatMessageListViewModel.hidesOtherDecisions(sessionId) + ) { ProposalListRoute( activeUserPublicKey = activeUserPublicKey, chatRoomId = chatRoomId @@ -440,6 +451,7 @@ private fun ChatRoomMessagingScreenPreview() { ), nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, onNavigateToRouteAndPopUpInclusive = {}, onNavigateToRoute = {} ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index c1e74835..dd08385b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -746,6 +746,7 @@ fun MantraNavHost( relayHint = route.relayHint, nostrRepository = databaseNostrRepository, chatRepository = databaseChatRepository, + frostSigningRepository = databaseFrostSigningRepository, onNavigateToRouteAndPopUpInclusive = { chatRoomDetailRoute -> navController.navigate( route = chatRoomDetailRoute diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 66067b1b..c88916b9 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -73,9 +73,11 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.SynchronizationFilter import press.mantra.compose.extensions.shortened import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.managers.FrostSigningManager import press.mantra.compose.nostr.Nip17Filters import press.mantra.compose.nostr.Relays import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.widgets.profile.ProfileColor import press.mantra.compose.ui.view.state.ChatMessageListUIState @@ -91,7 +93,8 @@ class ChatMessageListViewModel( initialChatMessageListUIState: ChatMessageListUIState, val localChatRoom: LocalChatRoom, val nostrRepository: NostrRepository, - val chatRepository: ChatRepository + val chatRepository: ChatRepository, + val frostSigningRepository: FrostSigningRepository ): ViewModel() { val logger = Logger.withTag(TAG) var chatMessageListUIState: ChatMessageListUIState by mutableStateOf(initialChatMessageListUIState) @@ -114,6 +117,33 @@ class ChatMessageListViewModel( var openMessageActionsFor: Long? by mutableStateOf(null) private set + /** + * The sessions still waiting on this member to agree to sign, by id. + * + * Held rather than asked for, because it is read at the moment of a tap -- + * see [hidesOtherDecisions] -- and that is not a moment that can wait on a + * query. + */ + var proposalsAwaitingYou: Set by mutableStateOf(emptySet()) + private set + + /** + * Whether opening [sessionId] by itself would hide other decisions the room + * is waiting on this member for. + * + * A signing line names one proposal, and opening it is right while that is + * the only thing being asked of them. It stops being right the moment there + * are two: the second is not in the transcript beside the first -- it may be + * pages up, or have arrived while they were reading -- so answering the one + * they tapped and leaving would look exactly like being done. + * + * Only for a proposal that is itself waiting on them. A line about something + * the group has already signed is history, and a reader who taps one is + * asking to see that, not to be handed the queue. + */ + fun hidesOtherDecisions(sessionId: String): Boolean = + sessionId in proposalsAwaitingYou && proposalsAwaitingYou.size > 1 + fun startDirectMessage(participant: Participant) { logger.d("Arming a direct message to ${participant.participantPublicKey}") directMessageRecipient = participant @@ -157,9 +187,31 @@ class ChatMessageListViewModel( logger.d("init") scheduleSynchronization() observeChatRoomFeed() + observeProposalsAwaitingYou() askForGroupHistory() } + /** + * Watches which of the room's proposals are waiting on this member. + * + * Observed rather than read once: a proposal arrives while the room is + * open as often as before it is opened, and one answered on another device + * stops being owed without anything happening here. Asked of the manager, + * the way the proposal list asks, so the two cannot disagree about which + * proposals still have a decision in them. + */ + private fun observeProposalsAwaitingYou() { + viewModelScope.launch(Dispatchers.IO) { + frostSigningRepository + .observeSessionsForChatRoom(localChatRoom.chatRoom.id) + .collect { sessions -> + proposalsAwaitingYou = sessions.filter { + FrostSigningManager.isAwaitingApproval(it.session, it.items) + }.map { it.session.id }.toSet() + } + } + } + /** * Ask the group for its signed record, if this device holds none of it. * @@ -643,14 +695,16 @@ class ChatMessageListViewModel( initialChatMessageListUIState: ChatMessageListUIState = ChatMessageListUIState.Loading, localChatRoom: LocalChatRoom, nostrRepository: NostrRepository, - chatRepository: ChatRepository + chatRepository: ChatRepository, + frostSigningRepository: FrostSigningRepository ): ViewModelProvider.Factory = viewModelFactory { initializer { ChatMessageListViewModel( initialChatMessageListUIState = initialChatMessageListUIState, localChatRoom = localChatRoom, nostrRepository = nostrRepository, - chatRepository = chatRepository + chatRepository = chatRepository, + frostSigningRepository = frostSigningRepository ) } }