feat(nip29): render inline group links as self-loading group cards

In the preview render path, a `<relay>'<groupId>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 17:10:26 +00:00
parent f9b24156e8
commit 9b7d2f7e45
6 changed files with 312 additions and 1 deletions
@@ -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,
@@ -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 (`<relay>'<groupId>[?code=<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),
)
}
}
}
@@ -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)
}
@@ -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<RelayGroupPreviewQueryState>() {
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<RelayGroupPreviewQueryState>,
) : PerUniqueIdEoseManager<RelayGroupPreviewQueryState, GroupId>(client, allKeys) {
override fun updateFilter(
key: RelayGroupPreviewQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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
}
@@ -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)
}
+1
View File
@@ -1966,6 +1966,7 @@
<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_open">Open group</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>