Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
6c1d0158ce feat: show NIP-32 hashtag labels from follows on posts
Posts now display a '#tag added by <user>' row (tag icon + clickable
hashtag + labeler attribution) in feeds and the thread view whenever a
followed user - or the logged-in user - attached a hashtag to the post
via a kind 1985 label event. Hashtags the author already used are
skipped, and labels from non-follows are never shown.

The labels are fetched by a new filter family in the per-note
EventWatcherSubAssembler: kind 1985 requests routed through the outbox
model (account.followsPerRelay), so each follow's labels are requested
from that follow's own outbox relays with the filter's authors
restricted to the follows that actually write there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjCyWTm3FqEMYiNUH8pEyE
2026-07-14 21:07:48 +00:00
6 changed files with 218 additions and 1 deletions

View File

@@ -265,6 +265,19 @@ fun observeNoteZaps(
return flow.collectAsStateWithLifecycle()
}
@Composable
fun observeNoteLabels(
note: Note,
accountViewModel: AccountViewModel,
): State<NoteState?> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow = remember(note) { note.flow().labels.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@Composable
fun observeNoteReposts(
note: Note,

View File

@@ -58,6 +58,8 @@ class EventWatcherSubAssembler(
return null
}
val account = keys.first().account
lastNotesOnFilter = keys.map { it.note }
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
@@ -65,10 +67,12 @@ class EventWatcherSubAssembler(
if (group.isNotEmpty()) {
val addressables = group.filterIsInstance<AddressableNote>()
val events = group.mapNotNull { if (it !is AddressableNote) it else null }
val eventEoses = findMinimumEOSEs(events, latestEOSEs)
listOfNotNull(
filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs)),
filterRepliesAndReactionsToNotes(events, eventEoses),
filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs)),
filterLabelsByFollowsToNotes(account, events, eventEoses),
).flatten()
} else {
emptyList()

View File

@@ -0,0 +1,60 @@
/*
* 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.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip32Labeling.LabelEvent
/**
* NIP-32: watches for kind 1985 label events that tag the notes on screen, restricted to
* the accounts the user follows and routed through the outbox model: each follow's labels
* are requested from THAT follow's outbox relays, with each relay's filter limited to the
* follows that actually write there. Labels from non-follows are never requested.
*/
fun filterLabelsByFollowsToNotes(
account: Account,
notes: List<Note>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (notes.isEmpty()) return emptyList()
val noteIds = notes.map { it.idHex }.sorted()
return account.followsPerRelay.value.mapNotNull { (relay, authors) ->
if (authors.isEmpty()) return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(LabelEvent.KIND),
authors = authors.sorted(),
tags = mapOf("e" to noteIds),
since = since?.get(relay)?.time,
// Labels are rare; this only bounds pathological cases.
limit = 500,
),
)
}
}

View File

@@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowLabelsInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
@@ -788,6 +789,8 @@ fun InnerNoteWithReactions(
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(noteEvent, false, accountViewModel, nav)
}
DisplayFollowLabelsInPost(baseNote, accountViewModel, nav)
}
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)

View File

@@ -0,0 +1,134 @@
/*
* 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.note.elements
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
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.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteLabels
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
/**
* NIP-32: shows the hashtags that people the user follows (or the user themselves) attached
* to this post via kind 1985 label events, as "🏷 #tag added by <user>" rows. Labels from
* non-follows are ignored, and hashtags the author already used in the post are skipped
* (they render with the post itself). Renders nothing when there is nothing to show.
*/
@Composable
fun DisplayFollowLabelsInPost(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val labelState by observeNoteLabels(baseNote, accountViewModel)
val labels = labelState?.note?.labels ?: return
if (labels.isEmpty()) return
val follows = accountViewModel.account.followingKeySet()
val loggedUser = accountViewModel.account.userProfile().pubkeyHex
val visibleLabels =
labels.mapNotNull { (tag, labelNotes) ->
// The author already used the hashtag — it renders with the post itself.
if (baseNote.event?.isTaggedHash(tag) == true) return@mapNotNull null
val labeler =
labelNotes
.firstOrNull {
val pubkey = it.author?.pubkeyHex
pubkey != null && (pubkey == loggedUser || pubkey in follows)
}?.author ?: return@mapNotNull null
tag to labeler
}
if (visibleLabels.isEmpty()) return
Column(modifier) {
Spacer(HalfVertSpacer)
visibleLabels.forEach { (tag, labeler) ->
FollowLabelRow(tag, labeler, accountViewModel, nav)
}
}
}
@Composable
private fun FollowLabelRow(
tag: String,
labeler: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = MaterialSymbols.Tag,
contentDescription = null,
modifier = Size16Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
Spacer(StdHorzSpacer)
ClickableTextColor(
"#$tag",
linkColor = MaterialTheme.colorScheme.lessImportantLink,
maxLines = 1,
) {
nav.nav(Route.Hashtag(tag))
}
Spacer(StdHorzSpacer)
Text(
stringRes(R.string.hashtag_label_added_by),
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
Spacer(StdHorzSpacer)
UsernameDisplay(
labeler,
fontWeight = FontWeight.Bold,
textColor = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
)
}
}

View File

@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowLabelsInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
@@ -1135,6 +1136,8 @@ private fun FullBleedNoteCompose(
}
}
DisplayFollowLabelsInPost(baseNote, accountViewModel, nav, PaddingHorizontal12Modifier)
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
ReactionsRow(baseNote, showReactionDetail = true, addPadding = true, editState = editState, accountViewModel = accountViewModel, nav = nav)