mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+3
@@ -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()
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+91
@@ -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)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
+221
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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
|
||||
|
||||
+84
@@ -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
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+28
@@ -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
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user