From f4da2ae6be21fcda40bfa499d14d67cf65202290 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 24 Sep 2023 15:10:50 -0400 Subject: [PATCH] Mute keywords --- .../vitorpamplona/amethyst/model/Account.kt | 170 +++++++++++- .../com/vitorpamplona/amethyst/model/Note.kt | 16 +- .../ui/dal/HiddenAccountsFeedFilter.kt | 26 ++ .../amethyst/ui/navigation/DrawerContent.kt | 6 +- .../amethyst/ui/screen/FeedView.kt | 4 +- .../amethyst/ui/screen/StringFeedState.kt | 11 + .../amethyst/ui/screen/StringFeedView.kt | 121 +++++++++ .../amethyst/ui/screen/StringFeedViewModel.kt | 107 ++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../ui/screen/loggedIn/HiddenUsersScreen.kt | 244 +++++++++++++++++- app/src/main/res/values/strings.xml | 3 + .../quartz/events/BookmarkListEvent.kt | 1 + .../com/vitorpamplona/quartz/events/Event.kt | 16 +- .../quartz/events/EventInterface.kt | 4 + .../quartz/events/GeneralListEvent.kt | 3 +- .../quartz/events/PeopleListEvent.kt | 179 ++++++++++--- 16 files changed, 843 insertions(+), 74 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a07d08182..740f7607d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -105,6 +105,7 @@ class Account( data class LiveHiddenUsers( val hiddenUsers: ImmutableSet, val spammers: ImmutableSet, + val hiddenWords: ImmutableSet, val showSensitiveContent: Boolean? ) @@ -129,23 +130,25 @@ class Account( LiveHiddenUsers( hiddenUsers = persistentSetOf(), - spammers = localLive?.account?.transientHiddenUsers - ?: persistentSetOf(), + hiddenWords = persistentSetOf(), + spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(), showSensitiveContent = showSensitiveContent ) } else { blockList.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[blockList.id] val liveBlockedUsers = blockList.publicAndPrivateUsers(blockList.decryptedContent ?: "") + val liveBlockedWords = blockList.publicAndPrivateWords(blockList.decryptedContent ?: "") LiveHiddenUsers( hiddenUsers = liveBlockedUsers, - spammers = localLive?.account?.transientHiddenUsers - ?: persistentSetOf(), + hiddenWords = liveBlockedWords, + spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(), showSensitiveContent = showSensitiveContent ) } } else { LiveHiddenUsers( hiddenUsers = persistentSetOf(), + hiddenWords = persistentSetOf(), spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(), showSensitiveContent = showSensitiveContent @@ -153,8 +156,10 @@ class Account( } } else { val liveBlockedUsers = blockList?.publicAndPrivateUsers(keyPair.privKey) + val liveBlockedWords = blockList?.publicAndPrivateWords(keyPair.privKey) LiveHiddenUsers( hiddenUsers = liveBlockedUsers ?: persistentSetOf(), + hiddenWords = liveBlockedWords ?: persistentSetOf(), spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(), showSensitiveContent = showSensitiveContent ) @@ -2276,6 +2281,157 @@ class Account( return returningList } + fun hideWord(word: String) { + val blockList = migrateHiddenUsersIfNeeded(getBlockList()) + if (loginWithExternalSigner) { + val id = blockList?.id + val encryptedContent = if (id == null) { + val privateTags = listOf(listOf("word", word)) + val msg = Event.mapper.writeValueAsString(privateTags) + + ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), "encrypted") + val encryptedContent = ExternalSignerUtils.content["encrypted"] ?: "" + ExternalSignerUtils.content.remove("encrypted") + if (encryptedContent.isBlank()) return + encryptedContent + } else { + var decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id] + if (decryptedContent == null) { + ExternalSignerUtils.decrypt(blockList.content, keyPair.pubKey.toHexKey(), id) + val content = ExternalSignerUtils.content[id] ?: "" + if (content.isBlank()) return + decryptedContent = content + } + + val privateTags = blockList.privateTagsOrEmpty(decryptedContent).plus(element = listOf("word", word)) + val msg = Event.mapper.writeValueAsString(privateTags) + ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), id) + val eventContent = ExternalSignerUtils.content[id] ?: "" + if (eventContent.isBlank()) return + eventContent + } + + var event = if (blockList != null) { + PeopleListEvent.addWord( + earlierVersion = blockList, + word = word, + isPrivate = true, + pubKey = keyPair.pubKey.toHexKey(), + encryptedContent + ) + } else { + PeopleListEvent.createListWithWord( + name = PeopleListEvent.blockList, + word = word, + isPrivate = true, + pubKey = keyPair.pubKey.toHexKey(), + encryptedContent + ) + } + + ExternalSignerUtils.openSigner(event) + + val eventContent = ExternalSignerUtils.content[event.id] ?: "" + if (eventContent.isBlank()) return + event = PeopleListEvent( + event.id, + event.pubKey, + event.createdAt, + event.tags, + event.content, + eventContent + ) + + Client.send(event) + LocalCache.consume(event) + } else { + val event = if (blockList != null) { + PeopleListEvent.addWord( + earlierVersion = blockList, + word = word, + isPrivate = true, + privateKey = keyPair.privKey!! + ) + } else { + PeopleListEvent.createListWithWord( + name = PeopleListEvent.blockList, + word = word, + isPrivate = true, + privateKey = keyPair.privKey!! + ) + } + + Client.send(event) + LocalCache.consume(event) + } + + live.invalidateData() + saveable.invalidateData() + } + + fun showWord(word: String) { + val blockList = migrateHiddenUsersIfNeeded(getBlockList()) + + if (blockList != null) { + if (loginWithExternalSigner) { + val content = blockList.content + val encryptedContent = if (content.isBlank()) { + val privateTags = listOf(listOf("word", word)) + val msg = Event.mapper.writeValueAsString(privateTags) + + ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), blockList.id) + val eventContent = ExternalSignerUtils.content[blockList.id] ?: "" + if (eventContent.isBlank()) return + eventContent + } else { + var decryptedContent = ExternalSignerUtils.cachedDecryptedContent[blockList.id] + if (decryptedContent == null) { + ExternalSignerUtils.decrypt(blockList.content, keyPair.pubKey.toHexKey(), blockList.id) + val eventContent = ExternalSignerUtils.content[blockList.id] ?: "" + if (eventContent.isBlank()) return + decryptedContent = eventContent + } + val privateTags = blockList.privateTagsOrEmpty(decryptedContent).minus(element = listOf("word", word)) + val msg = Event.mapper.writeValueAsString(privateTags) + ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), blockList.id) + val eventContent = ExternalSignerUtils.content[blockList.id] ?: "" + if (eventContent.isBlank()) return + eventContent + } + + var event = PeopleListEvent.removeTag( + earlierVersion = blockList, + tag = word, + isPrivate = true, + pubKey = keyPair.pubKey.toHexKey(), + encryptedContent + ) + + ExternalSignerUtils.openSigner(event) + val eventContent = ExternalSignerUtils.content[event.id] ?: "" + if (eventContent.isBlank()) return + event = PeopleListEvent.create(event, eventContent) + + Client.send(event) + LocalCache.consume(event) + } else { + val event = PeopleListEvent.removeWord( + earlierVersion = blockList, + word = word, + isPrivate = true, + privateKey = keyPair.privKey!! + ) + + Client.send(event) + LocalCache.consume(event) + } + } + + transientHiddenUsers = (transientHiddenUsers - word).toImmutableSet() + live.invalidateData() + saveable.invalidateData() + } + fun hideUser(pubkeyHex: String) { val blockList = migrateHiddenUsersIfNeeded(getBlockList()) if (loginWithExternalSigner) { @@ -2284,7 +2440,7 @@ class Account( val privateTags = listOf(listOf("p", pubkeyHex)) val msg = Event.mapper.writeValueAsString(privateTags) - ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), "encrypt") + ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), "encrypted") val encryptedContent = ExternalSignerUtils.content["encrypted"] ?: "" ExternalSignerUtils.content.remove("encrypted") if (encryptedContent.isBlank()) return @@ -2393,9 +2549,9 @@ class Account( eventContent } - var event = PeopleListEvent.addUser( + var event = PeopleListEvent.removeTag( earlierVersion = blockList, - pubKeyHex = pubkeyHex, + tag = pubkeyHex, isPrivate = true, pubKey = keyPair.pubKey.toHexKey(), encryptedContent diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index e1e2744ed..93d378a12 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -673,16 +673,24 @@ open class Note(val idHex: String) { } fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean { - if (event == null) return false + val thisEvent = event ?: return false - val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) { + val isBoostedNoteHidden = if (thisEvent is GenericRepostEvent || thisEvent is RepostEvent || thisEvent is CommunityPostApprovalEvent) { replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false } else { false } - val isSensitive = event?.isSensitive() ?: false - return isBoostedNoteHidden || + val isHiddenByWord = if (thisEvent is BaseTextNoteEvent) { + accountChoices.hiddenWords.any { + thisEvent.content.contains(it, true) + } + } else { + false + } + + val isSensitive = thisEvent.isSensitive() + return isBoostedNoteHidden || isHiddenByWord || accountChoices.hiddenUsers.contains(author?.pubkeyHex) || accountChoices.spammers.contains(author?.pubkeyHex) || (isSensitive && accountChoices.showSensitiveContent == false) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt index 2cd19a956..a3f967ff4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HiddenAccountsFeedFilter.kt @@ -32,6 +32,32 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter() { } } +class HiddenWordsFeedFilter(val account: Account) : FeedFilter() { + override fun feedKey(): String { + return account.userProfile().pubkeyHex + } + + override fun showHiddenKey(): Boolean { + return true + } + + override fun feed(): List { + val blockList = account.getBlockList() + val decryptedContent = blockList?.decryptedContent ?: "" + if (account.loginWithExternalSigner) { + if (decryptedContent.isEmpty()) return emptyList() + + return blockList + ?.publicAndPrivateWords(decryptedContent)?.toList() + ?: emptyList() + } + + return blockList + ?.publicAndPrivateWords(account.keyPair.privKey)?.toList() + ?: emptyList() + } +} + class SpammerAccountsFeedFilter(val account: Account) : FeedFilter() { override fun feedKey(): String { return account.userProfile().pubkeyHex diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 1aa7c98ca..b047dffec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -292,7 +292,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo singleLine = true, trailingIcon = { if (hasChanged) { - UserStatusSendButton() { + SendButton() { accountViewModel.createStatus(currentStatus.value) focusManager.clearFocus(true) } @@ -338,7 +338,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo singleLine = true, trailingIcon = { if (hasChanged) { - UserStatusSendButton() { + SendButton() { accountViewModel.updateStatus(it, thisStatus.value) focusManager.clearFocus(true) } @@ -356,7 +356,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo } @Composable -fun UserStatusSendButton(onClick: () -> Unit) { +fun SendButton(onClick: () -> Unit) { IconButton( modifier = Size26Modifier, onClick = onClick diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index d3a3bda17..9dd16fc50 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -258,9 +258,7 @@ fun FeedError(errorMessage: String, onRefresh: () -> Unit) { @Composable fun FeedEmpty(onRefresh: () -> Unit) { Column( - Modifier - .fillMaxHeight() - .fillMaxWidth(), + Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt new file mode 100644 index 000000000..a1c94ac42 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedState.kt @@ -0,0 +1,11 @@ +package com.vitorpamplona.amethyst.ui.screen + +import androidx.compose.runtime.MutableState +import kotlinx.collections.immutable.ImmutableList + +sealed class StringFeedState { + object Loading : StringFeedState() + class Loaded(val feed: MutableState>) : StringFeedState() + object Empty : StringFeedState() + class FeedError(val errorMessage: String) : StringFeedState() +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt new file mode 100644 index 000000000..5266a3d89 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedView.kt @@ -0,0 +1,121 @@ +package com.vitorpamplona.amethyst.ui.screen + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R + +@Composable +fun RefreshingFeedStringFeedView( + viewModel: StringFeedViewModel, + enablePullRefresh: Boolean = true, + inner: @Composable (String) -> Unit +) { + RefresheableView(viewModel, enablePullRefresh) { + StringFeedView(viewModel, inner = inner) + } +} + +@Composable +fun StringFeedView( + viewModel: StringFeedViewModel, + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + inner: @Composable (String) -> Unit +) { + val feedState by viewModel.feedContent.collectAsState() + + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is StringFeedState.Empty -> { + StringFeedEmpty(pre, post) { + viewModel.invalidateData() + } + } + + is StringFeedState.FeedError -> { + FeedError(state.errorMessage) { + viewModel.invalidateData() + } + } + + is StringFeedState.Loaded -> { + FeedLoaded(state, pre, post, inner) + } + + is StringFeedState.Loading -> { + LoadingFeed() + } + } + } +} + +@Composable +fun StringFeedEmpty( + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + onRefresh: () -> Unit +) { + Column() { + pre?.let { it() } + + Column( + Modifier.weight(1f).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text(stringResource(R.string.feed_is_empty)) + OutlinedButton(onClick = onRefresh) { + Text(text = stringResource(R.string.refresh)) + } + } + + post?.let { it() } + } +} + +@Composable +private fun FeedLoaded( + state: StringFeedState.Loaded, + pre: (@Composable () -> Unit)? = null, + post: (@Composable () -> Unit)? = null, + inner: @Composable (String) -> Unit +) { + val listState = rememberLazyListState() + + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ), + state = listState + ) { + item { + pre?.let { it() } + } + + itemsIndexed(state.feed.value, key = { _, item -> item }) { _, item -> + inner(item) + } + + item { + post?.let { it() } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt new file mode 100644 index 000000000..fe17b88c9 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/StringFeedViewModel.kt @@ -0,0 +1,107 @@ +package com.vitorpamplona.amethyst.ui.screen + +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.ui.components.BundledUpdate +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.amethyst.ui.dal.HiddenWordsFeedFilter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class NostrHiddenWordsFeedViewModel(val account: Account) : StringFeedViewModel( + HiddenWordsFeedFilter(account) +) { + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrHiddenWordsFeedViewModel { + return NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel + } + } +} + +@Stable +open class StringFeedViewModel(val dataSource: FeedFilter) : ViewModel(), InvalidatableViewModel { + private val _feedContent = MutableStateFlow(StringFeedState.Loading) + val feedContent = _feedContent.asStateFlow() + + private fun refresh() { + viewModelScope.launch(Dispatchers.Default) { + refreshSuspended() + } + } + + private fun refreshSuspended() { + checkNotInMainThread() + + val notes = dataSource.loadTop().toImmutableList() + + val oldNotesState = _feedContent.value + if (oldNotesState is StringFeedState.Loaded) { + // Using size as a proxy for has changed. + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { + updateFeed(notes) + } + } else { + updateFeed(notes) + } + } + + private fun updateFeed(notes: ImmutableList) { + viewModelScope.launch(Dispatchers.Main) { + val currentState = _feedContent.value + if (notes.isEmpty()) { + _feedContent.update { StringFeedState.Empty } + } else if (currentState is StringFeedState.Loaded) { + // updates the current list + currentState.feed.value = notes + } else { + _feedContent.update { StringFeedState.Loaded(mutableStateOf(notes)) } + } + } + } + + private val bundler = BundledUpdate(250, Dispatchers.IO) + + override fun invalidateData(ignoreIfDoing: Boolean) { + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(ignoreIfDoing) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + } + } + } + + var collectorJob: Job? = null + + init { + Log.d("Init", this.javaClass.simpleName) + collectorJob = viewModelScope.launch(Dispatchers.IO) { + checkNotInMainThread() + + LocalCache.live.newEventBundles.collect { newNotes -> + checkNotInMainThread() + + invalidateData() + } + } + } + + override fun onCleared() { + bundler.cancel() + collectorJob?.cancel() + super.onCleared() + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 764078896..91455dedf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -491,6 +491,12 @@ class AccountViewModel(val account: Account) : ViewModel(), Dao { } } + fun hide(word: String) { + viewModelScope.launch(Dispatchers.IO) { + account.hideWord(word) + } + } + fun showUser(pubkeyHex: String) { viewModelScope.launch(Dispatchers.IO) { account.showUser(pubkeyHex) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt index 69938b796..298d345a2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt @@ -1,20 +1,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Checkbox +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.ScrollableTabRow import androidx.compose.material.Tab -import androidx.compose.material.TabRow import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -23,19 +34,35 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.SendButton import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrHiddenWordsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefresheableView import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView +import com.vitorpamplona.amethyst.ui.screen.StringFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable @@ -44,17 +71,22 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit) factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account) ) + val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = viewModel( + factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account) + ) + val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = viewModel( factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account) ) - HiddenUsersScreen(hiddenFeedViewModel, spammerFeedViewModel, accountViewModel, nav) + HiddenUsersScreen(hiddenFeedViewModel, hiddenWordsFeedViewModel, spammerFeedViewModel, accountViewModel, nav) } @OptIn(ExperimentalFoundationApi::class) @Composable fun HiddenUsersScreen( hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel, + hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, spammerFeedViewModel: NostrSpammerAccountsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -65,6 +97,7 @@ fun HiddenUsersScreen( val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { println("Hidden Users Start") + hiddenWordsViewModel.invalidateData() hiddenFeedViewModel.invalidateData() spammerFeedViewModel.invalidateData() } @@ -78,7 +111,7 @@ fun HiddenUsersScreen( Column(Modifier.fillMaxHeight()) { Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) { - val pagerState = rememberPagerState() { 2 } + val pagerState = rememberPagerState() { 3 } val coroutineScope = rememberCoroutineScope() var warnAboutReports by remember { mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) } var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) } @@ -109,8 +142,9 @@ fun HiddenUsersScreen( Text(stringResource(R.string.filter_spam_from_strangers)) } - TabRow( + ScrollableTabRow( backgroundColor = MaterialTheme.colors.background, + edgePadding = 8.dp, selectedTabIndex = pagerState.currentPage, modifier = TabRowHeight ) { @@ -128,25 +162,98 @@ fun HiddenUsersScreen( Text(text = stringResource(R.string.spamming_users)) } ) + Tab( + selected = pagerState.currentPage == 2, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + text = { + Text(text = stringResource(R.string.hidden_words)) + } + ) } HorizontalPager(state = pagerState) { page -> when (page) { - 0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel, nav) - 1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel, nav) + 0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel) { + RefreshingFeedUserFeedView(hiddenFeedViewModel, accountViewModel, nav) + } + 1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel) { + RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav) + } + 2 -> HiddenWordsFeed(hiddenWordsViewModel, accountViewModel) } } } } } +@Composable +private fun HiddenWordsFeed( + hiddenWordsViewModel: NostrHiddenWordsFeedViewModel, + accountViewModel: AccountViewModel +) { + RefresheableView(hiddenWordsViewModel, false) { + StringFeedView( + hiddenWordsViewModel, + post = { AddMuteWordTextField(accountViewModel) } + ) { + MutedWordHeader(tag = it, account = accountViewModel) + } + } +} + +@Composable +private fun AddMuteWordTextField(accountViewModel: AccountViewModel) { + Row { + val currentWordToAdd = remember { + mutableStateOf("") + } + val hasChanged by remember { + derivedStateOf { + currentWordToAdd.value != "" + } + } + + OutlinedTextField( + value = currentWordToAdd.value, + onValueChange = { currentWordToAdd.value = it }, + label = { Text(text = stringResource(R.string.hide_new_word_label)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringResource(R.string.hide_new_word_label), + color = MaterialTheme.colors.placeholderText + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, + capitalization = KeyboardCapitalization.Sentences + ), + keyboardActions = KeyboardActions( + onSend = { + accountViewModel.hide(currentWordToAdd.value) + currentWordToAdd.value = "" + } + ), + singleLine = true, + trailingIcon = { + if (hasChanged) { + SendButton { + accountViewModel.hide(currentWordToAdd.value) + currentWordToAdd.value = "" + } + } + } + ) + } +} + @Composable fun RefreshingUserFeedView( feedViewModel: UserFeedViewModel, accountViewModel: AccountViewModel, - nav: (String) -> Unit + inner: @Composable () -> Unit ) { WatchAccountAndBlockList(feedViewModel, accountViewModel) - RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav) + inner() } @Composable @@ -161,3 +268,124 @@ fun WatchAccountAndBlockList( feedViewModel.invalidateData() } } + +@Composable +fun MutedWordHeader(tag: String, modifier: Modifier = StdPadding, account: AccountViewModel) { + Column( + Modifier.fillMaxWidth() + ) { + Column(modifier = modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + tag, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + + MutedWordActionOptions(tag, account) + } + } + + Divider( + thickness = 0.25.dp + ) + } +} + +@Composable +fun MutedWordActionOptions( + word: String, + accountViewModel: AccountViewModel +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val isMutedWord by accountViewModel.account.liveHiddenUsers.map { + word in it.hiddenWords + }.distinctUntilChanged().observeAsState() + + if (isMutedWord == true) { + ShowWordButton { + if (!accountViewModel.isWriteable()) { + if (accountViewModel.loggedInWithExternalSigner()) { + scope.launch(Dispatchers.IO) { + accountViewModel.account.showWord(word) + } + } else { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow), + Toast.LENGTH_SHORT + ) + .show() + } + } + } else { + scope.launch(Dispatchers.IO) { + accountViewModel.account.showWord(word) + } + } + } + } else { + HideWordButton { + if (!accountViewModel.isWriteable()) { + if (accountViewModel.loggedInWithExternalSigner()) { + scope.launch(Dispatchers.IO) { + accountViewModel.account.hideWord(word) + } + } else { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_follow), + Toast.LENGTH_SHORT + ) + .show() + } + } + } else { + scope.launch(Dispatchers.IO) { + accountViewModel.account.hideWord(word) + } + } + } + } +} + +@Composable +fun HideWordButton(onClick: () -> Unit) { + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.block_only), color = Color.White) + } +} + +@Composable +fun ShowWordButton(text: Int = R.string.unblock, onClick: () -> Unit) { + Button( + modifier = Modifier.padding(start = 3.dp), + onClick = onClick, + shape = ButtonBorder, + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e318a920..f494572f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -595,4 +595,7 @@ No Wallets found to pay a lightning invoice (Error: %1$s). Please install a Lightning wallet to use zaps No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps + + Hidden Words + Hide new word or sentence diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt index f99f837bf..9c93601f3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/BookmarkListEvent.kt @@ -16,6 +16,7 @@ class BookmarkListEvent( content: String, sig: HexKey ) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient var decryptedContent = "" companion object { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 20851034d..4b84baf68 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -125,14 +125,20 @@ open class Event( override fun matchTag1With(text: String) = tags.any { it.size > 1 && it[1].contains(text, true) } - override fun isTaggedUser(idHex: String) = tags.any { it.size > 1 && it[0] == "p" && it[1] == idHex } - override fun isTaggedUsers(idHexes: Set) = tags.any { it.size > 1 && it[0] == "p" && it[1] in idHexes } + override fun isTagged(key: String, tag: String) = tags.any { it.size > 1 && it[0] == key && it[1] == tag } - override fun isTaggedEvent(idHex: String) = tags.any { it.size > 1 && it[0] == "e" && it[1] == idHex } + override fun isAnyTagged(key: String, tags: Set) = this.tags.any { it.size > 1 && it[0] == key && it[1] in tags } - override fun isTaggedAddressableNote(idHex: String) = tags.any { it.size > 1 && it[0] == "a" && it[1] == idHex } + override fun isTaggedWord(word: String) = isTagged("word", word) - override fun isTaggedAddressableNotes(idHexes: Set) = tags.any { it.size > 1 && it[0] == "a" && it[1] in idHexes } + override fun isTaggedUser(idHex: String) = isTagged("p", idHex) + override fun isTaggedUsers(idHexes: Set) = isAnyTagged("p", idHexes) + + override fun isTaggedEvent(idHex: String) = isTagged("e", idHex) + + override fun isTaggedAddressableNote(idHex: String) = isTagged("a", idHex) + + override fun isTaggedAddressableNotes(idHexes: Set) = isAnyTagged( "a", idHexes) override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index f7d0102bb..52e3f1620 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -29,6 +29,10 @@ interface EventInterface { fun hasValidSignature(): Boolean + fun isTagged(key: String, tag: String): Boolean + fun isAnyTagged(key: String, tags: Set): Boolean + fun isTaggedWord(word: String): Boolean + fun isTaggedUser(idHex: String): Boolean fun isTaggedUsers(idHex: Set): Boolean diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt index 9a98b586c..e52094c66 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/GeneralListEvent.kt @@ -66,7 +66,8 @@ abstract class GeneralListEvent( return privateTagsCache } - fun privateTagsOrEmpty(privKey: ByteArray): List> { + fun privateTagsOrEmpty(privKey: ByteArray?): List> { + if (privKey == null) return emptyList() return privateTags(privKey) ?: emptyList() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt index b3e814fcd..9f08e67d5 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/PeopleListEvent.kt @@ -18,62 +18,94 @@ class PeopleListEvent( content: String, sig: HexKey ) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) { + @Transient var decryptedContent: String? = null + @Transient var publicAndPrivateUserCache: ImmutableSet? = null + @Transient + var publicAndPrivateWordCache: ImmutableSet? = null + + fun filterTagList(key: String, privateTags: List>?): ImmutableSet { + val privateUserList = privateTags?.let { + it.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() + } ?: emptySet() + val publicUserList = tags.filter { it.size > 1 && it[0] == key }.map { it[1] }.toSet() + + return (privateUserList + publicUserList).toImmutableSet() + } + + fun publicAndPrivateWords(privateKey: ByteArray?): ImmutableSet { + publicAndPrivateWordCache?.let { + return it + } + + publicAndPrivateWordCache = filterTagList("word", privateTagsOrEmpty(privKey = privateKey)) + + return publicAndPrivateWordCache ?: persistentSetOf() + } fun publicAndPrivateUsers(privateKey: ByteArray?): ImmutableSet { publicAndPrivateUserCache?.let { return it } - val privateUserList = privateKey?.let { - privateTagsOrEmpty(privKey = it).filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet() - } ?: emptySet() - val publicUserList = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet() - - publicAndPrivateUserCache = (privateUserList + publicUserList).toImmutableSet() + publicAndPrivateUserCache = filterTagList("p", privateTagsOrEmpty(privKey = privateKey)) return publicAndPrivateUserCache ?: persistentSetOf() } + fun publicAndPrivateWords(decryptedContent: String): ImmutableSet { + publicAndPrivateWordCache?.let { + return it + } + + publicAndPrivateWordCache = filterTagList("word", privateTagsOrEmpty(decryptedContent)) + + return publicAndPrivateWordCache ?: persistentSetOf() + } + fun publicAndPrivateUsers(decryptedContent: String): ImmutableSet { publicAndPrivateUserCache?.let { return it } - val privateUserList = privateTagsOrEmpty(decryptedContent).filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet() - - val publicUserList = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }.toSet() - - publicAndPrivateUserCache = (privateUserList + publicUserList).toImmutableSet() + publicAndPrivateUserCache = filterTagList("p", privateTagsOrEmpty(decryptedContent)) return publicAndPrivateUserCache ?: persistentSetOf() } - fun isTaggedUser(idHex: String, isPrivate: Boolean, privateKey: ByteArray): Boolean { + fun isTagged(key: String, tag: String, isPrivate: Boolean, privateKey: ByteArray): Boolean { return if (isPrivate) { - privateTagsOrEmpty(privKey = privateKey).any { it.size > 1 && it[0] == "p" && it[1] == idHex } + privateTagsOrEmpty(privKey = privateKey).any { it.size > 1 && it[0] == key && it[1] == tag } } else { - isTaggedUser(idHex) + isTagged(key, tag) } } - fun isTaggedUser(idHex: String, isPrivate: Boolean, content: String): Boolean { + fun isTagged(key: String, tag: String, isPrivate: Boolean, decryptedContent: String): Boolean { return if (isPrivate) { - privateTagsOrEmpty(content).any { it.size > 1 && it[0] == "p" && it[1] == idHex } + privateTagsOrEmpty(decryptedContent).any { it.size > 1 && it[0] == key && it[1] == tag } } else { - isTaggedUser(idHex) + isTagged(key, tag) } } + fun isTaggedWord(word: String, isPrivate: Boolean, privateKey: ByteArray) = isTagged( "word", word, isPrivate, privateKey) + + fun isTaggedUser(idHex: String, isPrivate: Boolean, privateKey: ByteArray) = isTagged( "p", idHex, isPrivate, privateKey) + + fun isTaggedUser(idHex: String, isPrivate: Boolean, decryptedContent: String) = isTagged( "p", idHex, isPrivate, decryptedContent) + + fun isTaggedWord(idHex: String, isPrivate: Boolean, decryptedContent: String) = isTagged( "word", idHex, isPrivate, decryptedContent) + companion object { const val kind = 30000 const val blockList = "mute" - fun createListWithUser(name: String, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { + fun createListWithTag(name: String, key: String, tag: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { return if (isPrivate) { create( - content = encryptTags(listOf(listOf("p", pubKeyHex)), privateKey), + content = encryptTags(listOf(listOf(key, tag)), privateKey), tags = listOf(listOf("d", name)), privateKey = privateKey, createdAt = createdAt @@ -81,13 +113,21 @@ class PeopleListEvent( } else { create( content = "", - tags = listOf(listOf("d", name), listOf("p", pubKeyHex)), + tags = listOf(listOf("d", name), listOf(key, tag)), privateKey = privateKey, createdAt = createdAt ) } } + fun createListWithUser(name: String, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { + return createListWithTag(name, "p", pubKeyHex, isPrivate, privateKey, createdAt) + } + + fun createListWithWord(name: String, word: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { + return createListWithTag(name, "word", word, isPrivate, privateKey, createdAt) + } + fun createListWithUser(name: String, pubKeyHex: String, isPrivate: Boolean, pubKey: HexKey, encryptedContent: String, createdAt: Long = TimeUtils.now()): PeopleListEvent { return if (isPrivate) { create( @@ -106,6 +146,24 @@ class PeopleListEvent( } } + fun createListWithWord(name: String, word: String, isPrivate: Boolean, pubKey: HexKey, encryptedContent: String, createdAt: Long = TimeUtils.now()): PeopleListEvent { + return if (isPrivate) { + create( + content = encryptedContent, + tags = listOf(listOf("d", name)), + pubKey = pubKey, + createdAt = createdAt + ) + } else { + create( + content = "", + tags = listOf(listOf("d", name), listOf("word", word)), + pubKey = pubKey, + createdAt = createdAt + ) + } + } + fun addUsers(earlierVersion: PeopleListEvent, listPubKeyHex: List, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { return if (isPrivate) { create( @@ -135,6 +193,24 @@ class PeopleListEvent( } } + fun addWord(earlierVersion: PeopleListEvent, word: String, isPrivate: Boolean, pubKey: HexKey, encryptedContent: String, createdAt: Long = TimeUtils.now()): PeopleListEvent { + return if (isPrivate) { + create( + content = encryptedContent, + tags = earlierVersion.tags, + pubKey = pubKey, + createdAt = createdAt + ) + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.plus(element = listOf("word", word)), + pubKey = pubKey, + createdAt = createdAt + ) + } + } + fun addUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, pubKey: HexKey, encryptedContent: String, createdAt: Long = TimeUtils.now()): PeopleListEvent { return if (isPrivate) { create( @@ -153,13 +229,40 @@ class PeopleListEvent( } } + fun removeTag(earlierVersion: PeopleListEvent, tag: String, isPrivate: Boolean, pubKey: HexKey, encryptedContent: String, createdAt: Long = TimeUtils.now()): PeopleListEvent { + return if (isPrivate) { + create( + content = encryptedContent, + tags = earlierVersion.tags, + pubKey = pubKey, + createdAt = createdAt + ) + } else { + create( + content = earlierVersion.content, + tags = earlierVersion.tags.filter { it.size > 1 && it[1] != tag }, + pubKey = pubKey, + createdAt = createdAt + ) + } + } + + fun addWord(earlierVersion: PeopleListEvent, word: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { + return addTag(earlierVersion, "word", word, isPrivate, privateKey, createdAt) + } + fun addUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { - if (earlierVersion.isTaggedUser(pubKeyHex, isPrivate, privateKey)) return earlierVersion + return addTag(earlierVersion, "p", pubKeyHex, isPrivate, privateKey, createdAt) + } + + + fun addTag(earlierVersion: PeopleListEvent, key: String, tag: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { + if (earlierVersion.isTagged(key, tag, isPrivate, privateKey)) return earlierVersion return if (isPrivate) { create( content = encryptTags( - privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).plus(element = listOf("p", pubKeyHex)), + privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).plus(element = listOf(key, tag)), privateKey = privateKey ), tags = earlierVersion.tags, @@ -169,48 +272,38 @@ class PeopleListEvent( } else { create( content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = listOf("p", pubKeyHex)), + tags = earlierVersion.tags.plus(element = listOf(key, tag)), privateKey = privateKey, createdAt = createdAt ) } } - fun removeUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, encryptedContent: String, pubKey: HexKey, createdAt: Long = TimeUtils.now()): PeopleListEvent { - return if (isPrivate) { - create( - content = encryptedContent, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }, - pubKey = pubKey, - createdAt = createdAt - ) - } else { - create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }, - pubKey = pubKey, - createdAt = createdAt - ) - } + fun removeWord(earlierVersion: PeopleListEvent, word: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { + return removeTag(earlierVersion, "word", word, isPrivate, privateKey, createdAt) } fun removeUser(earlierVersion: PeopleListEvent, pubKeyHex: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { - if (!earlierVersion.isTaggedUser(pubKeyHex, isPrivate, privateKey)) return earlierVersion + return removeTag(earlierVersion, "p", pubKeyHex, isPrivate, privateKey, createdAt) + } + + fun removeTag(earlierVersion: PeopleListEvent, key: String, tag: String, isPrivate: Boolean, privateKey: ByteArray, createdAt: Long = TimeUtils.now()): PeopleListEvent { + if (!earlierVersion.isTagged(key, tag, isPrivate, privateKey)) return earlierVersion return if (isPrivate) { create( content = encryptTags( - privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).filter { it.size > 1 && it[1] != pubKeyHex }, + privateTags = earlierVersion.privateTagsOrEmpty(privKey = privateKey).filter { it.size > 1 && !(it[0] == key && it[1] == tag) }, privateKey = privateKey ), - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }, + tags = earlierVersion.tags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }, privateKey = privateKey, createdAt = createdAt ) } else { create( content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != pubKeyHex }, + tags = earlierVersion.tags.filter { it.size > 1 && !(it[0] == key && it[1] == tag) }, privateKey = privateKey, createdAt = createdAt )