diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index eb796e9eeb..6e9c168b03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -203,6 +203,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent @@ -262,6 +263,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent @@ -1516,6 +1518,16 @@ class Account( return id } + /** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */ + suspend fun postRelayGroupThread( + channel: RelayGroupChannel, + title: String, + body: String, + ) { + val template = ThreadEvent.build(body, title) { hTag(channel.groupId.id) } + signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() } + } + /** Mint a kind 9009 invite code for the group (admin/moderator only). */ suspend fun createRelayGroupInvite( channel: RelayGroupChannel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 54f30ade19..76d0b18c1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -259,6 +259,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent @@ -1821,6 +1822,26 @@ object LocalCache : ILocalCache, ICacheProvider { } } + /** + * Same routing as [attachToRelayGroupIfScoped] but for kind-11 threads, which + * are kept in a separate collection from the chat timeline so the two content + * types don't mix in one feed. + */ + private fun attachThreadToRelayGroupIfScoped( + event: Event, + relay: NormalizedRelayUrl?, + ) { + val groupId = event.groupId() ?: return + val note = getOrCreateNote(event.id) + if (note.event == null) return + + if (relay != null) { + getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addThread(note) + } else { + relayGroupChannels.filter { key, _ -> key.id == groupId }.forEach { it.addThread(note) } + } + } + fun consume( event: LiveActivitiesChatMessageEvent, relay: NormalizedRelayUrl?, @@ -4031,6 +4052,12 @@ object LocalCache : ILocalCache, ICacheProvider { } } + is ThreadEvent -> { + consumeRegularEvent(event, relay, wasVerified).also { + attachThreadToRelayGroupIfScoped(event, relay) + } + } + is PollResponseEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 7902367100..ec63cf70ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource. import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupDirectoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupRosterFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler @@ -117,6 +118,7 @@ class RelaySubscriptionsCoordinator( val channel = ChannelFilterAssembler(client) val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client) val relayGroupRoster = RelayGroupRosterFilterAssembler(client) + val relayGroupThreads = RelayGroupThreadsFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -180,6 +182,7 @@ class RelaySubscriptionsCoordinator( listOf( relayGroupDirectory, relayGroupRoster, + relayGroupThreads, account, accountForeground, home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 44401ca49e..3c9474a0cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChannelListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupsHomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen @@ -589,6 +590,15 @@ fun BuildNavigation( ) } + composableFromEndArgs { + RelayGroupThreadsScreen( + id = it.id, + relayUrl = it.relayUrl, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { RelayGroupBrowseScreen( accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 4554235a43..9b08794a4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -653,6 +653,11 @@ sealed class Route { val relayUrl: String, ) : Route() + @Serializable data class RelayGroupThreads( + val id: String, + val relayUrl: String, + ) : Route() + @Serializable object RelayGroups : Route() @Serializable object RelayGroupBrowse : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 76b498def2..9724dc6313 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1452,6 +1452,12 @@ class AccountViewModel( code: String, ) = launchSigner { account.createRelayGroupInvite(channel, code) } + fun postRelayGroupThread( + channel: RelayGroupChannel, + title: String, + body: String, + ) = launchSigner { account.postRelayGroupThread(channel, title, body) } + fun removeRelayGroupUser( channel: RelayGroupChannel, pubkey: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt new file mode 100644 index 0000000000..5718b36abd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/NewRelayGroupThreadDialog.kt @@ -0,0 +1,91 @@ +/* + * 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.publicChannels.relayGroup + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Start a new kind-11 thread in the group: a required title plus a body. Publishes + * to the group's host relay (the relay accepts it only from a member). + */ +@Composable +fun NewRelayGroupThreadDialog( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var title by remember { mutableStateOf("") } + var body by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.relay_group_thread_new)) }, + text = { + Column { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_thread_title_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text(stringRes(R.string.relay_group_thread_body_label)) }, + modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp).padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton( + enabled = title.isNotBlank() && body.isNotBlank(), + onClick = { + accountViewModel.postRelayGroupThread(channel, title.trim(), body.trim()) + onDismiss() + }, + ) { + Text(stringRes(R.string.relay_group_thread_post)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt new file mode 100644 index 0000000000..419eaac887 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupThreadsScreen.kt @@ -0,0 +1,221 @@ +/* + * 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.publicChannels.relayGroup + +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.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.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadsSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent + +/** + * A group's forum-style threads (kind 11) — the secondary content type kept out of + * the kind-9 chat feed. Streams the group's threads + their comments via + * [RelayGroupThreadsSubscription]; tapping a thread opens the generic thread view + * ([Route.Note]) with its comment tree. Members can start a new thread. + */ +@Composable +fun RelayGroupThreadsScreen( + id: HexKey, + relayUrl: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relay = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) } ?: return + val channelId = remember(id, relay) { GroupId(id, relay) } + + LoadRelayGroupChannel(channelId, accountViewModel) { channel -> + RelayGroupThreads(channel, accountViewModel, nav) + } +} + +@Composable +private fun RelayGroupThreads( + channel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayGroupThreadsSubscription(channel, accountViewModel.dataSources().relayGroupThreads, accountViewModel) + + val threads by channel.threads.collectAsStateWithLifecycle() + var showCompose by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopBarExtensibleWithBackButton( + title = { + Column { + Text( + text = stringRes(R.string.relay_group_threads_title), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = channel.toBestDisplayName(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + popBack = nav::popBack, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showCompose = true }) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.relay_group_thread_new), + modifier = Modifier.size(24.dp), + ) + } + }, + ) { padding -> + if (threads.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + text = stringRes(R.string.relay_group_threads_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(32.dp), + ) + } + } else { + LazyColumn(modifier = Modifier.padding(padding)) { + items(threads, key = { it.idHex }) { thread -> + ThreadRow(thread, accountViewModel, nav) { nav.nav(Route.Note(thread.idHex)) } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + + if (showCompose) { + NewRelayGroupThreadDialog(channel, accountViewModel) { showCompose = false } + } +} + +@Composable +private fun ThreadRow( + thread: Note, + accountViewModel: AccountViewModel, + nav: INav, + onClick: () -> Unit, +) { + val event = thread.event as? ThreadEvent + val title = event?.title()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.relay_group_thread_untitled) + val preview = + event + ?.content + ?.replace('\n', ' ') + ?.trim() + .orEmpty() + val replyCount = thread.replies.size + val author = thread.author + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (author != null) { + UserPicture(author, Size35dp, accountViewModel = accountViewModel, nav = nav) + } + Column(Modifier.weight(1f)) { + Text( + text = title, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (preview.isNotEmpty()) { + Text( + text = preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (author != null) { + UsernameDisplay(author, accountViewModel = accountViewModel) + } + Icon( + symbol = MaterialSymbols.Chat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(13.dp), + ) + Text( + text = "$replyCount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt index 0d464c76af..d7d615e020 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupTopBar.kt @@ -141,6 +141,16 @@ fun RelayGroupTopBar( } }, actions = { + IconButton(onClick = { + nav.nav(Route.RelayGroupThreads(channel.groupId.id, channel.groupId.relayUrl.url)) + }) { + Icon( + symbol = MaterialSymbols.Forum, + contentDescription = stringRes(R.string.relay_group_threads_title), + modifier = Modifier.size(20.dp), + ) + } + val naddr = channel.toNAddr() if (naddr != null) { val context = LocalContext.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt new file mode 100644 index 0000000000..c24145633e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsFilterAssembler.kt @@ -0,0 +1,84 @@ +/* + * 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.publicChannels.relayGroup.datasource + +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent + +/** One threads-screen's request for a single group's kind-11 threads. */ +class RelayGroupThreadsQueryState( + val channel: RelayGroupChannel, +) + +/** + * The forum content of a NIP-29 group: kind-11 thread roots plus their kind-1111 + * comment trees, both scoped by the group's `h` tag and pinned to the host relay. + * Active only while a group's Threads screen is open (threads are secondary to the + * kind-9 chat, so we don't pay for them until asked). Fetching the comments here + * too means opening a thread from the list has its replies already cached. + */ +class RelayGroupThreadsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayGroupThreadsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class RelayGroupThreadsSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupThreadsQueryState, + since: SincePerRelayMap?, + ): List { + val groupId = key.channel.groupId + return listOf( + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND), + tags = mapOf("h" to listOf(groupId.id)), + since = since?.get(groupId.relayUrl)?.time, + ), + ), + ) + } + + override fun id(key: RelayGroupThreadsQueryState) = key.channel.groupId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt new file mode 100644 index 0000000000..c462bc63bd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupThreadsSubscription.kt @@ -0,0 +1,42 @@ +/* + * 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.publicChannels.relayGroup.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** Mount on a group's Threads screen to stream its kind-11 threads + 1111 comments. */ +@Composable +fun RelayGroupThreadsSubscription( + channel: RelayGroupChannel, + dataSource: RelayGroupThreadsFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(channel.groupId) { + RelayGroupThreadsQueryState(channel) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 648650f782..87473009c7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1965,6 +1965,13 @@ Save Members Edit group + Threads + No threads yet. Start one with the + button. + New thread + Untitled + Title + Write something… + Post Find groups Browse the groups hosted on a relay. Paste a relay address, or pick one below. Relay address diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt index 442ccaf47c..e504a7fe66 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannel.kt @@ -30,6 +30,9 @@ import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupAdminTag +import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * A NIP-29 relay-based group ("channel" in Discord terms). Unlike a NIP-C7 @@ -65,6 +68,31 @@ class RelayGroupChannel( private set private var adminsUpdatedAt: Long = 0 + /** + * Kind-11 threads (forum-style posts) scoped to this group, kept apart from the + * kind-9 chat [Channel.notes] so the chat feed stays clean. Surfaced through the + * group's "Threads" view; each thread's own comment tree (kind 1111) is loaded + * on demand by the thread-detail screen. + */ + private val threadNotes = LargeCache() + private val _threads = MutableStateFlow>(emptyList()) + val threads: StateFlow> = _threads + + /** Add a kind-11 thread note; newest-first snapshot is re-published to [threads]. */ + fun addThread(note: Note) { + if (threadNotes.containsKey(note.idHex)) return + threadNotes.put(note.idHex, note) + _threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L } + } + + fun removeThread(note: Note) { + if (!threadNotes.containsKey(note.idHex)) return + threadNotes.remove(note.idHex) + _threads.value = threadNotes.values().sortedByDescending { it.createdAt() ?: 0L } + } + + fun threadCount(): Int = threadNotes.size() + /** A relay group lives on exactly one relay: its host. */ override fun relays() = setOf(groupId.relayUrl) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt index 4f84c6db22..d8a56e5a27 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip29RelayGroups/RelayGroupChannelTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.nip29RelayGroups +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent @@ -178,4 +179,23 @@ class RelayGroupChannelTest { fun displayNameFallsBackToGroupIdWithoutMetadata() { assertEquals(gid, channel().toBestDisplayName()) } + + @Test + fun threadsDedupePublishToFlowAndRemove() { + val c = channel() + val t1 = Note("11".repeat(32)) + val t2 = Note("22".repeat(32)) + + c.addThread(t1) + c.addThread(t2) + c.addThread(t1) // duplicate id — must not double-count + + assertEquals(2, c.threadCount()) + assertEquals(2, c.threads.value.size) + assertTrue(c.threads.value.any { it.idHex == t1.idHex }) + + c.removeThread(t1) + assertEquals(1, c.threadCount()) + assertTrue(c.threads.value.none { it.idHex == t1.idHex }) + } }