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 70c38b208b..b43d13eddb 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,48 @@ 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(eventHint, 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 d9bf66998c..4bbe6869ee 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..cd0a703978 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterHashtagLabels.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.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 +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), 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). + */ +fun filterHashtagLabels( + account: Account, + hashtag: String, + relays: Set, + since: SincePerRelayMap?, +): List { + val labelValues = hashtagAlts(hashtag).sorted() + + // Outbox routing: relay -> the follows who publish to it. + val followsPerRelay = account.followsPerRelay.value + val labelFilters = + 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 = min(authorList.size * 5, 500), + 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..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 @@ -82,6 +84,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 +116,30 @@ 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( + labeledEvent: EventHintBundle, + hashtag: String, + createdAt: Long = TimeUtils.now(), + ) = 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 7ed37345ff..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 @@ -160,6 +161,61 @@ class LabelEventTest { assertEquals("en", languageLabels[0].label) } + @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( + labeledEvent = EventHintBundle(labeledEvent), + 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 =