From 9b7d2f7e45baa48454fa315fc428e8328c3f965b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:10:26 +0000 Subject: [PATCH] feat(nip29): render inline group links as self-loading group cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the preview render path, a `'` link now draws as a full-width group card instead of a bare link. The card renders immediately with a stable layout — robohash avatar seeded from the group id, the id as a placeholder name, and the host relay — then fills in the real name, picture and member count in place as the relay-signed metadata arrives, without changing the card's structure or height. While a card is on screen it warms the group via a lightweight, host-pinned compose subscription: keeps kind 39000/39001/39002 + roles live so the card stays current, and prefetches the newest ~15 chat messages / threads so tapping the card opens an already-populated screen. Non-preview contexts (e.g. DMs) keep the plain clickable link and issue no outbound subscription. - RelayGroupPreviewFilterAssembler / RelayGroupPreviewSubscription (registered in RelaySubscriptionsCoordinator) - RelayGroupCard + RichTextViewer preview-path branch - relay_group_open string Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj --- .../RelaySubscriptionsCoordinator.kt | 3 + .../amethyst/ui/components/RelayGroupCard.kt | 170 ++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 2 +- .../RelayGroupPreviewFilterAssembler.kt | 95 ++++++++++ .../RelayGroupPreviewSubscription.kt | 42 +++++ amethyst/src/main/res/values/strings.xml | 1 + 6 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt 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 ec63cf70ce..1751ac47cb 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 @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.Calend import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler 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.RelayGroupPreviewFilterAssembler 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 @@ -119,6 +120,7 @@ class RelaySubscriptionsCoordinator( val relayGroupDirectory = RelayGroupDirectoryFilterAssembler(client) val relayGroupRoster = RelayGroupRosterFilterAssembler(client) val relayGroupThreads = RelayGroupThreadsFilterAssembler(client) + val relayGroupPreview = RelayGroupPreviewFilterAssembler(client) val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -183,6 +185,7 @@ class RelaySubscriptionsCoordinator( relayGroupDirectory, relayGroupRoster, relayGroupThreads, + relayGroupPreview, account, accountForeground, home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt new file mode 100644 index 0000000000..9a00bf6899 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RelayGroupCard.kt @@ -0,0 +1,170 @@ +/* + * 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.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +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.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupPreviewSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink + +/** + * Renders a NIP-29 group invite link (`'[?code=]`) as a full-width + * card. It draws immediately with a stable layout — a robohash avatar seeded from the group + * id, the id as a placeholder name, and the host relay — then fills in the real name, picture + * and member count reactively as the relay-signed metadata arrives, WITHOUT changing the + * card's structure or height. While on screen it also warms the group (live metadata + + * recent messages/threads) so tapping it opens an already-populated screen. + */ +@Composable +fun RelayGroupCard( + linkText: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val invite = remember(linkText) { GroupInviteLink.parse(linkText) } + if (invite == null) { + // Detection produced this, so it should always parse; degrade to a plain link if not. + ClickableRelayGroupLink(linkText, nav) + return + } + + val groupId = remember(invite) { invite.toGroupId() } + + LoadRelayGroupChannel(groupId, accountViewModel) { channel -> + RelayGroupCardContent(channel, accountViewModel, nav) + } +} + +@Composable +private fun RelayGroupCardContent( + baseChannel: RelayGroupChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + RelayGroupPreviewSubscription(baseChannel, accountViewModel.dataSources().relayGroupPreview, accountViewModel) + + // Recompose in place when the relay-signed metadata / roster changes. + val channelState by baseChannel + .flow() + .metadata.stateFlow + .collectAsStateWithLifecycle() + val channel = channelState.channel as? RelayGroupChannel ?: baseChannel + + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val memberCount = channel.memberCount() + + // Relay host is known immediately; the member count fills in when the roster loads. + // Both render on the same single line so nothing reflows as the count arrives. + val relayLabel = channel.groupId.relayUrl.displayUrl() + val subtitle = + if (memberCount > 0) { + "$relayLabel · ${pluralStringResource(R.plurals.relay_group_member_count, memberCount, memberCount)}" + } else { + relayLabel + } + + OutlinedCard( + onClick = { nav.nav(routeFor(channel)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = channel.groupId.id, + model = channel.profilePicture(), + contentDescription = channel.toBestDisplayName(), + modifier = Modifier.size(48.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + + Column(Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (channel.isPrivate()) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = channel.toBestDisplayName(), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = stringRes(R.string.relay_group_open), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 79222f213d..183a5395ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -572,7 +572,7 @@ private fun RenderWordWithPreview( is RegularTextSegment -> Text(word.segmentText) is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) - is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav) + is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav) is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt new file mode 100644 index 0000000000..cfcac1c20f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewFilterAssembler.kt @@ -0,0 +1,95 @@ +/* + * 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.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.subassemblies.filterMetadataToRelayGroup +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.nip29RelayGroups.tags.GroupIdTag +import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent + +/** One on-screen group card's request to warm a single group. */ +class RelayGroupPreviewQueryState( + val channel: RelayGroupChannel, +) + +/** Newest content kinds we prefetch so opening the card lands on populated screens. */ +private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = + listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND) + +/** How many recent events to pull ahead of a tap — enough to fill the first screen, no more. */ +private const val RELAY_GROUP_PREVIEW_LIMIT = 15 + +/** + * Warms a NIP-29 group referenced inline (a group-link card) without opening it: + * keeps the relay-signed metadata (name/picture/about + admins/members/roles) live + * so the card fills in and stays current, and prefetches the newest handful of chat + * messages and threads so tapping the card lands on already-cached content. Both are + * pinned to the group's single host relay. Active only while the card is on-screen. + */ +class RelayGroupPreviewFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayGroupPreviewSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class RelayGroupPreviewSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayGroupPreviewQueryState, + since: SincePerRelayMap?, + ): List { + val groupId = key.channel.groupId + return filterMetadataToRelayGroup(key.channel, since) + + RelayBasedFilter( + relay = groupId.relayUrl, + filter = + Filter( + kinds = RELAY_GROUP_PREVIEW_CONTENT_KINDS, + tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)), + limit = RELAY_GROUP_PREVIEW_LIMIT, + since = since?.get(groupId.relayUrl)?.time, + ), + ) + } + + override fun id(key: RelayGroupPreviewQueryState) = key.channel.groupId +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.kt new file mode 100644 index 0000000000..bc1a4f8cdf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupPreviewSubscription.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 an inline group-link card to warm its metadata + recent content. */ +@Composable +fun RelayGroupPreviewSubscription( + channel: RelayGroupChannel, + dataSource: RelayGroupPreviewFilterAssembler, + accountViewModel: AccountViewModel, +) { + val state = + remember(channel.groupId) { + RelayGroupPreviewQueryState(channel) + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 87473009c7..89b4b1797c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1966,6 +1966,7 @@ Members Edit group Threads + Open group No threads yet. Start one with the + button. New thread Untitled