feat(desktop): five profile tabs — Followers, Following, Relays, Bookmarks, Mutual

- Scrollable tab row; Followers/Following render UserSearchCard from live
  contact-list subscriptions; Relays render a new RelayRowCard from kind 10002;
  Bookmarks (kind 10003 → DesktopBookmarkFeedFilter) and Mutual (new
  DesktopMutualFeedFilter) render FeedNoteCard
- Respects the shared mute/block hidden-set via DesktopFeedViewModel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-29 09:39:56 +03:00
co-authored by Claude Opus 4.8
parent e862b3ecac
commit bc482cb6eb
3 changed files with 422 additions and 5 deletions
@@ -253,6 +253,40 @@ class DesktopProfileFeedFilter(
override fun limit(): Int = 1000
}
/**
* Mutual feed: notes authored by the logged-in user ([myPubkey]) that tag the
* viewed profile ([profilePubkey]) — "posts you wrote that mention them".
*/
class DesktopMutualFeedFilter(
private val myPubkey: HexKey,
private val profilePubkey: HexKey,
private val cache: DesktopLocalCache,
private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY },
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "mutual-$myPubkey-$profilePubkey"
private fun isMutualNote(note: Note): Boolean {
val event = note.event ?: return false
return note.author?.pubkeyHex == myPubkey &&
isFeedNote(event) &&
event.isTaggedUser(profilePubkey) &&
!note.isHiddenFor(hidden())
}
override fun feed(): List<Note> =
cache.notes
.filterIntoSet { _, note -> isMutualNote(note) }
.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isMutualNote(it) }
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 200
}
/**
* Bookmark feed: notes by ID set (from BookmarkListEvent).
*/
@@ -50,7 +50,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -82,9 +82,12 @@ import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopBookmarkFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopMutualFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -99,11 +102,14 @@ import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
import com.vitorpamplona.amethyst.desktop.ui.profile.RelayRowCard
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
@@ -111,6 +117,9 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers
@@ -252,6 +261,52 @@ fun UserProfileScreen(
} else {
kotlinx.collections.immutable.persistentListOf()
}
// Mutual — notes the logged-in user authored that tag this profile.
val mutualViewModel =
remember(pubKeyHex, account, iAccount) {
if (account != null) {
DesktopFeedViewModel(
DesktopMutualFeedFilter(account.pubKeyHex, pubKeyHex, localCache, hidden = hidden),
localCache,
iAccount?.hiddenUsers,
)
} else {
null
}
}
DisposableEffect(mutualViewModel) { onDispose { mutualViewModel?.destroy() } }
val mutualLoadedNotes =
mutualViewModel?.let { vm ->
val state by vm.feedState.feedContent.collectAsState()
if (state is FeedState.Loaded) {
val loaded by (state as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
} ?: kotlinx.collections.immutable.persistentListOf()
// Bookmarks — public bookmarks (kind 10003) resolved to notes from cache.
var bookmarkIds by remember(pubKeyHex) { mutableStateOf<Set<HexKey>>(emptySet()) }
val bookmarkViewModel =
remember(pubKeyHex) {
DesktopFeedViewModel(
DesktopBookmarkFeedFilter({ bookmarkIds }, localCache),
localCache,
)
}
DisposableEffect(bookmarkViewModel) { onDispose { bookmarkViewModel.destroy() } }
LaunchedEffect(bookmarkIds) { bookmarkViewModel.invalidateData(false) }
val bookmarkFeedState by bookmarkViewModel.feedState.feedContent.collectAsState()
val bookmarkLoadedNotes =
if (bookmarkFeedState is FeedState.Loaded) {
val loaded by (bookmarkFeedState as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
var retryTrigger by remember { mutableStateOf(0) }
// Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel
@@ -284,6 +339,12 @@ fun UserProfileScreen(
val articleEvents = remember { mutableStateListOf<LongTextNoteEvent>() }
val highlightEvents = remember { mutableStateListOf<HighlightEvent>() }
// Followers / Following user lists (pubkeys) and the profile's relay list.
val followerList = remember(pubKeyHex) { mutableStateListOf<String>() }
val followingList = remember(pubKeyHex) { mutableStateListOf<String>() }
// Each relay entry: (url, canRead, canWrite)
var relayList by remember(pubKeyHex) { mutableStateOf<List<Triple<String, Boolean, Boolean>>>(emptyList()) }
// Follow state
val followState =
remember(account) {
@@ -390,9 +451,11 @@ fun UserProfileScreen(
pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
val count = event.verifiedFollowKeySet().size
followingCount = count
localCache.cacheFollowingCount(pubKeyHex, count)
val follows = event.verifiedFollowKeySet()
followingCount = follows.size
followingList.clear()
followingList.addAll(follows)
localCache.cacheFollowingCount(pubKeyHex, follows.size)
}
},
onEose = { _, _ -> },
@@ -410,6 +473,7 @@ fun UserProfileScreen(
if (connectedRelays.isNotEmpty()) {
// Clear dedup set but keep cached followersCount visible until new data arrives
followerAuthors.clear()
followerList.clear()
SubscriptionConfig(
subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}",
@@ -425,6 +489,7 @@ fun UserProfileScreen(
onEvent = { event, _, _, _ ->
// Count unique authors who follow this user
if (followerAuthors.add(event.pubKey)) {
followerList.add(event.pubKey)
val count = followerAuthors.size
followersCount = count
localCache.cacheFollowerCount(pubKeyHex, count)
@@ -518,6 +583,103 @@ fun UserProfileScreen(
}
}
// Subscribe to the profile user's relay list (kind 10002) for the Relays tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
SubscriptionConfig(
subId = generateSubId("relays-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 1,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is AdvertisedRelayListEvent) {
val writes = event.writeRelaysNorm()?.map { it.url }?.toSet() ?: emptySet()
val reads = event.readRelaysNorm()?.map { it.url }?.toSet() ?: emptySet()
relayList = (writes + reads).sorted().map { url -> Triple(url, url in reads, url in writes) }
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to the profile user's public bookmarks list (kind 10003)
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
SubscriptionConfig(
subId = generateSubId("bmlist-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkIds =
event
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Fetch the bookmarked notes themselves once their ids are known
rememberSubscription(connectedRelays, bookmarkIds, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && bookmarkIds.isNotEmpty()) {
SubscriptionConfig(
subId = generateSubId("bmnotes-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.byIds(bookmarkIds.toList())),
relays = connectedRelays,
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
onEose = { _, _ -> },
)
} else {
null
}
}
// Fetch the logged-in user's notes that tag this profile (Mutual tab)
rememberSubscription(connectedRelays, pubKeyHex, account, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = generateSubId("mutual-${pubKeyHex.take(8)}"),
filters =
listOf(
Filter(
kinds = listOf(TextNoteEvent.KIND),
authors = listOf(account.pubKeyHex),
tags = mapOf("p" to listOf(pubKeyHex)),
limit = 200,
),
),
relays = connectedRelays,
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
onEose = { _, _ -> },
)
} else {
null
}
}
// Scroll state for detecting scroll direction
val listState = rememberLazyListState()
var showFloatingHeader by remember { mutableStateOf(false) }
@@ -936,7 +1098,7 @@ fun UserProfileScreen(
// Tabs
item(key = "tabs") {
PrimaryTabRow(selectedTabIndex = selectedTab) {
PrimaryScrollableTabRow(selectedTabIndex = selectedTab, edgePadding = 0.dp) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
Text("Notes", modifier = Modifier.padding(12.dp))
}
@@ -958,6 +1120,30 @@ fun UserProfileScreen(
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 5, onClick = { selectedTab = 5 }) {
Text(
"Followers${if (followersCount > 0) " ($followersCount)" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 6, onClick = { selectedTab = 6 }) {
Text(
"Following${if (followingCount > 0) " ($followingCount)" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 7, onClick = { selectedTab = 7 }) {
Text(
"Relays${if (relayList.isNotEmpty()) " (${relayList.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 8, onClick = { selectedTab = 8 }) {
Text("Bookmarks", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 9, onClick = { selectedTab = 9 }) {
Text("Mutual", modifier = Modifier.padding(12.dp))
}
}
}
@@ -1214,6 +1400,96 @@ fun UserProfileScreen(
}
}
}
5 -> {
if (followerList.isEmpty()) {
item(key = "no-followers") { ProfileTabMessage("No followers found yet") }
} else {
items(followerList.toList(), key = { "follower-$it" }) { pk ->
val user = remember(pk) { localCache.getOrCreateUser(pk) }
UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) })
}
}
}
6 -> {
if (followingList.isEmpty()) {
item(key = "no-following") { ProfileTabMessage("No following found yet") }
} else {
items(followingList.toList(), key = { "following-$it" }) { pk ->
val user = remember(pk) { localCache.getOrCreateUser(pk) }
UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) })
}
}
}
7 -> {
if (relayList.isEmpty()) {
item(key = "no-relays") { ProfileTabMessage("No relay list published") }
} else {
items(relayList, key = { "relay-${it.first}" }) { (url, read, write) ->
RelayRowCard(url = url, canRead = read, canWrite = write)
}
}
}
8 -> {
if (bookmarkLoadedNotes.isEmpty()) {
item(key = "no-bookmarks") { ProfileTabMessage("No public bookmarks") }
} else {
items(bookmarkLoadedNotes, key = { "bm-${it.idHex}" }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = account?.pubKeyHex,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
}
9 -> {
if (account == null) {
item(key = "mutual-login") { ProfileTabMessage("Log in to see mutual posts") }
} else if (mutualLoadedNotes.isEmpty()) {
item(key = "no-mutual") { ProfileTabMessage("You haven't posted about this user") }
} else {
items(mutualLoadedNotes, key = { "mutual-${it.idHex}" }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = account.pubKeyHex,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
}
}
}
}
@@ -1365,6 +1641,20 @@ private suspend fun unfollowUser(
}
}
@Composable
private fun ProfileTabMessage(text: String) {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun PublishedHighlightCard(
highlight: HighlightEvent,
@@ -0,0 +1,93 @@
/*
* 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.desktop.ui.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
/**
* A single relay row for the profile Relays tab: the relay URL plus a compact
* read / write role label. Mouse-first, matches the surfaceVariant card style
* used elsewhere on the profile screen.
*/
@Composable
fun RelayRowCard(
url: String,
canRead: Boolean,
canWrite: Boolean,
modifier: Modifier = Modifier,
) {
val role =
when {
canRead && canWrite -> "read / write"
canWrite -> "write"
canRead -> "read"
else -> ""
}
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
MaterialSymbols.Language,
contentDescription = null,
modifier = Modifier.width(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
url.removePrefix("wss://").removePrefix("ws://").removeSuffix("/"),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (role.isNotEmpty()) {
Text(
role,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}