feat: Phase 7 — Marmot group chat UI & ViewModel layer

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
This commit is contained in:
Claude
2026-04-05 20:38:23 +00:00
parent e1304ddcab
commit 7ed036bbaa
19 changed files with 1137 additions and 1 deletions
@@ -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)
@@ -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<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, accountViewModel, nav) }
composableFromEnd<Route.MarmotGroupList> { MarmotGroupListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MarmotGroupChat> { MarmotGroupChatScreen(it.nostrGroupId, accountViewModel, nav) }
composableFromEndArgs<Route.PublicChatChannel> {
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
}
@@ -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,
@@ -1440,6 +1440,30 @@ class AccountViewModel(
}
}
// --- Marmot Group Messaging ---
suspend fun sendMarmotGroupMessage(
nostrGroupId: String,
text: String,
) {
val template =
com.vitorpamplona.quartz.nip01Core.signers.eventTemplate<com.vitorpamplona.quartz.nip01Core.core.Event>(
kind = 9,
description = text,
)
val innerEvent = account.signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(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()
@@ -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)
}
}
@@ -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<String?>(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
}
}
@@ -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")
}
},
)
}
@@ -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 },
)
}
}
@@ -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",
)
}
}
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T = MarmotGroupFeedViewModel(nostrGroupId, account) as T
}
}
@@ -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<Pair<HexKey, MarmotGroupChatroom>>()) }
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<Pair<HexKey, MarmotGroupChatroom>>) -> Unit,
) {
val groups = mutableListOf<Pair<HexKey, MarmotGroupChatroom>>()
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,
)
}
}
}
@@ -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))
}
}
@@ -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(
@@ -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
@@ -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<Note> = setOf()
var displayName = MutableStateFlow<String?>(null)
var memberCount = MutableStateFlow(0)
var newestMessage: Note? = null
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(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<Note> {
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<Note>(toRemove))
return toRemove
}
}
@@ -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<HexKey, MarmotGroupChatroom>()
private set
private val _groupListChanges = MutableSharedFlow<HexKey>(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<HexKey> {
val result = mutableListOf<HexKey>()
rooms.forEach { key, _ -> result.add(key) }
return result
}
}
@@ -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<Note>(),
ChangesFlowFilter<Note> {
fun chatroom() = marmotGroupList.getOrCreateGroup(nostrGroupId)
override fun changesFlow() = chatroom().changesFlow()
override fun feedKey(): String = nostrGroupId
override fun feed(): List<Note> =
chatroom()
.messages
.filter { account.isAcceptable(it) }
.sortedWith(DefaultFeedOrder)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val chatroom = chatroom()
return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet()
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -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,
)
@@ -118,6 +118,9 @@ class DesktopIAccount(
override val spammersHashCodes: Set<Int> = emptySet()
override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex)
override val marmotGroupList =
com.vitorpamplona.amethyst.commons.model.marmotGroups
.MarmotGroupList()
override val nip47SignerState: INwcSignerState =
object : INwcSignerState {