Merge pull request #3593 from vitorpamplona/claude/nip-51-compliance-review-vwhyvn

Add hashtag muting support via NIP-51
This commit is contained in:
Vitor Pamplona
2026-07-15 22:07:00 -04:00
committed by GitHub
15 changed files with 339 additions and 152 deletions
@@ -4734,6 +4734,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))
}
@@ -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,
)
@@ -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) {
@@ -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<Boolean> {
// 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
@@ -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)
@@ -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)
}
},
)
}
}
}
+2
View File
@@ -2331,6 +2331,8 @@
<string name="hidden_words">Hidden Words</string>
<string name="hide_new_word_label">Hide new word or sentence</string>
<string name="mute_hashtag">Mute hashtag</string>
<string name="unmute_hashtag">Unmute hashtag</string>
<string name="settings_muted_threads_title">Muted threads</string>
<string name="settings_muted_threads_empty">No muted threads</string>
<string name="settings_muted_threads_unknown">Unknown thread · %1$s</string>
@@ -61,11 +61,15 @@ data class LiveHiddenUsers(
val spammers: Set<String> = emptySet(),
val hiddenWords: Set<String> = emptySet(),
val mutedThreads: Set<String> = emptySet(),
// Lowercased hashtags (without the leading '#') the user has muted via NIP-51 `"t"` entries.
val hiddenHashtags: Set<String> = 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())
}
/**
@@ -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
@@ -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")
}
}
@@ -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<Array<String>>,
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<Array<String>>,
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<Array<String>>,
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
}
}
}
}
}
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun isTagged(
tag: Array<String>,
hashtag: String,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == hashtag
fun parse(tag: Array<String>): 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>): 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)
}
}
@@ -28,8 +28,8 @@ sealed interface MuteTag {
fun toTagIdOnly(): Tag
companion object {
fun isTagged(tag: Array<String>) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag)
fun isTagged(tag: Array<String>) = WordTag.isTagged(tag) || UserTag.isTagged(tag) || EventTag.isTagged(tag) || HashtagTag.isTagged(tag)
fun parse(tag: Array<String>): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag)
fun parse(tag: Array<String>): MuteTag? = WordTag.parse(tag) ?: UserTag.parse(tag) ?: EventTag.parse(tag) ?: HashtagTag.parse(tag)
}
}
@@ -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)
}
}