From abc673228651f6a70f15ec15d615115462b0d361 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:51:48 +0000 Subject: [PATCH 1/2] feat: support NIP-51 mute-list hashtag ("t") entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-51's kind:10000 mute list defines four entry types — `p` (pubkeys), `word`, `e` (threads) and `t` (hashtags). Quartz parsed only the first three, so `t` hashtag mutes written by other clients were silently dropped: uncounted, invisible, and never applied to filtering. Quartz: - Add HashtagTag (`"t"`) implementing the MuteTag sealed interface, and register it in MuteTag.parse/isTagged so it round-trips like the other entry types. - Add mutedHashtags()/mutedHashtagIds() TagArray helpers. Filtering (commons): - Add hiddenHashtags to LiveHiddenUsers plus isHashtagHidden(), and hide notes carrying a muted hashtag in Note.isHiddenFor() (exact, case- insensitive `t`-tag match — distinct from the existing substring word scan). Amethyst: - Aggregate HashtagTag entries from the mute/block lists in HiddenUsersState. - MuteListState.hideHashtag/showHashtag + Account and AccountViewModel wrappers, and observeUserIsMutingHashtag. - Surface a Mute/Unmute hashtag action in the hashtag screen's options overflow menu. Tests: HashtagTagTest (parse/round-trip/MuteTag dispatch) and NoteIsHiddenForTest cases for muted-hashtag hiding. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8 --- .../vitorpamplona/amethyst/model/Account.kt | 8 ++ .../model/nip51Lists/HiddenUsersState.kt | 3 + .../nip51Lists/muteList/MuteListState.kt | 34 ++++++++ .../reqCommand/user/UserObservers.kt | 27 +++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 4 + .../screen/loggedIn/hashtag/HashtagScreen.kt | 65 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + .../amethyst/commons/model/IAccount.kt | 4 + .../amethyst/commons/model/Note.kt | 6 ++ .../commons/model/NoteIsHiddenForTest.kt | 29 +++++++ .../quartz/nip51Lists/muteList/TagArrayExt.kt | 7 ++ .../nip51Lists/muteList/tags/HashtagTag.kt | 67 +++++++++++++++ .../nip51Lists/muteList/tags/MuteTag.kt | 4 +- .../muteList/tags/HashtagTagTest.kt | 81 +++++++++++++++++++ 14 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ab22669284..807b3aee80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4719,6 +4719,14 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.showWord(word)) } + suspend fun hideHashtag(hashtag: String) { + sendMyPublicAndPrivateOutbox(muteList.hideHashtag(hashtag)) + } + + suspend fun showHashtag(hashtag: String) { + muteList.showHashtag(hashtag)?.let { sendMyPublicAndPrivateOutbox(it) } + } + suspend fun hideUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt index d23ccb0d78..a2946aec11 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -57,6 +58,7 @@ class HiddenUsersState( ): LiveHiddenUsers { val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null } val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null } + val hiddenHashtags = blockList.mapNotNullTo(mutableSetOf()) { if (it is HashtagTag) it.hashtag.lowercase() else null } + muteList.mapNotNull { if (it is HashtagTag) it.hashtag.lowercase() else null } val mutedThreads = muteList.mapNotNullTo(mutableSetOf()) { if (it is EventTag) it.eventId else null } return LiveHiddenUsers( @@ -67,6 +69,7 @@ class HiddenUsersState( hiddenUsers = hiddenUsers, spammers = transientHiddenUsers, hiddenWords = hiddenWords, + hiddenHashtags = hiddenHashtags, maxHashtagLimit = maxHashtagLimit, mutedThreads = mutedThreads, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt index ae90a7c09b..622335d2e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/muteList/MuteListState.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -142,6 +143,39 @@ class MuteListState( } } + suspend fun hideHashtag(hashtag: String): MuteListEvent { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.add( + earlierVersion = muteList, + mute = HashtagTag(hashtag), + isPrivate = true, + signer = signer, + ) + } else { + MuteListEvent.create( + mute = HashtagTag(hashtag), + isPrivate = true, + signer = signer, + ) + } + } + + suspend fun showHashtag(hashtag: String): MuteListEvent? { + val muteList = getMuteList() + + return if (muteList != null) { + MuteListEvent.remove( + earlierVersion = muteList, + mute = HashtagTag(hashtag), + signer = signer, + ) + } else { + null + } + } + suspend fun hideThread(rootHex: HexKey): MuteListEvent { val muteList = getMuteList() return if (muteList != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 82a1de7a66..9dbcb3cceb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -396,6 +396,33 @@ fun observeUserIsFollowingHashtag( return flow.collectAsStateWithLifecycle(hashtag in accountViewModel.account.hashtagList.flow.value) } +@SuppressLint("StateFlowValueCalledInComposition") +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsMutingHashtag( + hashtag: String, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the LocalCache for changes that arrive in the device + val flow = + remember(accountViewModel, hashtag) { + accountViewModel.account.hiddenUsers.flow + .mapLatest { it.isHashtagHidden(hashtag) } + .onStart { + emit( + accountViewModel.account.hiddenUsers.flow.value + .isHashtagHidden(hashtag), + ) + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle( + accountViewModel.account.hiddenUsers.flow.value + .isHashtagHidden(hashtag), + ) +} + @SuppressLint("StateFlowValueCalledInComposition") @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable 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 2fc75b1839..75f0654081 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 @@ -1693,6 +1693,10 @@ class AccountViewModel( fun hideWord(word: String) = launchSigner { account.hideWord(word) } + fun hideHashtag(tag: String) = launchSigner { account.hideHashtag(tag) } + + fun showHashtag(tag: String) = launchSigner { account.showHashtag(tag) } + fun isLoggedUser(pubkeyHex: HexKey?): Boolean = account.signer.pubKey == pubkeyHex fun isLoggedUser(user: User?): Boolean = isLoggedUser(user?.pubkeyHex) 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 10900d7bc6..4c66c153a3 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 @@ -25,15 +25,26 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel 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.service.relayClient.reqCommand.user.observeUserIsFollowingHashtag +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsMutingHashtag import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded @@ -46,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.dal.HashtagFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable @@ -172,4 +184,57 @@ fun HashtagActionOptions( } } } + + HashtagMuteMenu(tag, accountViewModel) +} + +@Composable +fun HashtagMuteMenu( + tag: String, + accountViewModel: AccountViewModel, +) { + var menuOpen by remember { mutableStateOf(false) } + val isMuted by observeUserIsMutingHashtag(tag, accountViewModel) + + IconButton(onClick = { menuOpen = true }) { + Icon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(R.string.more_options), + modifier = Modifier.size(22.dp), + ) + } + + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (isMuted) { + DropdownMenuItem( + text = { Text(stringRes(R.string.unmute_hashtag)) }, + onClick = { + menuOpen = false + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_show_word, + ) + } else { + accountViewModel.showHashtag(tag) + } + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringRes(R.string.mute_hashtag)) }, + onClick = { + menuOpen = false + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_hide_word, + ) + } else { + accountViewModel.hideHashtag(tag) + } + }, + ) + } + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c8d8cb12b..46b4266f03 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2328,6 +2328,8 @@ Hidden Words Hide new word or sentence + Mute hashtag + Unmute hashtag Muted threads No muted threads Unknown thread · %1$s diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index e8583e0345..2cb3af0592 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -61,11 +61,15 @@ data class LiveHiddenUsers( val spammers: Set = emptySet(), val hiddenWords: Set = emptySet(), val mutedThreads: Set = emptySet(), + // Lowercased hashtags (without the leading '#') the user has muted via NIP-51 `"t"` entries. + val hiddenHashtags: Set = emptySet(), val maxHashtagLimit: Int = 8, ) { fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex) fun isThreadMuted(rootHex: String) = mutedThreads.contains(rootHex) + + fun isHashtagHidden(hashtag: String) = hiddenHashtags.contains(hashtag.lowercase()) } /** 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 61cec122e3..802ba935b7 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 @@ -1309,6 +1309,12 @@ open class Note( } } + if (accountChoices.hiddenHashtags.isNotEmpty()) { + if (thisEvent.anyHashTag { it.lowercase() in accountChoices.hiddenHashtags }) { + return true + } + } + if (accountChoices.hiddenWordsCase.isNotEmpty()) { if (thisEvent is BaseThreadedEvent && thisEvent.content.containsAny(accountChoices.hiddenWordsCase)) { return true diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt index b8c1378006..2f5cbee961 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/NoteIsHiddenForTest.kt @@ -90,4 +90,33 @@ class NoteIsHiddenForTest { assertTrue(note.isHiddenFor(choices), "Note whose author is hidden must still be hidden") } + + private fun tTag(hashtag: String) = arrayOf("t", hashtag) + + @Test + fun note_withMutedHashtag_isHidden() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("bitcoin"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertTrue(note.isHiddenFor(choices), "Note tagged with a muted hashtag must be hidden") + } + + @Test + fun note_withMutedHashtag_isCaseInsensitive() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("Bitcoin"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertTrue(note.isHiddenFor(choices), "Muted-hashtag matching must ignore case") + } + + @Test + fun note_withoutMutedHashtag_isNotHidden() { + val event = textNoteEvent(id = replyId, eTags = arrayOf(tTag("nostr"))) + val note = Note(replyId).also { it.event = event } + val choices = noHidden.copy(hiddenHashtags = setOf("bitcoin")) + + assertFalse(note.isHiddenFor(choices), "Note without the muted hashtag must not be hidden") + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt index b94b093067..fe7f5a57ae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/TagArrayExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip51Lists.muteList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag @@ -43,3 +44,9 @@ fun TagArray.mutedThreads() = mapNotNull(EventTag::parse) fun TagArray.mutedThreadIds() = mapNotNull(EventTag::parseId) fun TagArray.mutedThreadIdSet() = mapNotNullTo(mutableSetOf(), EventTag::parseId) + +fun TagArray.mutedHashtags() = mapNotNull(HashtagTag::parse) + +fun TagArray.mutedHashtagIds() = mapNotNull(HashtagTag::parseId) + +fun TagArray.mutedHashtagIdSet() = mapNotNullTo(mutableSetOf(), HashtagTag::parseId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt new file mode 100644 index 0000000000..59e3f9fb30 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTag.kt @@ -0,0 +1,67 @@ +/* + * 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.quartz.nip51Lists.muteList.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-51 mute-list hashtag entry (`"t"`). Hides notes that carry the given + * hashtag. Per NIP-24, `t` values are conventionally lowercased. + */ +@Immutable +class HashtagTag( + val hashtag: String, +) : MuteTag { + override fun toTagArray() = assemble(hashtag) + + override fun toTagIdOnly() = assemble(hashtag) + + companion object { + const val TAG_NAME = "t" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun isTagged( + tag: Array, + hashtag: String, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == hashtag + + fun parse(tag: Array): HashtagTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return HashtagTag(tag[1]) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(hashtag: String) = arrayOfNotNull(TAG_NAME, hashtag) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt index 7d95b71067..df4b77a595 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/MuteTag.kt @@ -28,8 +28,8 @@ sealed interface MuteTag { fun toTagIdOnly(): Tag companion object { - fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) + fun isTagged(tag: Array) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) || HashtagTag.isTagged(tag) - fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) + fun parse(tag: Array): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) ?: HashtagTag.parse(tag) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt new file mode 100644 index 0000000000..8a0ec89c39 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/muteList/tags/HashtagTagTest.kt @@ -0,0 +1,81 @@ +/* + * 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.quartz.nip51Lists.muteList.tags + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HashtagTagTest { + private val hashtag = "bitcoin" + + @Test fun isTagged_acceptsHashtag() { + assertTrue(HashtagTag.isTagged(arrayOf("t", hashtag))) + } + + @Test fun isTagged_rejectsEmpty() { + assertFalse(HashtagTag.isTagged(arrayOf("t", ""))) + } + + @Test fun isTagged_rejectsWrongPrefix() { + assertFalse(HashtagTag.isTagged(arrayOf("word", hashtag))) + } + + @Test fun parse_extractsHashtag() { + val tag = assertNotNull(HashtagTag.parse(arrayOf("t", hashtag))) + assertEquals(hashtag, tag.hashtag) + } + + @Test fun parse_rejectsEmpty() { + assertNull(HashtagTag.parse(arrayOf("t", ""))) + } + + @Test fun parse_rejectsWrongPrefix() { + assertNull(HashtagTag.parse(arrayOf("p", hashtag))) + } + + @Test fun parseId_extractsValue() { + assertEquals(hashtag, HashtagTag.parseId(arrayOf("t", hashtag))) + } + + @Test fun toTagArray_roundTrips() { + val parsed = assertNotNull(HashtagTag.parse(HashtagTag(hashtag).toTagArray())) + assertEquals(hashtag, parsed.hashtag) + } + + @Test fun muteTagCompanion_parsesHashtagTag() { + val parsed = assertNotNull(MuteTag.parse(arrayOf("t", hashtag))) + assertTrue(parsed is HashtagTag) + } + + @Test fun muteTagCompanion_isTaggedRecognizesHashtagTag() { + assertTrue(MuteTag.isTagged(arrayOf("t", hashtag))) + } + + @Test fun muteTagCompanion_doesNotConfuseHashtagWithWord() { + // "t" must parse as a hashtag, not a word; "word" must parse as a word. + assertTrue(MuteTag.parse(arrayOf("t", hashtag)) is HashtagTag) + assertTrue(MuteTag.parse(arrayOf("word", hashtag)) is WordTag) + } +} From bea342a73113ae498e2600b87fc11f264a6f8079 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:00:44 +0000 Subject: [PATCH 2/2] chore: remove dead kind:10011 GalleryListEvent GalleryListEvent (kind 10011, @Deprecated "Replaced by NIP-68") was unreachable: EventFactory routes kind 10011 to ExternalIdentitiesEvent, and no other code referenced the class. It also shadowed NIP-51's "Favorite follow sets" kind. Removing the dead class; the live profile gallery entry (ProfileGalleryEntryEvent) and its builders are untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017giudm3gXumsxZmd3uMQc8 --- .../profileGallery/GalleryListEvent.kt | 150 ------------------ 1 file changed, 150 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt deleted file mode 100644 index f30941c49c..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/profileGallery/GalleryListEvent.kt +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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.quartz.experimental.profileGallery - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag -import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent -import com.vitorpamplona.quartz.utils.TimeUtils - -@Deprecated("Replaced by NIP-68") -@Immutable -class GalleryListEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - @Suppress("DEPRECATION") - companion object { - const val KIND = 10011 - const val GALLERY_TAG_NAME = "url" - - suspend fun addEvent( - earlierVersion: GalleryListEvent?, - eventId: HexKey, - url: String, - relay: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = addTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, relay, signer, createdAt) - - suspend fun addTag( - earlierVersion: GalleryListEvent?, - tagName: String, - eventId: HexKey, - url: String, - relay: String?, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent { - val tags = arrayOf(tagName, url, eventId) - if (relay != null) { - tags + relay - } - - return add( - earlierVersion, - arrayOf(tags), - signer, - createdAt, - ) - } - - suspend fun add( - earlierVersion: GalleryListEvent?, - listNewTags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = - create( - content = earlierVersion?.content ?: "", - tags = listNewTags.plus(earlierVersion?.tags ?: arrayOf()), - signer = signer, - createdAt = createdAt, - ) - - suspend fun removeEvent( - earlierVersion: GalleryListEvent, - eventId: HexKey, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = removeTag(earlierVersion, GALLERY_TAG_NAME, eventId, url, signer, createdAt) - - suspend fun removeReplaceable( - earlierVersion: GalleryListEvent, - aTag: ATag, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ) = removeTag(earlierVersion, GALLERY_TAG_NAME, aTag.toTag(), url, signer, createdAt) - - private suspend fun removeTag( - earlierVersion: GalleryListEvent, - tagName: String, - eventId: HexKey, - url: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = - create( - content = earlierVersion.content, - tags = - earlierVersion.tags - .filter { it.size <= 1 || !(it[0] == tagName && it[1] == url && it[2] == eventId) } - .toTypedArray(), - signer = signer, - createdAt = createdAt, - ) - - suspend fun create( - content: String, - tags: Array>, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - ): GalleryListEvent = signer.sign(createdAt, KIND, tags, content) - } - - @Immutable - data class GalleryUrl( - val url: String, - val id: String, - val relay: String?, - ) { - fun encode(): String = ":$url:$id:$relay" - - companion object { - fun decode(encodedGallerySetup: String): GalleryUrl? { - val galleryParts = encodedGallerySetup.split(":", limit = 3) - return if (galleryParts.size > 3) { - GalleryUrl(galleryParts[1], galleryParts[2], galleryParts[3]) - } else { - null - } - } - } - } -}