Merge pull request #3111 from vitorpamplona/claude/epic-hamilton-23225

NIP-32: Add hashtag labeling and label-based hashtag feed
This commit is contained in:
Vitor Pamplona
2026-05-30 17:32:03 -04:00
committed by GitHub
17 changed files with 717 additions and 6 deletions
@@ -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<Event, Set<NormalizedRelayUrl>>? {
if (!signer.isWriteable()) return null
val eventHint = note.toEventHint<Event>() ?: 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?,
@@ -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)
}
@@ -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))
}
},
)
}
@@ -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
@@ -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)
}
}
}
}
@@ -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<String>,
deletedAddresses: Set<Address>,
@@ -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 <user>" 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,
)
}
}
@@ -113,6 +113,9 @@ fun HashtagScreen(
null,
accountViewModel = accountViewModel,
nav = nav,
onLoaded = { state, listState ->
HashtagFeedLoaded(tag.hashtag, state, listState, accountViewModel, nav)
},
)
}
}
@@ -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<Note> {
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<Note>): Set<Note> = innerApplyFilter(newItems)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> = collection.filterTo(HashSet()) { acceptableEvent(it, tag) }
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val follows = account.followingKeySet()
return collection.filterTo(HashSet()) { acceptableEvent(it, tag, follows) }
}
fun acceptableEvent(
it: Note,
hashTag: String,
follows: Set<HexKey>,
): 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<HexKey>,
): 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,
@@ -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<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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)
}
@@ -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<NormalizedRelayUrl>,
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()
@@ -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)
@@ -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<HashtagQueryState>,
) : PerUniqueIdEoseManager<HashtagQueryState, String>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(
key: HashtagQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = 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
}
+7
View File
@@ -806,6 +806,13 @@
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
<string name="add_hashtag_label">Add hashtag</string>
<string name="add_hashtag_label_title">Add a hashtag</string>
<string name="add_hashtag_label_explainer">Publicly tag this post with a hashtag (NIP-32 label). People who follow you will see it in that hashtag\'s feed.</string>
<string name="add_hashtag_label_field">Hashtag</string>
<string name="add_hashtag_label_confirm">Add</string>
<string name="hashtag_label_added_by">added by</string>
<string name="pinned_notes">Pinned Notes</string>
<string name="pinned_notes_explainer">Your pinned notes</string>
<string name="pin_to_profile">Pin to Profile</string>
@@ -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<User, List<Note>>()
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<String, List<Note>>()
private set
var zaps = mapOf<Note, Note?>()
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
@@ -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", "<hashtag>", "#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", "<hashtag>", "#t"]` (with `["L", "#t"]`) associates the target
* with the hashtag `<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<out Event>,
hashtag: String,
createdAt: Long = TimeUtils.now(),
) = eventTemplate<LabelEvent>(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.
*/
@@ -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<Event>(
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<LabelEvent>(
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 =