From 7ed036bbaa594a82ecedb16a0177dea2c39b438a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 20:38:23 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=207=20=E2=80=94=20Marmot=20group?= =?UTF-8?q?=20chat=20UI=20&=20ViewModel=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete UI layer for Marmot Protocol MLS group messaging: State models (commons/commonMain/): - MarmotGroupChatroom: tracks decrypted inner messages per group - MarmotGroupList: manages all group chatrooms with change notifications - MarmotGroupFeedFilter: feed filter for group message feeds - MarmotGroupFeedViewModel: ViewModel exposing group messages Account integration: - Add marmotGroupList to IAccount interface and implementations - GroupEventHandler indexes inner events into MarmotGroupList - AccountViewModel gains sendMarmotGroupMessage, createMarmotGroup, publishMarmotKeyPackage helper methods Android screens (amethyst/): - MarmotGroupListScreen: lists all groups with last message preview - MarmotGroupChatScreen: group conversation with message composer - MarmotGroupChatView: message feed + simple text composer - CreateGroupDialog: create new MLS group with display name - AddMemberDialog: add member by npub/hex (KeyPackage fetch TBD) Navigation & integration: - Route.MarmotGroupList and Route.MarmotGroupChat routes - Wired into AppNavigation nav graph - "MLS Groups" button added to ChannelFabColumn on Messages screen - Marmot group newest messages appear in ChatroomListKnownFeedFilter https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/ui/navigation/AppNavigation.kt | 5 + .../amethyst/ui/navigation/routes/Routes.kt | 6 + .../ui/screen/loggedIn/AccountViewModel.kt | 24 ++ .../loggedIn/DecryptAndIndexProcessor.kt | 3 + .../chats/marmotGroup/AddMemberDialog.kt | 149 +++++++++++ .../chats/marmotGroup/CreateGroupDialog.kt | 100 ++++++++ .../marmotGroup/MarmotGroupChatScreen.kt | 113 ++++++++ .../chats/marmotGroup/MarmotGroupChatView.kt | 145 +++++++++++ .../marmotGroup/MarmotGroupFeedViewModel.kt | 45 ++++ .../marmotGroup/MarmotGroupListScreen.kt | 241 ++++++++++++++++++ .../loggedIn/chats/rooms/ChannelFabColumn.kt | 19 ++ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 7 +- .../amethyst/commons/model/IAccount.kt | 4 + .../model/marmotGroups/MarmotGroupChatroom.kt | 107 ++++++++ .../model/marmotGroups/MarmotGroupList.kt | 67 +++++ .../commons/ui/feeds/MarmotGroupFeedFilter.kt | 56 ++++ .../viewmodels/MarmotGroupFeedViewModel.kt | 41 +++ .../amethyst/desktop/model/DesktopIAccount.kt | 3 + 19 files changed, 1137 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/MarmotGroupFeedFilter.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 24a60819f..57b7a4ed7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -376,6 +376,9 @@ class Account( val draftsDecryptionCache = DraftEventCache(signer) override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) + override val marmotGroupList = + com.vitorpamplona.amethyst.commons.model.marmotGroups + .MarmotGroupList() val newNotesPreProcessor = EventProcessor(this, cache) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 87525c32b..dc2c0d4cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -71,6 +71,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen @@ -282,6 +284,9 @@ fun BuildNavigation( composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } composableFromEndArgs { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) } + composableFromEnd { MarmotGroupListScreen(accountViewModel, nav) } + composableFromEndArgs { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) } + composableFromEndArgs { PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9fa265f54..3d5f7712a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -263,6 +263,12 @@ sealed class Route { val id: String? = null, ) : Route() + @Serializable object MarmotGroupList : Route() + + @Serializable data class MarmotGroupChat( + val nostrGroupId: String, + ) : Route() + @Serializable data class NewGroupDM( val message: String? = null, val attachment: String? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 15e31d01f..6ddb07484 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1440,6 +1440,30 @@ class AccountViewModel( } } + // --- Marmot Group Messaging --- + + suspend fun sendMarmotGroupMessage( + nostrGroupId: String, + text: String, + ) { + val template = + com.vitorpamplona.quartz.nip01Core.signers.eventTemplate( + kind = 9, + description = text, + ) + val innerEvent = account.signer.sign(template) + val relays = account.outboxRelays.flow.value + account.sendMarmotGroupMessage(nostrGroupId, innerEvent, relays) + } + + suspend fun createMarmotGroup(nostrGroupId: String) { + account.createMarmotGroup(nostrGroupId) + } + + suspend fun publishMarmotKeyPackage() { + account.publishMarmotKeyPackage() + } + override fun onCleared() { Log.d("AccountViewModel", "onCleared") callController?.cleanup() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index c9b9544f3..f4c99f093 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -475,6 +475,9 @@ class GroupEventHandler( if (cache.justConsume(innerEvent, null, false)) { val innerNote = cache.getOrCreateNote(innerEvent.id) innerNote.event = innerEvent + + // Track the message in the Marmot group chatroom + account.marmotGroupList.addMessage(result.groupId, innerNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberDialog.kt new file mode 100644 index 000000000..70eb7c6c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/AddMemberDialog.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun AddMemberDialog( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var memberInput by remember { mutableStateOf("") } + var statusMessage by remember { mutableStateOf(null) } + var isAdding by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Member") }, + text = { + Column { + OutlinedTextField( + value = memberInput, + onValueChange = { + memberInput = it + statusMessage = null + }, + label = { Text("npub or hex pubkey") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Text( + "Enter the member's npub or hex public key. " + + "They must have published a KeyPackage (kind:30443) to be added.", + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodySmall, + ) + if (statusMessage != null) { + Text( + text = statusMessage!!, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodySmall, + color = + if (statusMessage!!.startsWith("Error")) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + isAdding = true + statusMessage = "Looking up KeyPackage..." + scope.launch(Dispatchers.IO) { + try { + val pubkey = resolvePubkey(memberInput) + if (pubkey == null) { + statusMessage = "Error: Invalid public key format" + isAdding = false + return@launch + } + statusMessage = "Fetching KeyPackage for ${pubkey.take(8)}..." + // TODO: Fetch KeyPackage from relays and call addMarmotGroupMember + // For now, show that the flow would proceed here + statusMessage = "Error: KeyPackage fetch not yet implemented. " + + "The member's KeyPackage (kind:30443) must be fetched from relays." + isAdding = false + } catch (e: Exception) { + statusMessage = "Error: ${e.message}" + isAdding = false + } + } + }, + enabled = !isAdding && memberInput.isNotBlank(), + ) { + Text(if (isAdding) "Adding..." else "Add") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} + +private fun resolvePubkey(input: String): HexKey? { + val trimmed = input.trim() + + // Try hex pubkey + if (trimmed.length == 64 && trimmed.all { it in '0'..'9' || it in 'a'..'f' }) { + return trimmed + } + + // Try bech32 (npub / nprofile) + val entity = + Nip19Parser.uriToRoute("nostr:$trimmed")?.entity + ?: Nip19Parser.uriToRoute(trimmed)?.entity + return when (entity) { + is NPub -> entity.hex + is NProfile -> entity.hex + else -> null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupDialog.kt new file mode 100644 index 000000000..d8f4bb06f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupDialog.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.random.Random + +@Composable +fun CreateGroupDialog( + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onGroupCreated: (HexKey) -> Unit, +) { + var groupName by remember { mutableStateOf("") } + var isCreating by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create Marmot Group") }, + text = { + Column { + OutlinedTextField( + value = groupName, + onValueChange = { groupName = it }, + label = { Text("Group Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Text( + "A new MLS group will be created. You can add members after.", + modifier = Modifier.padding(top = 8.dp), + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + isCreating = true + scope.launch(Dispatchers.IO) { + val nostrGroupId = Random.nextBytes(32).joinToString("") { "%02x".format(it) } + accountViewModel.createMarmotGroup(nostrGroupId) + // Set display name locally + if (groupName.isNotBlank()) { + accountViewModel.account.marmotGroupList + .getOrCreateGroup(nostrGroupId) + .displayName.value = groupName + } + onGroupCreated(nostrGroupId) + } + }, + enabled = !isCreating, + ) { + Text(if (isCreating) "Creating..." else "Create") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt new file mode 100644 index 000000000..22606699a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatScreen.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.GroupAdd +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MarmotGroupChatScreen( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val chatroom = + remember(nostrGroupId) { + accountViewModel.account.marmotGroupList.getOrCreateGroup(nostrGroupId) + } + val displayName by chatroom.displayName.collectAsStateWithLifecycle() + val memberCount by chatroom.memberCount.collectAsStateWithLifecycle() + var showAddMemberDialog by remember { mutableStateOf(false) } + + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + title = { + Column { + Text(displayName ?: "Marmot Group") + if (memberCount > 0) { + Text( + text = "$memberCount members", + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + ) + } + } + }, + actions = { + IconButton(onClick = { showAddMemberDialog = true }) { + Icon( + imageVector = Icons.Default.GroupAdd, + contentDescription = "Add Member", + ) + } + }, + ) + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it).consumeWindowInsets(it).statusBarsPadding()) { + MarmotGroupChatView( + nostrGroupId = nostrGroupId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + if (showAddMemberDialog) { + AddMemberDialog( + nostrGroupId = nostrGroupId, + accountViewModel = accountViewModel, + onDismiss = { showAddMemberDialog = false }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt new file mode 100644 index 000000000..50eeff486 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupChatView.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun MarmotGroupChatView( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: MarmotGroupFeedViewModel = + viewModel( + key = nostrGroupId + "MarmotGroupFeedViewModel", + factory = + MarmotGroupFeedViewModel.Factory( + nostrGroupId, + accountViewModel.account, + ), + ) + + WatchLifecycleAndUpdateModel(feedViewModel) + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true), + ) { + RefreshingChatroomFeedView( + feedContentState = feedViewModel.feedState, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "MarmotGroup/$nostrGroupId", + onWantsToReply = { }, + onWantsToEditDraft = { }, + ) + } + + Spacer(modifier = DoubleVertSpacer) + + MarmotGroupMessageComposer( + nostrGroupId = nostrGroupId, + accountViewModel = accountViewModel, + onMessageSent = { + feedViewModel.feedState.sendToTop() + }, + ) + } +} + +@Composable +fun MarmotGroupMessageComposer( + nostrGroupId: HexKey, + accountViewModel: AccountViewModel, + onMessageSent: suspend () -> Unit, +) { + val scope = rememberCoroutineScope() + val messageState = remember { TextFieldState() } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + OutlinedTextField( + state = messageState, + modifier = Modifier.weight(1f), + placeholder = { Text("Message") }, + lineLimits = + androidx.compose.foundation.text.input.TextFieldLineLimits + .MultiLine(maxHeightInLines = 4), + ) + IconButton( + onClick = { + val text = messageState.text.toString().trim() + if (text.isNotEmpty()) { + scope.launch(Dispatchers.IO) { + accountViewModel.sendMarmotGroupMessage(nostrGroupId, text) + messageState.clearText() + onMessageSent() + } + } + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt new file mode 100644 index 000000000..b0179bf11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupFeedViewModel.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.commons.ui.feeds.MarmotGroupFeedFilter +import com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +class MarmotGroupFeedViewModel( + nostrGroupId: HexKey, + account: Account, +) : ListChangeFeedViewModel( + MarmotGroupFeedFilter(nostrGroupId, account.marmotGroupList, account), + LocalCache, + ) { + class Factory( + val nostrGroupId: HexKey, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = MarmotGroupFeedViewModel(nostrGroupId, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt new file mode 100644 index 000000000..15ef25129 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup + +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MarmotGroupListScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var showCreateGroupDialog by remember { mutableStateOf(false) } + var groupList by remember { mutableStateOf(listOf>()) } + val scope = rememberCoroutineScope() + + // Load group list + LaunchedEffect(Unit) { + loadGroupList(accountViewModel, onUpdate = { groupList = it }) + } + + // Listen for group list changes + LaunchedEffect(Unit) { + accountViewModel.account.marmotGroupList.groupListChanges.collect { + loadGroupList(accountViewModel, onUpdate = { groupList = it }) + } + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + title = { Text("Marmot Groups") }, + actions = { + IconButton( + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.publishMarmotKeyPackage() + } + }, + ) { + Icon( + imageVector = Icons.Default.VpnKey, + contentDescription = "Publish KeyPackage", + ) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showCreateGroupDialog = true }) { + Icon(Icons.Default.Add, contentDescription = "Create Group") + } + }, + ) { padding -> + if (groupList.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "No groups yet", + style = MaterialTheme.typography.titleMedium, + ) + Text( + "Publish your KeyPackage to receive invitations.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + ) { + items(groupList, key = { it.first }) { (groupId, chatroom) -> + MarmotGroupListItem( + groupId = groupId, + chatroom = chatroom, + onClick = { + nav.nav(Route.MarmotGroupChat(groupId)) + }, + ) + HorizontalDivider() + } + } + } + } + + if (showCreateGroupDialog) { + CreateGroupDialog( + accountViewModel = accountViewModel, + onDismiss = { showCreateGroupDialog = false }, + onGroupCreated = { groupId -> + showCreateGroupDialog = false + nav.nav(Route.MarmotGroupChat(groupId)) + }, + ) + } +} + +private fun loadGroupList( + accountViewModel: AccountViewModel, + onUpdate: (List>) -> Unit, +) { + val groups = mutableListOf>() + accountViewModel.account.marmotGroupList.rooms.forEach { key, chatroom -> + groups.add(key to chatroom) + } + // Also add groups from MarmotManager that might not have messages yet + accountViewModel.account.marmotManager?.activeGroupIds()?.forEach { groupId -> + if (groups.none { it.first == groupId }) { + groups.add(groupId to accountViewModel.account.marmotGroupList.getOrCreateGroup(groupId)) + } + } + groups.sortByDescending { it.second.newestMessage?.createdAt() ?: 0L } + onUpdate(groups) +} + +@Composable +fun MarmotGroupListItem( + groupId: HexKey, + chatroom: MarmotGroupChatroom, + onClick: () -> Unit, +) { + val displayName by chatroom.displayName.collectAsStateWithLifecycle() + val newestMessage = chatroom.newestMessage + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = displayName ?: "Group ${groupId.take(8)}...", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (newestMessage != null) { + Text( + text = newestMessage.event?.content ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } else { + Text( + text = "No messages yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = "${chatroom.messages.size} msgs", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index 3e394af0d..91dbfaa23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -103,6 +103,25 @@ fun ChannelFabColumn(nav: INav) { } Spacer(modifier = Modifier.height(20.dp)) + + FloatingActionButton( + onClick = { + nav.nav(Route.MarmotGroupList) + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = "MLS\nGroups", + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 2e843ccec..932e3197f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -77,7 +77,12 @@ class ChatroomListKnownFeedFilter( .firstOrNull() } - return (privateMessages + publicChannels + ephemeralChats).sortedWith(DefaultFeedOrder) + val marmotGroups = + account.marmotGroupList.rooms.mapNotNull { _, chatroom -> + chatroom.newestMessage + } + + return (privateMessages + publicChannels + ephemeralChats + marmotGroups).sortedWith(DefaultFeedOrder) } override fun updateListWith( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index 3b1e42d4b..2fe0507b5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupList import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -101,6 +102,9 @@ interface IAccount { /** Chatroom list for private DM conversations */ val chatroomList: ChatroomList + /** Marmot MLS group chat list */ + val marmotGroupList: MarmotGroupList + /** Whether a note is acceptable (not hidden, not blocked, etc.) */ fun isAcceptable(note: Note): Boolean diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt new file mode 100644 index 000000000..d64e944ac --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.marmotGroups + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Channel.Companion.DefaultFeedOrder +import com.vitorpamplona.amethyst.commons.model.ListChange +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NotesGatherer +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import java.lang.ref.WeakReference + +/** + * Represents a Marmot MLS group chat room. + * Tracks decrypted inner messages for a single group. + * Follows the same pattern as [com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom]. + */ +@Stable +class MarmotGroupChatroom( + val nostrGroupId: HexKey, +) : NotesGatherer { + var messages: Set = setOf() + var displayName = MutableStateFlow(null) + var memberCount = MutableStateFlow(0) + var newestMessage: Note? = null + + private var changesFlow: WeakReference>> = WeakReference(null) + + fun changesFlow(): MutableSharedFlow> { + val current = changesFlow.get() + if (current != null) return current + val new = MutableSharedFlow>(0, 100, BufferOverflow.DROP_OLDEST) + changesFlow = WeakReference(new) + return new + } + + override fun removeNote(note: Note) { + removeMessageSync(note) + } + + @Synchronized + fun addMessageSync(msg: Note): Boolean { + if (msg !in messages) { + messages = messages + msg + msg.addGatherer(this) + + val createdAt = msg.createdAt() ?: 0L + if (createdAt > (newestMessage?.createdAt() ?: 0L)) { + newestMessage = msg + } + + changesFlow.get()?.tryEmit(ListChange.Addition(msg)) + return true + } + return false + } + + @Synchronized + fun removeMessageSync(msg: Note): Boolean { + if (msg in messages) { + messages = messages - msg + msg.removeGatherer(this) + + if (msg == newestMessage) { + newestMessage = messages.maxByOrNull { it.createdAt() ?: 0L } + } + + changesFlow.get()?.tryEmit(ListChange.Deletion(msg)) + return true + } + return false + } + + fun pruneMessagesToTheLatestOnly(): Set { + val sorted = messages.sortedWith(DefaultFeedOrder) + val toKeep = + sorted.take(100).toSet() + + sorted.filter { it.flowSet?.isInUse() ?: false } + + val toRemove = messages.minus(toKeep) + messages = toKeep + + changesFlow.get()?.tryEmit(ListChange.SetDeletion(toRemove)) + return toRemove + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt new file mode 100644 index 000000000..e70f72831 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupList.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.marmotGroups + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +/** + * Tracks all Marmot MLS group chatrooms for an account. + * Follows the same pattern as [com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList]. + */ +class MarmotGroupList { + var rooms = LargeCache() + private set + + private val _groupListChanges = MutableSharedFlow(0, 20, BufferOverflow.DROP_OLDEST) + val groupListChanges = _groupListChanges + + fun getOrCreateGroup(nostrGroupId: HexKey): MarmotGroupChatroom = rooms.getOrCreate(nostrGroupId) { MarmotGroupChatroom(nostrGroupId) } + + fun addMessage( + nostrGroupId: HexKey, + msg: Note, + ) { + val chatroom = getOrCreateGroup(nostrGroupId) + if (chatroom.addMessageSync(msg)) { + _groupListChanges.tryEmit(nostrGroupId) + } + } + + fun removeMessage( + nostrGroupId: HexKey, + msg: Note, + ) { + val chatroom = getOrCreateGroup(nostrGroupId) + if (chatroom.removeMessageSync(msg)) { + _groupListChanges.tryEmit(nostrGroupId) + } + } + + fun allGroupIds(): List { + val result = mutableListOf() + rooms.forEach { key, _ -> result.add(key) } + return result + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/MarmotGroupFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/MarmotGroupFeedFilter.kt new file mode 100644 index 000000000..52c29d7ba --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/MarmotGroupFeedFilter.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupList +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Feed filter for messages within a single Marmot MLS group. + * Retrieves decrypted inner events from the [MarmotGroupList]. + */ +class MarmotGroupFeedFilter( + val nostrGroupId: HexKey, + val marmotGroupList: MarmotGroupList, + val account: IAccount, +) : AdditiveFeedFilter(), + ChangesFlowFilter { + fun chatroom() = marmotGroupList.getOrCreateGroup(nostrGroupId) + + override fun changesFlow() = chatroom().changesFlow() + + override fun feedKey(): String = nostrGroupId + + override fun feed(): List = + chatroom() + .messages + .filter { account.isAcceptable(it) } + .sortedWith(DefaultFeedOrder) + + override fun applyFilter(newItems: Set): Set { + val chatroom = chatroom() + return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet() + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt new file mode 100644 index 000000000..4f0243ef0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/MarmotGroupFeedViewModel.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupList +import com.vitorpamplona.amethyst.commons.ui.feeds.MarmotGroupFeedFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * ViewModel for a single Marmot group conversation feed. + * Exposes the decrypted inner messages for the group. + */ +class MarmotGroupFeedViewModel( + val nostrGroupId: HexKey, + val account: IAccount, + marmotGroupList: MarmotGroupList, + cacheProvider: ICacheProvider, +) : ListChangeFeedViewModel( + MarmotGroupFeedFilter(nostrGroupId, marmotGroupList, account), + cacheProvider, + ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 3606c09ce..9aa168906 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -118,6 +118,9 @@ class DesktopIAccount( override val spammersHashCodes: Set = emptySet() override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex) + override val marmotGroupList = + com.vitorpamplona.amethyst.commons.model.marmotGroups + .MarmotGroupList() override val nip47SignerState: INwcSignerState = object : INwcSignerState {