From d57f8c18c673663104604e718973a0116a8ea86b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 20:32:43 +0000 Subject: [PATCH 1/3] feat: first-class NIP-32 hashtag labels on posts and in the hashtag feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let users tag any post with a hashtag via a NIP-32 kind 1985 label event (using the `#t` tag-association namespace), and surface follow-labeled posts in the hashtag feed. quartz: - LabelEvent.buildHashtagLabel() + HASHTAG_NAMESPACE ("#t") and hashtagAssociations() to build/extract hashtag-association labels. commons: - Note now carries a `labels` reverse-reference map (hashtag -> labeler notes) with addLabel/removeLabel and a NoteFlowSet.labels flow, mirroring reactions/reports. amethyst: - LocalCache consumes LabelEvent, attaching hashtag labels to their target notes and re-notifying feed observers for already-cached targets. - Account.createLabelHashtagEvent/labelHashtag/consumeLabelEvent and AccountViewModel.labelWithHashtag (tracked + direct broadcast). - Overflow "⋯" menu gains an "Add hashtag" action backed by a new AddHashtagLabelDialog. - HashtagFeedFilter also accepts posts a followed user labeled with the hashtag; a new label sub-assembler subscribes to kind 1985 by `#l` and fetches missing label targets. - Hashtag feed shows an attribution banner ("#tag added by @user") above follow-labeled posts via a custom RefresheableFeedView onLoaded. https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX --- .../vitorpamplona/amethyst/model/Account.kt | 49 ++++++ .../amethyst/model/LocalCache.kt | 42 +++++ .../ui/note/elements/AddHashtagLabelDialog.kt | 103 +++++++++++ .../amethyst/ui/note/elements/DropDownMenu.kt | 15 ++ .../amethyst/ui/screen/FeedView.kt | 14 +- .../ui/screen/loggedIn/AccountViewModel.kt | 10 ++ .../loggedIn/hashtag/HashtagFeedLoaded.kt | 160 ++++++++++++++++++ .../screen/loggedIn/hashtag/HashtagScreen.kt | 3 + .../loggedIn/hashtag/dal/HashtagFeedFilter.kt | 31 +++- .../hashtag/datasource/FilterHashtagLabels.kt | 96 +++++++++++ .../datasource/HashtagFilterAssembler.kt | 3 + .../HashtagFilterAssemblerSubscription.kt | 6 +- .../datasource/HashtagLabelSubAssembler.kt | 46 +++++ amethyst/src/main/res/values/strings.xml | 7 + .../amethyst/commons/model/Note.kt | 45 ++++- .../quartz/nip32Labeling/LabelEvent.kt | 36 ++++ .../quartz/nip32Labeling/LabelEventTest.kt | 45 +++++ 17 files changed, 705 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagLabelSubAssembler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index af22c00ff3..2fffcdc791 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -192,6 +192,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile 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.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -741,6 +742,54 @@ class Account( cache.justConsumeMyOwnEvent(event) } + /** + * NIP-32: tags [note] with [hashtag] by publishing a kind 1985 label event using the + * `#t` tag-association namespace. Fire-and-forget; signs and broadcasts immediately. + */ + suspend fun labelHashtag( + note: Note, + hashtag: String, + ) { + createLabelHashtagEvent(note, hashtag)?.let { (event, relays) -> + cache.justConsumeMyOwnEvent(event) + client.publish(event, relays) + } + } + + /** + * Builds and signs a NIP-32 hashtag label event for [note] without sending it. + * Returns the signed event and target relays for tracked broadcasting, or null if + * the account can't write or the note has no underlying event. + */ + suspend fun createLabelHashtagEvent( + note: Note, + hashtag: String, + ): Pair>? { + if (!signer.isWriteable()) return null + + val eventHint = note.toEventHint() ?: return null + + val template = + LabelEvent.buildHashtagLabel( + labeledEventId = eventHint.event.id, + labeledEventRelay = eventHint.relay?.url, + labeledEventAuthor = eventHint.event.pubKey, + hashtag = hashtag, + ) + + val event = signer.sign(template) + val relays = computeRelayListToBroadcast(event) + + return event to relays + } + + /** + * Consumes a label event into local cache. Called when tracked broadcasting succeeds. + */ + fun consumeLabelEvent(event: Event) { + cache.justConsumeMyOwnEvent(event) + } + suspend fun createZapRequestFor( event: Event, pollOption: Int?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 47ec75f681..f7847f225a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -143,6 +143,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.nip34Git.grasp.UserGraspListEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent @@ -1525,6 +1526,43 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: LabelEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val note = getOrCreateNote(event.id) + + // Already processed this event. + if (note.event != null) return true + + if (wasVerified || justVerify(event)) { + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) + + note.loadEvent(event, author, repliesTo) + + // Attach NIP-32 hashtag labels (namespace #t) to the labeled events so the + // hashtag feed can surface posts tagged by a follow and attribute the labeler. + val hashtags = event.hashtagAssociations() + if (hashtags.isNotEmpty()) { + event.labeledEvents().mapNotNull { checkGetOrCreateNote(it) }.forEach { target -> + hashtags.forEach { hashtag -> target.addLabel(hashtag, note) } + + // If the labeled post is already in cache, re-notify feed observers so the + // hashtag feed can pick it up now that a (possibly followed) user labeled it. + if (target.event != null) refreshNewNoteObservers(target) + } + } + + refreshNewNoteObservers(note) + + return true + } + + return false + } + fun consume( event: ReportEvent, relay: NormalizedRelayUrl?, @@ -3730,6 +3768,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is LabelEvent -> { + consume(event, relay, wasVerified) + } + is ContactCardEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt new file mode 100644 index 0000000000..cfde82f681 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/AddHashtagLabelDialog.kt @@ -0,0 +1,103 @@ +/* + * 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.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +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.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * NIP-32: lets the user tag any post with a hashtag by publishing a kind 1985 label + * event (under the `#t` tag-association namespace). The label is public so that the + * author's and the labeler's followers can discover the post in the hashtag feed. + */ +@Composable +fun AddHashtagLabelDialog( + note: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var hashtag by remember { mutableStateOf("") } + + // Strip the leading '#', drop whitespace and lowercase so the stored label matches the + // hashtag-feed convention. A blank result disables the confirm button. + val sanitized = + hashtag + .trim() + .removePrefix("#") + .filterNot { it.isWhitespace() } + .lowercase() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.add_hashtag_label_title)) }, + text = { + Column { + Text(stringRes(R.string.add_hashtag_label_explainer)) + OutlinedTextField( + value = hashtag, + onValueChange = { hashtag = it }, + singleLine = true, + label = { Text(stringRes(R.string.add_hashtag_label_field)) }, + prefix = { Text("#") }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + enabled = sanitized.isNotEmpty(), + onClick = { + accountViewModel.labelWithHashtag(note, sanitized) + onDismiss() + }, + ) { + Text(stringRes(R.string.add_hashtag_label_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index cea9f92a61..fd4d2b5695 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -115,6 +115,7 @@ fun NoteDropDownMenu( nav: INav, ) { var reportDialogShowing by remember { mutableStateOf(false) } + var addLabelDialogShowing by remember { mutableStateOf(false) } val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( DropDownParams( @@ -286,6 +287,9 @@ fun NoteDropDownMenu( } } } + M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) { + addLabelDialogShowing = true + } // Pick exactly one curation flow per kind: music tracks go to playlists, emoji // packs go to the emoji list, everything else gets the standard bookmark rows. // Showing both at once is noisy and makes "bookmark" feel like the catch-all when @@ -387,6 +391,17 @@ fun NoteDropDownMenu( onDismiss() } } + + if (addLabelDialogShowing) { + AddHashtagLabelDialog( + note = note, + accountViewModel = accountViewModel, + onDismiss = { + addLabelDialogShowing = false + onDismiss() + }, + ) + } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 7e878a2bce..3089f9ad8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -51,10 +51,22 @@ fun RefresheableFeedView( scrollStateKey: String? = null, accountViewModel: AccountViewModel, nav: INav, + onLoaded: (@Composable (FeedState.Loaded, LazyListState) -> Unit)? = null, ) { RefresheableBox(viewModel, enablePullRefresh) { SaveableFeedState(viewModel.feedState, scrollStateKey) { listState -> - RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead) + if (onLoaded != null) { + RenderFeedState( + viewModel, + accountViewModel, + listState, + nav, + routeForLastRead, + onLoaded = { onLoaded(it, listState) }, + ) + } else { + RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1f4d7ba6c7..b50e91abd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1068,6 +1068,16 @@ class AccountViewModel( direct = { account.removeBookmark(note, false) }, ) + /** NIP-32: tags [note] with [hashtag] by publishing a kind 1985 label event. */ + fun labelWithHashtag( + note: Note, + hashtag: String, + ) = launchTrackedOrDirect( + createTracked = { account.createLabelHashtagEvent(note, hashtag) }, + consumeTracked = account::consumeLabelEvent, + direct = { account.labelHashtag(note, hashtag) }, + ) + fun removeDeletedBookmarks( deletedEventIds: Set, deletedAddresses: Set
, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt new file mode 100644 index 0000000000..62aeae683b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagFeedLoaded.kt @@ -0,0 +1,160 @@ +/* + * 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.hashtag + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +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 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.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +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.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash + +/** + * Hashtag feed renderer that adds an attribution banner above any post that a followed user + * surfaced through a NIP-32 hashtag label (rather than the author tagging it directly). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HashtagFeedLoaded( + tag: String, + loaded: FeedState.Loaded, + listState: LazyListState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyColumn( + contentPadding = rememberFeedContentPadding(FeedPadding), + state = listState, + ) { + itemsIndexed( + items.list, + key = { _, item -> item.idHex }, + contentType = { _, item -> item.event?.kind ?: -1 }, + ) { _, item -> + Column(Modifier.fillMaxWidth().animateItem()) { + HashtagLabelAttribution(item, tag, accountViewModel) + + NoteCompose( + item, + modifier = Modifier.fillMaxWidth(), + routeForLastRead = null, + isBoostedNote = false, + isHiddenFeed = items.showHidden, + quotesLeft = 3, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } +} + +/** + * Shows "🏷 #tag added by " when a followed user labeled this post with the hashtag + * and the author didn't include it themselves. Renders nothing otherwise. + */ +@Composable +fun HashtagLabelAttribution( + note: Note, + tag: String, + accountViewModel: AccountViewModel, +) { + val labelState by note + .flow() + .labels.stateFlow + .collectAsStateWithLifecycle() + + // The author already used the hashtag — nothing to attribute. + if (note.event?.isTaggedHash(tag) == true) return + + val follows = accountViewModel.account.followingKeySet() + val labeler = + labelState.note.labels[tag.lowercase()] + ?.firstOrNull { it.author?.pubkeyHex in follows } + ?.author ?: return + + Row( + modifier = Modifier.fillMaxWidth().then(HalfStartPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Tag, + contentDescription = null, + modifier = Size16Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(StdHorzSpacer) + Text( + "#$tag", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt index 9124536352..10900d7bc6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/HashtagScreen.kt @@ -113,6 +113,9 @@ fun HashtagScreen( null, accountViewModel = accountViewModel, nav = nav, + onLoaded = { state, listState -> + HashtagFeedLoaded(tag.hashtag, state, listState, accountViewModel, nav) + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index a3f47452e9..b5eb204f25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -53,9 +54,10 @@ class HashtagFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + tag override fun feed(): List { + val follows = account.followingKeySet() val notes = cache.notes.filterIntoSet { _, it -> - acceptableEvent(it, tag) + acceptableEvent(it, tag, follows) } return sort(notes) @@ -63,16 +65,39 @@ class HashtagFeedFilter( override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) - private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it, tag) } + private fun innerApplyFilter(collection: Collection): Set { + val follows = account.followingKeySet() + return collection.filterTo(HashSet()) { acceptableEvent(it, tag, follows) } + } fun acceptableEvent( it: Note, hashTag: String, + follows: Set, ): Boolean = - (acceptableViaHashtag(it.event, hashTag) || acceptableViaScope(it.event, hashTag)) && + ( + acceptableViaHashtag(it.event, hashTag) || + acceptableViaScope(it.event, hashTag) || + acceptableViaFollowLabel(it, hashTag, follows) + ) && !it.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(it) + /** + * NIP-32: accept a post that a followed user has tagged with this hashtag via a kind + * 1985 label event (namespace `#t`), even if the post's author never used the hashtag. + * Requires the target to actually have an event so it can render. + */ + fun acceptableViaFollowLabel( + note: Note, + hashTag: String, + follows: Set, + ): Boolean { + if (note.event == null) return false + val labelers = note.labels[hashTag.lowercase()] ?: return false + return labelers.any { it.author?.pubkeyHex in follows } + } + fun acceptableViaHashtag( event: Event?, hashTag: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt new file mode 100644 index 0000000000..2d4bf16af0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt @@ -0,0 +1,96 @@ +/* + * 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.hashtag.datasource + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent +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.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts +import com.vitorpamplona.quartz.nip32Labeling.LabelEvent +import com.vitorpamplona.quartz.utils.mapOfSet + +/** + * Builds the relay filters that power the NIP-32 side of the hashtag feed: + * + * 1. A subscription for kind 1985 label events that tag posts with this hashtag + * (the `l` tag under the `#t` namespace). We don't restrict authors at the relay + * — the feed restricts display to the user's follows locally — but we do bound it + * with a limit. + * 2. Requests for the underlying posts a *followed* user has labeled but that aren't in + * cache yet, so they can actually render in the feed. This rotates as new label + * events arrive (the sub-assembler re-runs after each EOSE). + */ +fun filterHashtagLabels( + account: Account, + hashtag: String, + relays: Set, + since: SincePerRelayMap?, +): List { + val labelValues = hashtagAlts(hashtag).sorted() + + val labelFilters = + relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(LabelEvent.KIND), + tags = mapOf("l" to labelValues), + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } + + val follows = account.followingKeySet() + + // Collect the posts that a followed user labeled with this hashtag but that we don't + // have the full event for yet, and ask their likely relays for them. + val labelNotes = + LocalCache.notes.filterIntoSet { _, note -> + val noteEvent = note.event + noteEvent is LabelEvent && + noteEvent.pubKey in follows && + hashtag in noteEvent.hashtagAssociations() + } + + val missingTargets = + mapOfSet { + labelNotes.forEach { labelNote -> + (labelNote.event as LabelEvent).labeledEvents().forEach { targetId -> + val target = LocalCache.getNoteIfExists(targetId) + if (target?.event == null) { + val targetNote = LocalCache.getOrCreateNote(targetId) + potentialRelaysToFindEvent(targetNote).ifEmpty { relays }.forEach { relayUrl -> + add(relayUrl, targetId) + } + } + } + } + } + + return labelFilters + filterMissingEvents(missingTargets) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt index 55a481ed2e..d394b588d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssembler.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -28,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl class HashtagQueryState( val hashtag: String, val relays: Set, + val account: Account, ) { val lowercaseHashtag = hashtag.lowercase() } @@ -38,6 +40,7 @@ class HashtagFilterAssembler( val group = listOf( HashtagFeedFilterSubAssembler(client, ::allKeys), + HashtagLabelSubAssembler(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt index 0075f5d49c..54d0e69a0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFilterAssemblerSubscription.kt @@ -35,7 +35,11 @@ fun HashtagFilterAssemblerSubscription( // even if they are tracking the same tag. val state = remember(tag) { - HashtagQueryState(tag.hashtag, accountViewModel.account.followOutboxesOrProxy.flow.value) + HashtagQueryState( + tag.hashtag, + accountViewModel.account.followOutboxesOrProxy.flow.value, + accountViewModel.account, + ) } LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().hashtags) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagLabelSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagLabelSubAssembler.kt new file mode 100644 index 0000000000..c84cf69895 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagLabelSubAssembler.kt @@ -0,0 +1,46 @@ +/* + * 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.hashtag.datasource + +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 + +/** + * NIP-32: streams kind 1985 label events that tag posts with the screen's hashtag and + * pulls in the posts a followed user labeled. Rotates after each EOSE (`invalidateAfterEose`) + * so newly-arrived label events get their target posts fetched on the next pass. + */ +class HashtagLabelSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys, invalidateAfterEose = true) { + override fun updateFilter( + key: HashtagQueryState, + since: SincePerRelayMap?, + ): List = filterHashtagLabels(key.account, key.lowercaseHashtag, key.relays, since) + + /** + * Only one key per hashtag. Prefixed so it doesn't collide with the main feed sub-assembler. + */ + override fun id(key: HashtagQueryState) = "label-" + key.lowercaseHashtag +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 01d3b1c9ba..cf1d12b9e4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -806,6 +806,13 @@ Remove from Private Bookmarks Remove from Public Bookmarks + Add hashtag + Add a hashtag + Publicly tag this post with a hashtag (NIP-32 label). People who follow you will see it in that hashtag\'s feed. + Hashtag + Add + added by + Pinned Notes Your pinned notes Pin to Profile diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 9179858e50..ebe9e27d02 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -145,6 +145,7 @@ open class Note( removeZap(note) removeZapPayment(note) removeReport(note) + removeLabel(note) } var poll: PollResponsesCache? = null @@ -166,6 +167,16 @@ open class Note( var reports = mapOf>() private set + /** + * NIP-32 hashtag labels (kind 1985) targeting this note. + * Key: hashtag value, lowercased (the `l` tag value under the `#t` namespace). + * Value: the list of LabelEvent notes that applied that hashtag to this note, + * so `source.author` identifies who labeled it. Used by the hashtag feed to + * surface posts a follow has tagged and to attribute the label in the UI. + */ + var labels = mapOf>() + private set + var zaps = mapOf() private set @@ -366,12 +377,14 @@ open class Note( val zapsChanged = zaps.isNotEmpty() || zapPayments.isNotEmpty() || onchainZaps.isNotEmpty() || nutzaps.isNotEmpty() val boostsChanged = boosts.isNotEmpty() val reportsChanged = reports.isNotEmpty() + val labelsChanged = labels.isNotEmpty() val toBeRemoved = replies + reactions.values.flatten() + boosts + reports.values.flatten() + + labels.values.flatten() + zaps.keys + zaps.values.filterNotNull() + zapPayments.keys + @@ -382,6 +395,7 @@ open class Note( reactions = mapOf() boosts = listOf() reports = mapOf() + labels = mapOf() zaps = mapOf() onchainZaps = mapOf() onchainZapResolved = false @@ -394,6 +408,7 @@ open class Note( if (reactionsChanged) flowSet?.reactions?.invalidateData() if (boostsChanged) flowSet?.boosts?.invalidateData() if (reportsChanged) flowSet?.reports?.invalidateData() + if (labelsChanged) flowSet?.labels?.invalidateData() if (zapsChanged) flowSet?.zaps?.invalidateData() return toBeRemoved @@ -668,6 +683,32 @@ open class Note( } } + /** Attach a NIP-32 LabelEvent note as having tagged this note with [hashtag] (lowercased). */ + fun addLabel( + hashtag: String, + note: Note, + ) { + val listOfLabelers = labels[hashtag] + if (listOfLabelers == null) { + labels = labels + Pair(hashtag, listOf(note)) + flowSet?.labels?.invalidateData() + } else if (!listOfLabelers.contains(note)) { + labels = labels + Pair(hashtag, listOfLabelers + note) + flowSet?.labels?.invalidateData() + } + } + + /** Detach a LabelEvent note (e.g. deleted) from every hashtag bucket it was in. */ + fun removeLabel(note: Note) { + if (labels.none { it.value.contains(note) }) return + + labels = + labels + .mapValues { it.value - note } + .filterValues { it.isNotEmpty() } + flowSet?.labels?.invalidateData() + } + fun addRelaySync(relay: NormalizedRelayUrl) = syncLock.withLock { if (relay !in relays) { @@ -1239,6 +1280,7 @@ class NoteFlowSet( val zaps = NoteBundledRefresherFlow(u) val ots = NoteBundledRefresherFlow(u) val edits = NoteBundledRefresherFlow(u) + val labels = NoteBundledRefresherFlow(u) @OptIn(ExperimentalCoroutinesApi::class) fun author() = @@ -1257,7 +1299,8 @@ class NoteFlowSet( replies.hasObservers() || zaps.hasObservers() || ots.hasObservers() || - edits.hasObservers() + edits.hasObservers() || + labels.hasObservers() } @Stable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt index 9615773a04..853d82dac9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt @@ -82,6 +82,16 @@ class LabelEvent( /** Labels filtered by a specific namespace. */ fun labelsByNamespace(namespace: String) = labels().filter { it.namespace == namespace } + /** + * Hashtags this event associates with its targets, via the NIP-32 tag-association + * namespace `#t` (`["L", "#t"]` + `["l", "", "#t"]`). Values are returned + * lowercased so callers can compare without worrying about case. + */ + fun hashtagAssociations() = + labelsByNamespace(HASHTAG_NAMESPACE) + .map { it.label.lowercase() } + .distinct() + /** Referenced event IDs (label targets). */ fun labeledEvents() = tags.mapNotNull(ETag::parseId) @@ -104,6 +114,32 @@ class LabelEvent( const val KIND = 1985 const val ALT = "Label event" + /** + * NIP-32 tag-association namespace for hashtags. A label of the form + * `["l", "", "#t"]` (with `["L", "#t"]`) associates the target + * with the hashtag `` under the standard `t` tag. + */ + const val HASHTAG_NAMESPACE = "#t" + + /** + * Build a label event that tags an existing event with a hashtag, using the + * NIP-32 tag-association namespace `#t`. The hashtag is stored lowercased so + * follow-graph hashtag feeds match regardless of input case. + */ + fun buildHashtagLabel( + labeledEventId: HexKey, + labeledEventRelay: String? = null, + labeledEventAuthor: HexKey? = null, + hashtag: String, + createdAt: Long = TimeUtils.now(), + ) = buildEventLabel( + labeledEventId = labeledEventId, + labeledEventRelay = labeledEventRelay, + labeledEventAuthor = labeledEventAuthor, + labels = listOf(LabelTag(hashtag.removePrefix("#").lowercase(), HASHTAG_NAMESPACE)), + createdAt = createdAt, + ) + /** * Build a label event for labeling events. */ diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt index 7ed37345ff..7365ae4157 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt @@ -160,6 +160,51 @@ class LabelEventTest { assertEquals("en", languageLabels[0].label) } + @Test + fun testBuildHashtagLabel() { + val template = + LabelEvent.buildHashtagLabel( + labeledEventId = testEventId, + labeledEventAuthor = testPubKey, + hashtag = "#PhotoGraphy", + ) + + assertEquals(1985, template.kind) + + val hasEventRef = template.tags.any { it[0] == "e" && it[1] == testEventId } + assertTrue(hasEventRef, "Should have e tag targeting labeled event") + + val hasNamespace = template.tags.any { it[0] == "L" && it[1] == "#t" } + assertTrue(hasNamespace, "Should have the #t tag-association namespace") + + // The leading '#' is stripped and the value is lowercased. + val hasLabel = template.tags.any { it[0] == "l" && it[1] == "photography" && it[2] == "#t" } + assertTrue(hasLabel, "Should have a lowercased l label tag under #t") + } + + @Test + fun testHashtagAssociations() { + val event = + EventFactory.create( + id = testEventId, + pubKey = testPubKey, + createdAt = 1234567890L, + kind = 1985, + tags = + arrayOf( + arrayOf("L", "#t"), + arrayOf("l", "Bitcoin", "#t"), + arrayOf("l", "nostr", "#t"), + arrayOf("l", "MIT", "license"), + arrayOf("e", testEventId), + ), + content = "", + sig = "c".repeat(128), + ) + + assertEquals(listOf("bitcoin", "nostr"), event.hashtagAssociations()) + } + @Test fun testLabeledTargets() { val event = From 8e7d169198c40f7ca6954e992e504206c581c560 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 20:59:19 +0000 Subject: [PATCH 2/3] feat: route follow hashtag-labels through each follow's outbox relays The kind-1985 label subscription for the hashtag feed now follows the NIP-65 outbox model: instead of querying the flat hashtag relay set for all labels and filtering to follows locally, it queries each follow's outbox relays for that follow's own label events (authors restricted per relay via account.followsPerRelay), tagged with the hashtag. This ensures a follow's labels are picked up even when they never reach the hashtag's own relays. https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX --- .../hashtag/datasource/FilterHashtagLabels.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt index 2d4bf16af0..cd0a703978 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.kt @@ -31,14 +31,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtagAlts import com.vitorpamplona.quartz.nip32Labeling.LabelEvent import com.vitorpamplona.quartz.utils.mapOfSet +import kotlin.math.min /** * Builds the relay filters that power the NIP-32 side of the hashtag feed: * - * 1. A subscription for kind 1985 label events that tag posts with this hashtag - * (the `l` tag under the `#t` namespace). We don't restrict authors at the relay - * — the feed restricts display to the user's follows locally — but we do bound it - * with a limit. + * 1. A subscription for kind 1985 label events that tag posts with this hashtag (the `l` + * tag under the `#t` namespace), routed through the NIP-65 outbox model: each follow's + * labels are requested from THAT follow's outbox relays, with the relay's filter + * restricted to the follows that actually write there. This is what guarantees we pick + * up a follow's labels even if they never reach the hashtag's own relays. * 2. Requests for the underlying posts a *followed* user has labeled but that aren't in * cache yet, so they can actually render in the feed. This rotates as new label * events arrive (the sub-assembler re-runs after each EOSE). @@ -51,15 +53,20 @@ fun filterHashtagLabels( ): List { val labelValues = hashtagAlts(hashtag).sorted() + // Outbox routing: relay -> the follows who publish to it. + val followsPerRelay = account.followsPerRelay.value val labelFilters = - relays.map { relay -> + followsPerRelay.mapNotNull { (relay, authors) -> + if (authors.isEmpty()) return@mapNotNull null + val authorList = authors.sorted() RelayBasedFilter( relay = relay, filter = Filter( kinds = listOf(LabelEvent.KIND), + authors = authorList, tags = mapOf("l" to labelValues), - limit = 200, + limit = min(authorList.size * 5, 500), since = since?.get(relay)?.time, ), ) From 3f4066c4b8daab7a4389691cb90d09aa24dd0e97 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 21:23:56 +0000 Subject: [PATCH 3/3] refactor: buildHashtagLabel takes an EventHintBundle Replace the labeledEventId/relay/author trio with a single EventHintBundle, building the e-tag via the standard EventHintBundle.toETag() idiom. The Account caller now passes the note's event hint directly. https://claude.ai/code/session_019gc3FipVBcndF9fmqCCfVX --- .../vitorpamplona/amethyst/model/Account.kt | 8 +------- .../quartz/nip32Labeling/LabelEvent.kt | 20 +++++++++---------- .../quartz/nip32Labeling/LabelEventTest.kt | 15 ++++++++++++-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2fffcdc791..649a22afe0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -769,13 +769,7 @@ class Account( val eventHint = note.toEventHint() ?: return null - val template = - LabelEvent.buildHashtagLabel( - labeledEventId = eventHint.event.id, - labeledEventRelay = eventHint.relay?.url, - labeledEventAuthor = eventHint.event.pubKey, - hashtag = hashtag, - ) + val template = LabelEvent.buildHashtagLabel(eventHint, hashtag) val event = signer.sign(template) val relays = computeRelayListToBroadcast(event) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt index 853d82dac9..974bfec5a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint @@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.events.toETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag @@ -127,18 +129,16 @@ class LabelEvent( * follow-graph hashtag feeds match regardless of input case. */ fun buildHashtagLabel( - labeledEventId: HexKey, - labeledEventRelay: String? = null, - labeledEventAuthor: HexKey? = null, + labeledEvent: EventHintBundle, hashtag: String, createdAt: Long = TimeUtils.now(), - ) = buildEventLabel( - labeledEventId = labeledEventId, - labeledEventRelay = labeledEventRelay, - labeledEventAuthor = labeledEventAuthor, - labels = listOf(LabelTag(hashtag.removePrefix("#").lowercase(), HASHTAG_NAMESPACE)), - createdAt = createdAt, - ) + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT) + eTag(labeledEvent.toETag()) + val tag = LabelTag(hashtag.removePrefix("#").lowercase(), HASHTAG_NAMESPACE) + labelNamespace(tag.namespace) + label(tag) + } /** * Build a label event for labeling events. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt index 7365ae4157..7dc80aebe1 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip32Labeling/LabelEventTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip32Labeling import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip32Labeling.tags.LabelTag import com.vitorpamplona.quartz.utils.EventFactory @@ -162,10 +163,20 @@ class LabelEventTest { @Test fun testBuildHashtagLabel() { + val labeledEvent = + EventFactory.create( + id = testEventId, + pubKey = testPubKey, + createdAt = 1234567890L, + kind = 1, + tags = arrayOf(), + content = "hello", + sig = "c".repeat(128), + ) + val template = LabelEvent.buildHashtagLabel( - labeledEventId = testEventId, - labeledEventAuthor = testPubKey, + labeledEvent = EventHintBundle(labeledEvent), hashtag = "#PhotoGraphy", )