feat: relay-group threads (kind 11) as a secondary view

Closes the Flotilla interop gap where NIP-29 groups also carry kind-11
"threads" (forum-style posts) that Amethyst's chat-only room view dropped.
Threads are a secondary surface, kept out of the kind-9 chat feed — a
Threads button in the group top bar, mirroring Discord/Slack.

- RelayGroupChannel: a separate `threads` collection (kind-11 notes) with a
  reactive StateFlow, distinct from the chat timeline.
- LocalCache: attach kind-11 to channel.threads (same host-pinned + own-send
  null-relay routing as chat messages).
- Host-pinned RelayGroupThreadsFilterAssembler (kinds 11 + 1111 scoped by
  `#h`), active only while a group's Threads screen is open; fetching the
  1111 comments too means opening a thread has its replies already cached.
- RelayGroupThreadsScreen lists a group's threads (title, author, preview,
  reply count) and opens each in the existing thread view (Route.Note) for
  the full comment tree — no bespoke detail screen needed. Members start a
  thread via NewRelayGroupThreadDialog → Account.postRelayGroupThread
  (ThreadEvent.build(body, title){ hTag }).
- Route.RelayGroupThreads + a Forum icon in the group top bar.

Verified: kind-11 with h + title round-trips and is queryable by #h against
an embedded relay (the exact filter Flotilla uses); commons threads-collection
test (dedup/flow/remove) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 13:15:23 +00:00
parent 633903b5c1
commit 2fb6e3cb2f
14 changed files with 566 additions and 0 deletions
@@ -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,
@@ -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)
}
@@ -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,
@@ -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<Route.RelayGroupThreads> {
RelayGroupThreadsScreen(
id = it.id,
relayUrl = it.relayUrl,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroupBrowse> {
RelayGroupBrowseScreen(
accountViewModel = accountViewModel,
@@ -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()
@@ -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,
@@ -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)) }
},
)
}
@@ -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,
)
}
}
}
}
@@ -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
@@ -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<RelayGroupThreadsQueryState>() {
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<RelayGroupThreadsQueryState>,
) : PerUniqueIdEoseManager<RelayGroupThreadsQueryState, GroupId>(client, allKeys) {
override fun updateFilter(
key: RelayGroupThreadsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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
}
@@ -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)
}
+7
View File
@@ -1965,6 +1965,13 @@
<string name="relay_group_edit_confirm">Save</string>
<string name="relay_group_menu_members">Members</string>
<string name="relay_group_menu_edit">Edit group</string>
<string name="relay_group_threads_title">Threads</string>
<string name="relay_group_threads_empty">No threads yet. Start one with the + button.</string>
<string name="relay_group_thread_new">New thread</string>
<string name="relay_group_thread_untitled">Untitled</string>
<string name="relay_group_thread_title_label">Title</string>
<string name="relay_group_thread_body_label">Write something…</string>
<string name="relay_group_thread_post">Post</string>
<string name="relay_group_browse_title">Find groups</string>
<string name="relay_group_browse_description">Browse the groups hosted on a relay. Paste a relay address, or pick one below.</string>
<string name="relay_group_browse_relay_label">Relay address</string>
@@ -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<HexKey, Note>()
private val _threads = MutableStateFlow<List<Note>>(emptyList())
val threads: StateFlow<List<Note>> = _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)
@@ -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 })
}
}