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:
@@ -376,6 +376,9 @@ class Account(
|
|||||||
val draftsDecryptionCache = DraftEventCache(signer)
|
val draftsDecryptionCache = DraftEventCache(signer)
|
||||||
|
|
||||||
override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
|
override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
|
||||||
|
override val marmotGroupList =
|
||||||
|
com.vitorpamplona.amethyst.commons.model.marmotGroups
|
||||||
|
.MarmotGroupList()
|
||||||
|
|
||||||
val newNotesPreProcessor = EventProcessor(this, cache)
|
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.ArticleBookmarkListManagementScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
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.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.ChatroomByAuthorScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen
|
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.Room> { ChatroomScreen(it.toKey(), it.message, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.RoomByAuthor> { ChatroomByAuthorScreen(it.id, null, 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> {
|
composableFromEndArgs<Route.PublicChatChannel> {
|
||||||
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
PublicChatChannelScreen(it.id, it.draftId, it.replyTo, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ sealed class Route {
|
|||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
) : Route()
|
) : Route()
|
||||||
|
|
||||||
|
@Serializable object MarmotGroupList : Route()
|
||||||
|
|
||||||
|
@Serializable data class MarmotGroupChat(
|
||||||
|
val nostrGroupId: String,
|
||||||
|
) : Route()
|
||||||
|
|
||||||
@Serializable data class NewGroupDM(
|
@Serializable data class NewGroupDM(
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val attachment: String? = null,
|
val attachment: String? = null,
|
||||||
|
|||||||
+24
@@ -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() {
|
override fun onCleared() {
|
||||||
Log.d("AccountViewModel", "onCleared")
|
Log.d("AccountViewModel", "onCleared")
|
||||||
callController?.cleanup()
|
callController?.cleanup()
|
||||||
|
|||||||
+3
@@ -475,6 +475,9 @@ class GroupEventHandler(
|
|||||||
if (cache.justConsume(innerEvent, null, false)) {
|
if (cache.justConsume(innerEvent, null, false)) {
|
||||||
val innerNote = cache.getOrCreateNote(innerEvent.id)
|
val innerNote = cache.getOrCreateNote(innerEvent.id)
|
||||||
innerNote.event = innerEvent
|
innerNote.event = innerEvent
|
||||||
|
|
||||||
|
// Track the message in the Marmot group chatroom
|
||||||
|
account.marmotGroupList.addMessage(result.groupId, innerNote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+113
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+241
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -103,6 +103,25 @@ fun ChannelFabColumn(nav: INav) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -77,7 +77,12 @@ class ChatroomListKnownFeedFilter(
|
|||||||
.firstOrNull()
|
.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(
|
override fun updateListWith(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.model
|
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.amethyst.commons.model.privateChats.ChatroomList
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
@@ -101,6 +102,9 @@ interface IAccount {
|
|||||||
/** Chatroom list for private DM conversations */
|
/** Chatroom list for private DM conversations */
|
||||||
val chatroomList: ChatroomList
|
val chatroomList: ChatroomList
|
||||||
|
|
||||||
|
/** Marmot MLS group chat list */
|
||||||
|
val marmotGroupList: MarmotGroupList
|
||||||
|
|
||||||
/** Whether a note is acceptable (not hidden, not blocked, etc.) */
|
/** Whether a note is acceptable (not hidden, not blocked, etc.) */
|
||||||
fun isAcceptable(note: Note): Boolean
|
fun isAcceptable(note: Note): Boolean
|
||||||
|
|
||||||
|
|||||||
+107
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -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)
|
||||||
|
}
|
||||||
+41
@@ -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,
|
||||||
|
)
|
||||||
+3
@@ -118,6 +118,9 @@ class DesktopIAccount(
|
|||||||
override val spammersHashCodes: Set<Int> = emptySet()
|
override val spammersHashCodes: Set<Int> = emptySet()
|
||||||
|
|
||||||
override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex)
|
override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex)
|
||||||
|
override val marmotGroupList =
|
||||||
|
com.vitorpamplona.amethyst.commons.model.marmotGroups
|
||||||
|
.MarmotGroupList()
|
||||||
|
|
||||||
override val nip47SignerState: INwcSignerState =
|
override val nip47SignerState: INwcSignerState =
|
||||||
object : INwcSignerState {
|
object : INwcSignerState {
|
||||||
|
|||||||
Reference in New Issue
Block a user