mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
+103
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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>,
|
||||
|
||||
+160
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
@@ -113,6 +113,9 @@ fun HashtagScreen(
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onLoaded = { state, listState ->
|
||||
HashtagFeedLoaded(tag.hashtag, state, listState, accountViewModel, nav)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -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,
|
||||
|
||||
+103
@@ -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)
|
||||
}
|
||||
+3
@@ -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()
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+46
@@ -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
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user