mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3593 from vitorpamplona/claude/nip-51-compliance-review-vwhyvn
Add hashtag muting support via NIP-51
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
+34
@@ -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) {
|
||||
|
||||
+27
@@ -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
|
||||
|
||||
+4
@@ -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)
|
||||
|
||||
+65
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
-150
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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)
|
||||
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user