Compare commits

..
13 Commits
27 changed files with 1060 additions and 174 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 305
versionName "0.77.8"
versionCode 306
versionName "0.78.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -7,6 +7,7 @@ import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
@@ -16,6 +17,7 @@ import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.quartz.crypto.KeyPair
@@ -76,6 +78,7 @@ private object PrefKeys {
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
}
@@ -272,6 +275,11 @@ object LocalPreferences {
} else {
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview.prefCode!!)
}
if (account.settings.automaticallyHideNavigationBars.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, account.settings.automaticallyHideNavigationBars.prefCode!!)
}
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
}.apply()
}
@@ -417,6 +425,11 @@ object LocalPreferences {
} else {
ConnectivityType.ALWAYS
}
settings.automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
} else {
BooleanType.ALWAYS
}
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
}
@@ -142,6 +142,13 @@ object ServiceManager {
LocalCache.pruneExpiredEvents()
}
}
fun restartIfDifferentAccount(account: Account, context: Context) {
if (this.account != account) {
pause()
start(account, context)
}
}
}
object SingletonDiskCache {
@@ -105,6 +105,7 @@ class Account(
data class LiveHiddenUsers(
val hiddenUsers: ImmutableSet<String>,
val spammers: ImmutableSet<String>,
val hiddenWords: ImmutableSet<String>,
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
)
@@ -180,6 +185,14 @@ class Account(
saveable.invalidateData()
}
fun updateAutomaticallyHideHavBars(
automaticallyHideHavBars: BooleanType
) {
settings.automaticallyHideNavigationBars = automaticallyHideHavBars
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: ConnectivityType
) {
@@ -2268,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) {
@@ -2276,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
@@ -2385,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
@@ -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)
@@ -8,7 +8,8 @@ class Settings(
var preferredLanguage: String? = null,
var automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS
)
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
@@ -38,3 +39,28 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
}
}
}
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never)
}
fun parseBooleanType(code: Boolean?): BooleanType {
return when (code) {
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
BooleanType.NEVER.prefCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}
fun parseBooleanType(screenCode: Int): BooleanType {
return when (screenCode) {
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
BooleanType.NEVER.screenCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}
@@ -32,6 +32,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -281,70 +282,74 @@ fun GetVideoController(
UUID.randomUUID().toString()
}
val scope = rememberCoroutineScope()
// Prepares a VideoPlayer from the foreground service.
DisposableEffect(key1 = videoUri) {
// If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player.
if (controller.value == null) {
PlaybackClientController.prepareController(
uid,
videoUri,
nostrUriCallback,
context
) {
// checks again because of race conditions.
if (controller.value == null) { // still prone to race conditions.
controller.value = it
scope.launch(Dispatchers.IO) {
if (controller.value == null) {
PlaybackClientController.prepareController(
uid,
videoUri,
nostrUriCallback,
context
) {
// checks again because of race conditions.
if (controller.value == null) { // still prone to race conditions.
controller.value = it
if (!it.isPlaying) {
if (keepPlayingMutex?.isPlaying == true) {
// There is a video playing, start this one on mute.
controller.value?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controller.value?.volume = if (defaultToStart) 0f else 1f
}
}
controller.value?.setMediaItem(mediaItem)
controller.value?.prepare()
} else if (controller.value != it) {
// discards the new controller because there is an existing one
it.stop()
it.release()
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
if (!it.isPlaying) {
if (keepPlayingMutex?.isPlaying == true) {
// There is a video playing, start this one on mute.
it.volume = 0f
controller.value?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
controller.value?.volume = if (defaultToStart) 0f else 1f
}
}
it.setMediaItem(mediaItem)
it.prepare()
controller.value?.setMediaItem(mediaItem)
controller.value?.prepare()
} else if (controller.value != it) {
// discards the new controller because there is an existing one
it.stop()
it.release()
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
}
it.setMediaItem(mediaItem)
it.prepare()
}
}
}
}
}
} else {
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
}
} else {
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
}
it.setMediaItem(mediaItem)
it.prepare()
it.setMediaItem(mediaItem)
it.prepare()
}
}
}
}
@@ -366,34 +371,36 @@ fun GetVideoController(
if (event == Lifecycle.Event.ON_RESUME) {
// if the controller is null, restarts the controller with a new one
// if the controller is not null, just continue playing what the controller was playing
if (controller.value == null) {
PlaybackClientController.prepareController(
uid,
videoUri,
nostrUriCallback,
context
) {
// checks again to make sure no other thread has created a controller.
if (controller.value == null) {
controller.value = it
scope.launch(Dispatchers.IO) {
if (controller.value == null) {
PlaybackClientController.prepareController(
uid,
videoUri,
nostrUriCallback,
context
) {
// checks again to make sure no other thread has created a controller.
if (controller.value == null) {
controller.value = it
if (!it.isPlaying) {
if (keepPlayingMutex?.isPlaying == true) {
// There is a video playing, start this one on mute.
controller.value?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controller.value?.volume = if (defaultToStart) 0f else 1f
if (!it.isPlaying) {
if (keepPlayingMutex?.isPlaying == true) {
// There is a video playing, start this one on mute.
controller.value?.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
controller.value?.volume = if (defaultToStart) 0f else 1f
}
}
}
controller.value?.setMediaItem(mediaItem)
controller.value?.prepare()
} else if (controller.value != it) {
// discards the new controller because there is an existing one
it.stop()
it.release()
controller.value?.setMediaItem(mediaItem)
controller.value?.prepare()
} else if (controller.value != it) {
// discards the new controller because there is an existing one
it.stop()
it.release()
}
}
}
}
@@ -519,7 +526,8 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 100.dp)
.align(Alignment.Center)
} else {
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 100.dp)
.align(Alignment.Center)
},
@@ -32,6 +32,32 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
}
}
class HiddenWordsFeedFilter(val account: Account) : FeedFilter<String>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun showHiddenKey(): Boolean {
return true
}
override fun feed(): List<String> {
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<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
@@ -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
@@ -508,17 +508,19 @@ fun ListContent(
route = Route.BlockedUsers.route
)
IconRow(
title = stringResource(R.string.backup_keys),
icon = R.drawable.ic_key,
tint = MaterialTheme.colors.onBackground,
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
accountViewModel.account.keyPair.privKey?.let {
IconRow(
title = stringResource(R.string.backup_keys),
icon = R.drawable.ic_key,
tint = MaterialTheme.colors.onBackground,
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
}
backupDialogOpen = true
}
backupDialogOpen = true
}
)
)
}
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
IconRow(
@@ -2250,14 +2250,23 @@ fun RemoveButton(onClick: () -> Unit) {
}
@Composable
fun AddButton(text: Int = R.string.add, onClick: () -> Unit) {
fun AddButton(
text: Int = R.string.add,
isActive: Boolean = true,
modifier: Modifier = Modifier.padding(start = 3.dp),
onClick: () -> Unit
) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
modifier = modifier,
onClick = {
if (isActive) {
onClick()
}
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
),
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
) {
@@ -88,7 +88,7 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start(account, context)
ServiceManager.restartIfDifferentAccount(account, context)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)
@@ -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
) {
@@ -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<ImmutableList<String>>) : StringFeedState()
object Empty : StringFeedState()
class FeedError(val errorMessage: String) : StringFeedState()
}
@@ -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() }
}
}
}
@@ -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 <NostrHiddenWordsFeedViewModel : ViewModel> create(modelClass: Class<NostrHiddenWordsFeedViewModel>): NostrHiddenWordsFeedViewModel {
return NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel
}
}
}
@Stable
open class StringFeedViewModel(val dataSource: FeedFilter<String>) : ViewModel(), InvalidatableViewModel {
private val _feedContent = MutableStateFlow<StringFeedState>(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<String>) {
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()
}
}
@@ -12,6 +12,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
@@ -97,6 +98,12 @@ class AccountViewModel(val account: Account) : ViewModel(), Dao {
account.updateAutomaticallyShowUrlPreview(automaticallyShowUrlPreview)
}
fun updateAutomaticallyHideNavBars(
automaticallyHideHavBars: BooleanType
) {
account.updateAutomaticallyHideHavBars(automaticallyHideHavBars)
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: ConnectivityType
) {
@@ -484,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)
@@ -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,37 @@ 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.note.AddButton
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.HorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
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 +73,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 +99,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 +113,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 +144,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 +164,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(modifier = Modifier.padding(vertical = Size10dp)) {
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 = {
if (hasChanged) {
accountViewModel.hide(currentWordToAdd.value)
currentWordToAdd.value = ""
}
}
),
singleLine = true,
trailingIcon = {
AddButton(isActive = hasChanged, modifier = HorzPadding) {
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 +270,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)
}
}
@@ -42,6 +42,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
@@ -206,15 +207,18 @@ fun MainScreen(
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = bottomBarOffsetHeightPx.value + delta
val newOffset = bottomBarOffsetHeightPx.value + available.y
val currentRoute = navState.value?.destination?.route
bottomBarOffsetHeightPx.value = if (currentRoute !in InvertedLayouts) {
newOffset.coerceIn(-bottomBarHeightPx, 0f)
if (accountViewModel.account.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
bottomBarOffsetHeightPx.value = if (navState.value?.destination?.route !in InvertedLayouts) {
newOffset.coerceIn(-bottomBarHeightPx, 0f)
} else {
newOffset.coerceIn(0f, bottomBarHeightPx)
}
} else {
newOffset.coerceIn(0f, bottomBarHeightPx)
if (abs(bottomBarOffsetHeightPx.value) > 0.1) {
bottomBarOffsetHeightPx.value = 0f
}
}
return Offset.Zero
@@ -40,6 +40,7 @@ import androidx.core.os.LocaleListCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -122,10 +123,16 @@ fun SettingsScreen(
TitleExplainer(stringResource(R.string.dark))
)
val booleanItems = persistentListOf(
TitleExplainer(stringResource(ConnectivityType.ALWAYS.reourceId)),
TitleExplainer(stringResource(ConnectivityType.NEVER.reourceId))
)
val settings = accountViewModel.account.settings
val showImagesIndex = settings.automaticallyShowImages.screenCode
val videoIndex = settings.automaticallyStartPlayback.screenCode
val linkIndex = settings.automaticallyShowUrlPreview.screenCode
val hideNavBarsIndex = settings.automaticallyHideNavigationBars.screenCode
val themeIndex = themeViewModel.theme.value ?: 0
@@ -220,6 +227,22 @@ fun SettingsScreen(
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.automatically_hide_nav_bars,
R.string.automatically_hide_nav_bars_description,
booleanItems,
hideNavBarsIndex
) {
val automaticallyHideNavBars = parseBooleanType(it)
scope.launch(Dispatchers.IO) {
accountViewModel.updateAutomaticallyHideNavBars(automaticallyHideNavBars)
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
}
}
}
@@ -76,6 +76,9 @@ val StdPadding = Modifier.padding(10.dp)
val HalfHorzPadding = Modifier.padding(horizontal = 5.dp)
val HalfVertPadding = Modifier.padding(vertical = 5.dp)
val HorzPadding = Modifier.padding(horizontal = 10.dp)
val VertPadding = Modifier.padding(vertical = 10.dp)
val Size6Modifier = Modifier.size(6.dp)
val Size10Modifier = Modifier.size(10.dp)
val Size15Modifier = Modifier.size(15.dp)
@@ -428,6 +428,8 @@
<string name="automatically_load_images_gifs">Ladda automatiskt bilder/gif</string>
<string name="automatically_play_videos">Spela automatiskt videor</string>
<string name="automatically_show_url_preview">Visa automatiskt förhandsgranskning av URL</string>
<string name="automatically_hide_nav_bars">Omslutande bläddring</string>
<string name="automatically_hide_nav_bars_description">Dölj navigeringsfält vid bläddring</string>
<string name="load_image">Ladda bild</string>
<string name="spamming_users">Spammare</string>
<string name="muted_button">Ljud avstängt. Klicka för att ta bort ljudlöst</string>
@@ -490,6 +492,7 @@
<string name="zap_split_serarch_and_add_user_placeholder">Användarnamn eller visningsnamn</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren %1$s har inte ställt in en blixtfakturaadress för att ta emot satoshis</string>
<string name="zap_split_weight">Procent</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Delar zaps med</string>
<string name="forwarding_zaps_to">Vidarebefordrar zaps till</string>
<string name="lightning_wallets_not_found2">Blixtpengar hittades inte</string>
+5
View File
@@ -499,6 +499,8 @@
<string name="automatically_load_images_gifs">Image Preview</string>
<string name="automatically_play_videos">Video Playback</string>
<string name="automatically_show_url_preview">URL Preview</string>
<string name="automatically_hide_nav_bars">Immersive Scrolling</string>
<string name="automatically_hide_nav_bars_description">Hide Nav Bars when Scrolling</string>
<string name="load_image">Load Image</string>
<string name="spamming_users">Spammers</string>
@@ -593,4 +595,7 @@
<string name="no_wallet_found_with_error">No Wallets found to pay a lightning invoice (Error: %1$s). Please install a Lightning wallet to use zaps</string>
<string name="no_wallet_found">No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps</string>
<string name="hidden_words">Hidden Words</string>
<string name="hide_new_word_label">Hide new word or sentence</string>
</resources>
@@ -16,6 +16,7 @@ class BookmarkListEvent(
content: String,
sig: HexKey
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
@Transient
var decryptedContent = ""
companion object {
@@ -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<String>) = 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<String>) = 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<String>) = 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<String>) = isAnyTagged("p", idHexes)
override fun isTaggedEvent(idHex: String) = isTagged("e", idHex)
override fun isTaggedAddressableNote(idHex: String) = isTagged("a", idHex)
override fun isTaggedAddressableNotes(idHexes: Set<String>) = isAnyTagged( "a", idHexes)
override fun isTaggedHash(hashtag: String) = tags.any { it.size > 1 && it[0] == "t" && it[1].equals(hashtag, true) }
@@ -29,6 +29,10 @@ interface EventInterface {
fun hasValidSignature(): Boolean
fun isTagged(key: String, tag: String): Boolean
fun isAnyTagged(key: String, tags: Set<String>): Boolean
fun isTaggedWord(word: String): Boolean
fun isTaggedUser(idHex: String): Boolean
fun isTaggedUsers(idHex: Set<String>): Boolean
@@ -66,7 +66,8 @@ abstract class GeneralListEvent(
return privateTagsCache
}
fun privateTagsOrEmpty(privKey: ByteArray): List<List<String>> {
fun privateTagsOrEmpty(privKey: ByteArray?): List<List<String>> {
if (privKey == null) return emptyList()
return privateTags(privKey) ?: emptyList()
}
@@ -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<HexKey>? = null
@Transient
var publicAndPrivateWordCache: ImmutableSet<String>? = null
fun filterTagList(key: String, privateTags: List<List<String>>?): ImmutableSet<String> {
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<String> {
publicAndPrivateWordCache?.let {
return it
}
publicAndPrivateWordCache = filterTagList("word", privateTagsOrEmpty(privKey = privateKey))
return publicAndPrivateWordCache ?: persistentSetOf()
}
fun publicAndPrivateUsers(privateKey: ByteArray?): ImmutableSet<HexKey> {
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<HexKey> {
publicAndPrivateWordCache?.let {
return it
}
publicAndPrivateWordCache = filterTagList("word", privateTagsOrEmpty(decryptedContent))
return publicAndPrivateWordCache ?: persistentSetOf()
}
fun publicAndPrivateUsers(decryptedContent: String): ImmutableSet<HexKey> {
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<String>, 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
)