mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee466aa1e4 | ||
|
|
9704d61a7a | ||
|
|
69d7e82b71 | ||
|
|
288d80d163 | ||
|
|
b1edf4e934 | ||
|
|
af9b0b444c | ||
|
|
a3865e68bd | ||
|
|
ed70585e90 | ||
|
|
f707fd007b | ||
|
|
d92e30dba3 | ||
|
|
4afa5c1789 | ||
|
|
e89e8e5d01 | ||
|
|
52f600485b | ||
|
|
b5a0d65f3c | ||
|
|
fc4433e7ae | ||
|
|
f2b6a9bedb | ||
|
|
35f0b1291f | ||
|
|
045699ecd9 | ||
|
|
74a2b46508 | ||
|
|
feaa594e52 | ||
|
|
d966541ea7 | ||
|
|
ecfc287871 | ||
|
|
c10461126a | ||
|
|
20903cefc8 | ||
|
|
e1caf40c3a | ||
|
|
6e70034758 | ||
|
|
ee30ff28f6 | ||
|
|
03654b5ebd | ||
|
|
7071174aed | ||
|
|
2c27531056 | ||
|
|
57d5556728 | ||
|
|
f4da2ae6be | ||
|
|
57dfe3af8c | ||
|
|
618d14a965 | ||
|
|
ee95942289 | ||
|
|
c6c15c3ec7 | ||
|
|
ed4c51b664 | ||
|
|
b3f39434ee | ||
|
|
30ded17581 | ||
|
|
19557bf358 | ||
|
|
bdc2cbbb99 | ||
|
|
82e2d15aaf | ||
|
|
6aff31886e | ||
|
|
7cc089f0ee | ||
|
|
6c025f7274 | ||
|
|
d5220324c4 | ||
|
|
a0308bd3a1 | ||
|
|
856b4ca2c1 | ||
|
|
8abfd10f0c | ||
|
|
1bc4d33bb7 | ||
|
|
35c67f692a | ||
|
|
a319902cf7 | ||
|
|
2bc8d26b4f | ||
|
|
402da58391 | ||
|
|
b533afe27f | ||
|
|
6a4afb45dc | ||
|
|
73a718e6c7 | ||
|
|
140f6e3318 | ||
|
|
073add9e71 | ||
|
|
35ecf1f7a7 | ||
|
|
71db62e5a5 | ||
|
|
ff82b52a5b | ||
|
|
18e81de26d | ||
|
|
27f37fc158 | ||
|
|
6638ae1d7b | ||
|
|
42094c2a70 | ||
|
|
932abdd412 | ||
|
|
b4bb6f2b15 | ||
|
|
4c7b523de1 | ||
|
|
59608e9acc | ||
|
|
77afedb2d0 | ||
|
|
ccb2c626a7 | ||
|
|
9b6eec5fdd |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 302
|
||||
versionName "0.77.5"
|
||||
versionCode 307
|
||||
versionName "0.78.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -192,7 +192,7 @@ dependencies {
|
||||
implementation 'id.zelory:compressor:3.0.1'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.7'
|
||||
testImplementation 'io.mockk:mockk:1.13.8'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,7 +12,7 @@ import java.io.File
|
||||
|
||||
@UnstableApi object VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 90 * 1024 * 1024 // 90MB
|
||||
var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB
|
||||
|
||||
var leastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(exoPlayerCacheSize)
|
||||
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
@@ -265,6 +278,7 @@ class Account(
|
||||
|
||||
var event = MetadataEvent.create(toString, identities, keyPair.pubKey.toHexKey(), keyPair.privKey)
|
||||
if (loginWithExternalSigner) {
|
||||
ExternalSignerUtils.openSigner(event)
|
||||
val content = ExternalSignerUtils.content[event.id]
|
||||
if (content.isBlank()) {
|
||||
return
|
||||
@@ -2267,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) {
|
||||
@@ -2275,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
|
||||
@@ -2384,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.tagIndex
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
@@ -290,3 +289,9 @@ class SchemelessUrlSegment(segment: String, val url: String, val extras: String?
|
||||
|
||||
@Immutable
|
||||
class RegularTextSegment(segment: String) : Segment(segment)
|
||||
|
||||
fun startsWithNIP19Scheme(word: String): Boolean {
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
|
||||
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
|
||||
}
|
||||
|
||||
@@ -145,6 +145,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
}
|
||||
|
||||
override fun consume(event: Event, relay: Relay) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (LocalCache.justVerify(event)) {
|
||||
if (event is GiftWrapEvent) {
|
||||
val privateKey = account.keyPair.privKey
|
||||
|
||||
@@ -43,6 +43,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
|
||||
val key = account.keyPair.privKey
|
||||
if (account.loginWithExternalSigner) {
|
||||
ExternalSignerUtils.account = account
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -23,7 +24,10 @@ class RegisterAccounts(
|
||||
): List<RelayAuthEvent> {
|
||||
return accounts.mapNotNull {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null && acc.isWriteable()) {
|
||||
if (acc != null && (acc.isWriteable() || acc.loginWithExternalSigner)) {
|
||||
if (acc.loginWithExternalSigner) {
|
||||
ExternalSignerUtils.account = acc
|
||||
}
|
||||
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
|
||||
if (relayToUse != null) {
|
||||
acc.createAuthEvent(relayToUse, notificationToken)
|
||||
@@ -66,5 +70,6 @@ class RegisterAccounts(
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
PushNotificationUtils.hasInit = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ private fun RenderSearch(
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Join Start")
|
||||
|
||||
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.CancelIcon
|
||||
@@ -213,7 +214,10 @@ fun NewPostView(
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost(relayList = relayList)
|
||||
onClose()
|
||||
scope.launch {
|
||||
delay(100)
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -223,7 +227,10 @@ fun NewPostView(
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
CloseButton(onPress = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
scope.launch {
|
||||
delay(100)
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
},
|
||||
backgroundColor = MaterialTheme.colors.surface,
|
||||
@@ -1219,9 +1226,7 @@ private fun MarkAsSensitive(
|
||||
@Composable
|
||||
fun CloseButton(onPress: () -> Unit) {
|
||||
Button(
|
||||
onClick = {
|
||||
onPress()
|
||||
},
|
||||
onClick = onPress,
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -38,7 +37,6 @@ import kotlinx.coroutines.withContext
|
||||
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
val postViewModel: NewUserMetadataViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
@@ -88,43 +86,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
.padding(10.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.display_name)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.displayName.value,
|
||||
onValueChange = { postViewModel.displayName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.my_display_name),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Text("@", Modifier.padding(5.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.username)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.userName.value,
|
||||
onValueChange = { postViewModel.userName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.my_username),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.display_name)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.displayName.value,
|
||||
onValueChange = { postViewModel.displayName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.my_display_name),
|
||||
color = MaterialTheme.colors.placeholderText
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.io.StringWriter
|
||||
class NewUserMetadataViewModel : ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val userName = mutableStateOf("")
|
||||
// val userName = mutableStateOf("")
|
||||
val displayName = mutableStateOf("")
|
||||
val about = mutableStateOf("")
|
||||
|
||||
@@ -47,7 +47,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
this.account = account
|
||||
|
||||
account.userProfile().let {
|
||||
userName.value = it.bestUsername() ?: ""
|
||||
// userName.value = it.bestUsername() ?: ""
|
||||
displayName.value = it.bestDisplayName() ?: ""
|
||||
about.value = it.info?.about ?: ""
|
||||
picture.value = it.info?.picture ?: ""
|
||||
@@ -82,8 +82,8 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
} else {
|
||||
ObjectMapper().createObjectNode()
|
||||
}
|
||||
currentJson.put("name", userName.value.trim())
|
||||
currentJson.put("username", userName.value.trim())
|
||||
// currentJson.put("username", userName.value.trim())
|
||||
currentJson.put("name", displayName.value.trim())
|
||||
currentJson.put("display_name", displayName.value.trim())
|
||||
currentJson.put("displayName", displayName.value.trim())
|
||||
currentJson.put("picture", picture.value.trim())
|
||||
@@ -128,7 +128,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
userName.value = ""
|
||||
// userName.value = ""
|
||||
displayName.value = ""
|
||||
about.value = ""
|
||||
picture.value = ""
|
||||
|
||||
@@ -111,7 +111,15 @@ fun RelayInformationDialog(
|
||||
Section(stringResource(R.string.contact))
|
||||
|
||||
Box(modifier = Modifier.padding(start = 10.dp)) {
|
||||
ClickableEmail(relayInfo.contact ?: "")
|
||||
relayInfo.contact?.let {
|
||||
if (it.startsWith("https:")) {
|
||||
ClickableUrl(urlText = it, url = it)
|
||||
} else if (it.startsWith("mailto:") || it.contains('@')) {
|
||||
ClickableEmail(it)
|
||||
} else {
|
||||
SectionContent(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(stringResource(R.string.supports))
|
||||
|
||||
@@ -13,11 +13,12 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
fun ClickableEmail(email: String) {
|
||||
val stripped = email.replaceFirst("mailto:", "")
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = remember { AnnotatedString(email) },
|
||||
onClick = { runCatching { context.sendMail(email) } },
|
||||
text = remember { AnnotatedString(stripped) },
|
||||
onClick = { runCatching { context.sendMail(stripped) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -677,7 +677,7 @@ fun InLineIconRenderer(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 1.dp)
|
||||
.padding(horizontal = 0.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
const val SHORT_TEXT_LENGTH = 350
|
||||
const val SHORTEN_AFTER_LINES = 10
|
||||
|
||||
@Composable
|
||||
fun ExpandableRichTextViewer(
|
||||
@@ -45,11 +46,25 @@ fun ExpandableRichTextViewer(
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
val whereToCut = remember(content) {
|
||||
// Cuts the text in the first space after 350
|
||||
// Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
|
||||
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
// or after SHORTEN_AFTER_LINES lines
|
||||
val numberOfLines = content.count { it == '\n' }
|
||||
|
||||
var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
|
||||
if (numberOfLines > SHORTEN_AFTER_LINES) {
|
||||
val shortContent = content.lines().take(SHORTEN_AFTER_LINES)
|
||||
charactersInLines = 0
|
||||
for (line in shortContent) {
|
||||
// +1 because new line character is omitted from .lines
|
||||
charactersInLines += (line.length + 1)
|
||||
}
|
||||
}
|
||||
|
||||
minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines)
|
||||
}
|
||||
|
||||
val text by remember(content) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
class MarkdownParser {
|
||||
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists<String>): Pair<String, String>? {
|
||||
val matcher = tagIndex.matcher(tag)
|
||||
val (index, suffix) = try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $tag", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
LocalCache.users[nip19.hex]?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
LocalCache.notes[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.addressables[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
LocalCache.notes[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?): List<Nip19.Return> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val listOfReferences = mutableListOf<Nip19.Return>()
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
parsedNip19?.let {
|
||||
listOfReferences.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags?.lists?.forEach {
|
||||
if (it[0] == "p" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
|
||||
} else if (it[0] == "e" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
|
||||
} else if (it[0] == "a" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
|
||||
}
|
||||
}
|
||||
|
||||
return listOfReferences
|
||||
}
|
||||
|
||||
fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
returnContent += " "
|
||||
} else {
|
||||
returnContent += "[$word]($word) "
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
returnContent += "[$word](mailto:$word) "
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
returnContent += "[$word](tel:$word) "
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
returnContent += if (parsedNip19 !== null) {
|
||||
val pair = getDisplayNameFromNip19(parsedNip19)
|
||||
if (pair != null) {
|
||||
val (displayName, nip19) = pair
|
||||
"[$displayName](nostr:$nip19) "
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
val pair = getDisplayNameAndNIP19FromTag(word, tags)
|
||||
if (pair != null) {
|
||||
returnContent += "[${pair.first}](nostr:${pair.second}) "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
val (myTag, mySuffix) = try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (myTag != null) {
|
||||
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
}
|
||||
returnContent += "\n"
|
||||
}
|
||||
return returnContent
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
@@ -34,7 +32,6 @@ import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.markdown.MarkdownParseOptions
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
@@ -59,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadedBechLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font17SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
@@ -113,7 +111,7 @@ fun RichTextViewer(
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (remember(content) { isMarkdown(content) }) {
|
||||
RenderContentAsMarkdown(content, tags, nav)
|
||||
RenderContentAsMarkdown(content, tags, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
@@ -320,7 +318,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
|
||||
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val uri = LocalUriHandler.current
|
||||
val onClick = remember {
|
||||
{ link: String ->
|
||||
@@ -338,7 +336,7 @@ private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<
|
||||
MaterialRichText(
|
||||
style = MaterialTheme.colors.markdownStyle
|
||||
) {
|
||||
RefreshableContent(content, tags) {
|
||||
RefreshableContent(content, tags, accountViewModel) {
|
||||
Markdown(
|
||||
content = it,
|
||||
markdownParseOptions = MarkdownParseOptions.Default,
|
||||
@@ -350,13 +348,14 @@ private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefreshableContent(content: String, tags: ImmutableListOfLists<String>?, onCompose: @Composable (String) -> Unit) {
|
||||
private fun RefreshableContent(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, onCompose: @Composable (String) -> Unit) {
|
||||
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
|
||||
|
||||
ObserverAllNIP19References(content, tags) {
|
||||
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
|
||||
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
|
||||
markdownWithSpecialContent = newMarkdownWithSpecialContent
|
||||
ObserverAllNIP19References(content, tags, accountViewModel) {
|
||||
accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent ->
|
||||
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
|
||||
markdownWithSpecialContent = newMarkdownWithSpecialContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,47 +365,47 @@ private fun RefreshableContent(content: String, tags: ImmutableListOfLists<Strin
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, onRefresh: () -> Unit) {
|
||||
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, onRefresh: () -> Unit) {
|
||||
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
launch(Dispatchers.IO) {
|
||||
nip19References = returnNIP19References(content, tags)
|
||||
accountViewModel.returnNIP19References(content, tags) {
|
||||
nip19References = it
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
nip19References.forEach {
|
||||
ObserveNIP19(it, onRefresh)
|
||||
ObserveNIP19(it, accountViewModel, onRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveNIP19(
|
||||
it: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
ObserveNIP19Event(it, onRefresh)
|
||||
ObserveNIP19Event(it, accountViewModel, onRefresh)
|
||||
} else if (it.type == Nip19.Type.USER) {
|
||||
ObserveNIP19User(it, onRefresh)
|
||||
ObserveNIP19User(it, accountViewModel, onRefresh)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveNIP19Event(
|
||||
it: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
var baseNote by remember(it) { mutableStateOf<Note?>(LocalCache.getNoteIfExists(it.hex)) }
|
||||
var baseNote by remember(it) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(it.hex)) }
|
||||
|
||||
if (baseNote == null) {
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
launch(Dispatchers.Main) { baseNote = note }
|
||||
}
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
accountViewModel.checkGetOrCreateNote(it.hex) { note ->
|
||||
launch(Dispatchers.Main) { baseNote = note }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,9 +422,7 @@ fun ObserveNote(note: Note, onRefresh: () -> Unit) {
|
||||
|
||||
LaunchedEffect(key1 = loadedNoteId) {
|
||||
if (loadedNoteId != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
onRefresh()
|
||||
}
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,17 +430,16 @@ fun ObserveNote(note: Note, onRefresh: () -> Unit) {
|
||||
@Composable
|
||||
private fun ObserveNIP19User(
|
||||
it: Nip19.Return,
|
||||
accountViewModel: AccountViewModel,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
var baseUser by remember(it) { mutableStateOf<User?>(LocalCache.getUserIfExists(it.hex)) }
|
||||
var baseUser by remember(it) { mutableStateOf<User?>(accountViewModel.getUserIfExists(it.hex)) }
|
||||
|
||||
if (baseUser == null) {
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
launch(Dispatchers.IO) {
|
||||
if (it.type == Nip19.Type.USER) {
|
||||
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
|
||||
launch(Dispatchers.Main) { baseUser = user }
|
||||
}
|
||||
if (it.type == Nip19.Type.USER) {
|
||||
accountViewModel.checkGetOrCreateUser(it.hex)?.let { user ->
|
||||
launch(Dispatchers.Main) { baseUser = user }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -460,184 +456,19 @@ private fun ObserveUser(user: User, onRefresh: () -> Unit) {
|
||||
|
||||
LaunchedEffect(key1 = loadedUserMetaId) {
|
||||
if (loadedUserMetaId != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
onRefresh()
|
||||
}
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists<String>): Pair<String, String>? {
|
||||
val matcher = tagIndex.matcher(tag)
|
||||
val (index, suffix) = try {
|
||||
matcher.find()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
Log.w("Tag Parser", "Couldn't link tag $tag", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (index != null && index >= 0 && index < tags.lists.size) {
|
||||
val tag = tags.lists[index]
|
||||
|
||||
if (tag.size > 1) {
|
||||
if (tag[0] == "p") {
|
||||
LocalCache.checkGetOrCreateUser(tag[1])?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
} else if (tag[0] == "e" || tag[0] == "a") {
|
||||
LocalCache.checkGetOrCreateNote(tag[1])?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
LocalCache.users[nip19.hex]?.let {
|
||||
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
LocalCache.notes[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.addressables[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
LocalCache.notes[nip19.hex]?.let {
|
||||
return Pair(it.idDisplayNote(), it.toNEvent())
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?): List<Nip19.Return> {
|
||||
val listOfReferences = mutableListOf<Nip19.Return>()
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
parsedNip19?.let {
|
||||
listOfReferences.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tags?.lists?.forEach {
|
||||
if (it[0] == "p" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
|
||||
} else if (it[0] == "e" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
|
||||
} else if (it[0] == "a" && it.size > 1) {
|
||||
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
|
||||
}
|
||||
}
|
||||
|
||||
return listOfReferences
|
||||
}
|
||||
|
||||
private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
returnContent += " "
|
||||
} else {
|
||||
returnContent += "[$word]($word) "
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
returnContent += "[$word](mailto:$word) "
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
returnContent += "[$word](tel:$word) "
|
||||
} else if (startsWithNIP19Scheme(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
returnContent += if (parsedNip19 !== null) {
|
||||
val pair = getDisplayNameFromNip19(parsedNip19)
|
||||
if (pair != null) {
|
||||
val (displayName, nip19) = pair
|
||||
"[$displayName](nostr:$nip19) "
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else {
|
||||
"$word "
|
||||
}
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
val pair = getDisplayNameAndNIP19FromTag(word, tags)
|
||||
if (pair != null) {
|
||||
returnContent += "[${pair.first}](nostr:${pair.second}) "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
val hashtagMatcher = hashTagsPattern.matcher(word)
|
||||
|
||||
val (myTag, mySuffix) = try {
|
||||
hashtagMatcher.find()
|
||||
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (myTag != null) {
|
||||
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
} else {
|
||||
returnContent += "$word "
|
||||
}
|
||||
}
|
||||
returnContent += "\n"
|
||||
}
|
||||
return returnContent
|
||||
}
|
||||
|
||||
fun startsWithNIP19Scheme(word: String): Boolean {
|
||||
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
|
||||
|
||||
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
|
||||
|
||||
@Composable
|
||||
fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var loadedLink by remember { mutableStateOf<LoadedBechLink?>(null) }
|
||||
|
||||
if (loadedLink == null) {
|
||||
LaunchedEffect(key1 = word) {
|
||||
launch(Dispatchers.IO) {
|
||||
Nip19.uriToRoute(word)?.let {
|
||||
var returningNote: Note? = null
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
returningNote = note
|
||||
}
|
||||
}
|
||||
|
||||
val newLink = LoadedBechLink(returningNote, it)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
loadedLink = newLink
|
||||
}
|
||||
}
|
||||
accountViewModel.parseNIP19(word) {
|
||||
loadedLink = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -657,7 +488,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
|
||||
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
val text = remember {
|
||||
val text = remember(word) {
|
||||
if (word.length > 16) {
|
||||
word.replaceRange(8, word.length - 8, ":")
|
||||
} else {
|
||||
|
||||
@@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -15,13 +15,15 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
@Composable
|
||||
@@ -57,12 +59,12 @@ fun UrlPreviewCard(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Text(
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 5.dp),
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -71,9 +73,7 @@ fun UrlPreviewCard(
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp),
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -81,13 +81,13 @@ fun UrlPreviewCard(
|
||||
Text(
|
||||
text = previewInfo.description,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
@@ -211,27 +212,29 @@ fun VideoViewInner(
|
||||
} else {
|
||||
VideoPlayerActiveMutex(videoUri) { activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
mutableStateOf(
|
||||
MediaItem.Builder()
|
||||
.setMediaId(videoUri)
|
||||
.setUri(videoUri)
|
||||
.setMediaMetadata(
|
||||
MediaMetadata.Builder()
|
||||
.setArtist(authorName?.ifBlank { null })
|
||||
.setTitle(title?.ifBlank { null } ?: videoUri)
|
||||
.setArtworkUri(
|
||||
try {
|
||||
if (artworkUri != null) {
|
||||
Uri.parse(artworkUri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
GetVideoController(
|
||||
@@ -258,7 +261,7 @@ fun VideoViewInner(
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
fun GetVideoController(
|
||||
mediaItem: MediaItem,
|
||||
mediaItem: MutableState<MediaItem>,
|
||||
videoUri: String,
|
||||
defaultToStart: Boolean = false,
|
||||
nostrUriCallback: String? = null,
|
||||
@@ -282,12 +285,14 @@ fun GetVideoController(
|
||||
UUID.randomUUID().toString()
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Prepares a VideoPlayer from the foreground service.
|
||||
LaunchedEffect(key1 = videoUri) {
|
||||
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) {
|
||||
launch(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (controller.value == null) {
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
@@ -309,7 +314,7 @@ fun GetVideoController(
|
||||
}
|
||||
}
|
||||
|
||||
controller.value?.setMediaItem(mediaItem)
|
||||
controller.value?.setMediaItem(mediaItem.value)
|
||||
controller.value?.prepare()
|
||||
} else if (controller.value != it) {
|
||||
// discards the new controller because there is an existing one
|
||||
@@ -327,44 +332,52 @@ fun GetVideoController(
|
||||
it.volume = if (defaultToStart) 0f else 1f
|
||||
}
|
||||
|
||||
it.setMediaItem(mediaItem)
|
||||
it.setMediaItem(mediaItem.value)
|
||||
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.value)
|
||||
it.prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
controller.value?.stop()
|
||||
controller.value?.release()
|
||||
controller.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User pauses and resumes the app. What to do with videos?
|
||||
val scope = rememberCoroutineScope()
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(key1 = videoUri) {
|
||||
DisposableEffect(key1 = lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
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) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (controller.value == null) {
|
||||
PlaybackClientController.prepareController(
|
||||
UUID.randomUUID().toString(),
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context
|
||||
@@ -384,7 +397,7 @@ fun GetVideoController(
|
||||
}
|
||||
}
|
||||
|
||||
controller.value?.setMediaItem(mediaItem)
|
||||
controller.value?.setMediaItem(mediaItem.value)
|
||||
controller.value?.prepare()
|
||||
} else if (controller.value != it) {
|
||||
// discards the new controller because there is an existing one
|
||||
@@ -408,13 +421,6 @@ fun GetVideoController(
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
controller.value?.stop()
|
||||
controller.value?.release()
|
||||
controller.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +463,7 @@ fun VideoPlayerActiveMutex(videoUri: String, inner: @Composable (MutableState<Bo
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
val myModifier = remember(videoUri) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
@@ -486,7 +492,9 @@ fun VideoPlayerActiveMutex(videoUri: String, inner: @Composable (MutableState<Bo
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
}
|
||||
|
||||
Box(modifier = myModifier) {
|
||||
inner(active)
|
||||
}
|
||||
}
|
||||
@@ -508,8 +516,6 @@ private fun RenderVideoPlayer(
|
||||
activeOnScreen: MutableState<Boolean>,
|
||||
onDialog: ((Boolean) -> Unit)?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
|
||||
|
||||
val controllerVisible = remember(controller) {
|
||||
@@ -517,17 +523,23 @@ private fun RenderVideoPlayer(
|
||||
}
|
||||
|
||||
BoxWithConstraints() {
|
||||
AndroidView(
|
||||
modifier = if (roundedCorner) {
|
||||
MaterialTheme.colors.imageModifier
|
||||
val borders = MaterialTheme.colors.imageModifier
|
||||
|
||||
val myModifier = remember {
|
||||
if (roundedCorner) {
|
||||
borders
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.align(Alignment.Center)
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.align(Alignment.Center)
|
||||
},
|
||||
factory = {
|
||||
}
|
||||
}
|
||||
|
||||
val factory = remember(controller) {
|
||||
{ context: Context ->
|
||||
PlayerView(context).apply {
|
||||
player = controller
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
@@ -552,10 +564,15 @@ private fun RenderVideoPlayer(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory
|
||||
)
|
||||
|
||||
waveform?.let {
|
||||
Waveform(it, controller, Modifier.align(Alignment.Center))
|
||||
Waveform(it, controller, remember { Modifier.align(Alignment.Center) })
|
||||
}
|
||||
|
||||
val startingMuteState = remember(controller) {
|
||||
@@ -576,7 +593,7 @@ private fun RenderVideoPlayer(
|
||||
controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
KeepPlayingButton(keepPlaying, controllerVisible, Modifier.align(Alignment.TopEnd)) { newKeepPlaying: Boolean ->
|
||||
KeepPlayingButton(keepPlaying, controllerVisible, remember { Modifier.align(Alignment.TopEnd) }) { newKeepPlaying: Boolean ->
|
||||
// If something else is playing and the user marks this video to keep playing, stops the other one.
|
||||
if (newKeepPlaying) {
|
||||
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
@@ -694,7 +711,7 @@ fun ControlWhenPlayerIsActive(
|
||||
val view = LocalView.current
|
||||
|
||||
// Keeps the screen on while playing and viewing videos.
|
||||
DisposableEffect(key1 = controller) {
|
||||
DisposableEffect(key1 = controller, key2 = view) {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
// doesn't consider the mutex because the screen can turn off if the video
|
||||
|
||||
@@ -614,7 +614,7 @@ fun ZoomableImageDialog(
|
||||
) {
|
||||
val view = LocalView.current
|
||||
|
||||
DisposableEffect(key1 = Unit) {
|
||||
DisposableEffect(key1 = view) {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.hide(
|
||||
android.view.WindowInsets.Type.systemBars()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -284,7 +284,7 @@ fun AppNavigation(
|
||||
actionableNextPage = null
|
||||
}
|
||||
|
||||
DisposableEffect(navController) {
|
||||
DisposableEffect(activity) {
|
||||
val consumer = Consumer<Intent> { intent ->
|
||||
val uri = intent?.data?.toString()
|
||||
val newPage = uriToRoute(uri)
|
||||
|
||||
@@ -64,7 +64,6 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
@@ -110,7 +109,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LongChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LongRoomHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.RoomNameOnlyDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShortChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowVideoStreaming
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
@@ -355,11 +353,6 @@ private fun ChannelTopBar(
|
||||
) {
|
||||
LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel ->
|
||||
FlexibleTopBarWithBackButton(
|
||||
prefixRow = {
|
||||
if (baseChannel is LiveActivitiesChannel) {
|
||||
ShowVideoStreaming(baseChannel, accountViewModel)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
ShortChannelHeader(
|
||||
baseChannel = baseChannel,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -233,7 +233,7 @@ fun NoteCompose(
|
||||
) {
|
||||
val hasEvent by baseNote.live().hasEvent.observeAsState(baseNote.event != null)
|
||||
|
||||
Crossfade(targetState = hasEvent) {
|
||||
Crossfade(targetState = hasEvent, label = "Event presence") {
|
||||
if (it) {
|
||||
CheckHiddenNoteCompose(
|
||||
note = baseNote,
|
||||
@@ -831,34 +831,17 @@ private fun CheckNewAndRenderNote(
|
||||
val backgroundColor = remember { mutableStateOf<Color>(defaultBackgroundColor) }
|
||||
|
||||
LaunchedEffect(key1 = routeForLastRead, key2 = parentBackgroundColor?.value) {
|
||||
launch(Dispatchers.IO) {
|
||||
routeForLastRead?.let {
|
||||
val lastTime = accountViewModel.account.loadLastRead(it)
|
||||
|
||||
val createdAt = baseNote.createdAt()
|
||||
if (createdAt != null) {
|
||||
accountViewModel.account.markAsRead(it, createdAt)
|
||||
|
||||
val isNew = createdAt > lastTime
|
||||
|
||||
val newBackgroundColor = if (isNew) {
|
||||
if (parentBackgroundColor != null) {
|
||||
newItemColor.compositeOver(parentBackgroundColor.value)
|
||||
} else {
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
}
|
||||
routeForLastRead?.let {
|
||||
accountViewModel.loadAndMarkAsRead(it, baseNote.createdAt()) { isNew ->
|
||||
val newBackgroundColor = if (isNew) {
|
||||
if (parentBackgroundColor != null) {
|
||||
newItemColor.compositeOver(parentBackgroundColor.value)
|
||||
} else {
|
||||
parentBackgroundColor?.value ?: defaultBackgroundColor
|
||||
}
|
||||
|
||||
if (newBackgroundColor != backgroundColor.value) {
|
||||
launch(Dispatchers.Main) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
}
|
||||
newItemColor.compositeOver(defaultBackgroundColor)
|
||||
}
|
||||
} else {
|
||||
parentBackgroundColor?.value ?: defaultBackgroundColor
|
||||
}
|
||||
} ?: run {
|
||||
val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor
|
||||
|
||||
if (newBackgroundColor != backgroundColor.value) {
|
||||
launch(Dispatchers.Main) {
|
||||
@@ -866,6 +849,14 @@ private fun CheckNewAndRenderNote(
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
val newBackgroundColor = parentBackgroundColor?.value ?: defaultBackgroundColor
|
||||
|
||||
if (newBackgroundColor != backgroundColor.value) {
|
||||
launch(Dispatchers.Main) {
|
||||
backgroundColor.value = newBackgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2250,14 +2241,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)
|
||||
) {
|
||||
@@ -3101,26 +3101,28 @@ fun DisplayUncitedHashtags(
|
||||
eventContent: String,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val hasHashtags = remember {
|
||||
val hasHashtags = remember(eventContent) {
|
||||
hashtags.isNotEmpty()
|
||||
}
|
||||
|
||||
if (hasHashtags) {
|
||||
val unusedHashtags = remember(eventContent) {
|
||||
hashtags.filter { !eventContent.contains(it, true) }
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = remember { Modifier.padding(top = 5.dp) }
|
||||
) {
|
||||
hashtags.forEach { hashtag ->
|
||||
if (!eventContent.contains(hashtag, true)) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("#$hashtag "),
|
||||
onClick = { nav("Hashtag/$hashtag") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
unusedHashtags.forEach { hashtag ->
|
||||
ClickableText(
|
||||
text = remember { AnnotatedString("#$hashtag ") },
|
||||
onClick = { nav("Hashtag/$hashtag") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +114,11 @@ private fun VerticalDivider(color: Color) =
|
||||
fun LongPressToQuickAction(baseNote: Note, accountViewModel: AccountViewModel, content: @Composable (() -> Unit) -> Unit) {
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
val showPopup = remember { { popupExpanded.value = true } }
|
||||
val hidePopup = remember { { popupExpanded.value = false } }
|
||||
|
||||
content(showPopup)
|
||||
|
||||
NoteQuickActionMenu(baseNote, popupExpanded.value, { popupExpanded.value = false }, accountViewModel)
|
||||
NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -413,9 +413,20 @@ fun ZapVote(
|
||||
}
|
||||
|
||||
if (wantsToPay.isNotEmpty()) {
|
||||
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
|
||||
wantsToPay = persistentListOf()
|
||||
}
|
||||
PayViaIntentDialog(
|
||||
payingInvoices = wantsToPay,
|
||||
accountViewModel = accountViewModel,
|
||||
onClose = {
|
||||
wantsToPay = persistentListOf()
|
||||
},
|
||||
onError = {
|
||||
wantsToPay = persistentListOf()
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showErrorMessageDialog != null) {
|
||||
|
||||
@@ -33,9 +33,12 @@ class PollNoteViewModel : ViewModel() {
|
||||
|
||||
private var pollEvent: PollNoteEvent? = null
|
||||
private var pollOptions: Map<Int, String>? = null
|
||||
private var valueMaximum: Int? = null
|
||||
private var valueMinimum: Int? = null
|
||||
private var closedAt: Int? = null
|
||||
private var valueMaximum: Long? = null
|
||||
private var valueMinimum: Long? = null
|
||||
private var valueMaximumBD: BigDecimal? = null
|
||||
private var valueMinimumBD: BigDecimal? = null
|
||||
|
||||
private var closedAt: Long? = null
|
||||
private var consensusThreshold: BigDecimal? = null
|
||||
|
||||
private var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||
@@ -49,10 +52,12 @@ class PollNoteViewModel : ViewModel() {
|
||||
pollNote = note
|
||||
pollEvent = pollNote?.event as PollNoteEvent
|
||||
pollOptions = pollEvent?.pollOptions()
|
||||
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
|
||||
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
|
||||
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||
valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
|
||||
valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
|
||||
valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
|
||||
valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
|
||||
consensusThreshold = pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||
closedAt = pollEvent?.getTagLong(CLOSED_AT)
|
||||
}
|
||||
|
||||
fun refreshTallies() {
|
||||
@@ -110,6 +115,29 @@ class PollNoteViewModel : ViewModel() {
|
||||
} catch (e: Exception) { null }
|
||||
}
|
||||
|
||||
fun isValidInputVoteAmount(amount: BigDecimal?): Boolean {
|
||||
if (amount == null) {
|
||||
return false
|
||||
} else if (valueMinimum == null && valueMaximum == null) {
|
||||
if (amount > BigDecimal.ZERO) {
|
||||
return true
|
||||
}
|
||||
} else if (valueMinimum == null) {
|
||||
if (amount > BigDecimal.ZERO && amount <= valueMaximumBD!!) {
|
||||
return true
|
||||
}
|
||||
} else if (valueMaximum == null) {
|
||||
if (amount >= valueMinimumBD!!) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if ((valueMinimumBD!! <= amount) && (amount <= valueMaximumBD!!)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isValidInputVoteAmount(amount: Long?): Boolean {
|
||||
if (amount == null) {
|
||||
return false
|
||||
@@ -145,24 +173,29 @@ class PollNoteViewModel : ViewModel() {
|
||||
private fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val event = it?.event as? LnZapEvent
|
||||
if (event?.zappedPollOption() == option) {
|
||||
event.amount ?: BigDecimal(0)
|
||||
val zapAmount = event?.amount ?: BigDecimal.ZERO
|
||||
val isValidAmount = isValidInputVoteAmount(event?.amount)
|
||||
|
||||
if (isValidAmount && event?.zappedPollOption() == option) {
|
||||
zapAmount
|
||||
} else {
|
||||
BigDecimal(0)
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
} ?: BigDecimal(0)
|
||||
} ?: BigDecimal.ZERO
|
||||
}
|
||||
|
||||
private fun totalZapped(): BigDecimal {
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val zapEvent = (it?.event as? LnZapEvent)
|
||||
val zapAmount = zapEvent?.amount ?: BigDecimal.ZERO
|
||||
val isValidAmount = isValidInputVoteAmount(zapEvent?.amount)
|
||||
|
||||
if (zapEvent?.zappedPollOption() != null) {
|
||||
zapEvent.amount ?: BigDecimal(0)
|
||||
if (isValidAmount && zapEvent?.zappedPollOption() != null) {
|
||||
zapAmount
|
||||
} else {
|
||||
BigDecimal(0)
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
} ?: BigDecimal(0)
|
||||
} ?: BigDecimal.ZERO
|
||||
}
|
||||
|
||||
fun createZapOptionsThatMatchThePollingParameters(): List<Long> {
|
||||
@@ -176,8 +209,8 @@ class PollNoteViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
valueMinimum?.let { options.add(it.toLong()) }
|
||||
valueMaximum?.let { options.add(it.toLong()) }
|
||||
valueMinimum?.let { options.add(it) }
|
||||
valueMaximum?.let { options.add(it) }
|
||||
|
||||
return options.toSet().sorted()
|
||||
}
|
||||
|
||||
@@ -1056,9 +1056,20 @@ fun ZapReaction(
|
||||
}
|
||||
|
||||
if (wantsToPay.isNotEmpty()) {
|
||||
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
|
||||
wantsToPay = persistentListOf()
|
||||
}
|
||||
PayViaIntentDialog(
|
||||
payingInvoices = wantsToPay,
|
||||
accountViewModel = accountViewModel,
|
||||
onClose = {
|
||||
wantsToPay = persistentListOf()
|
||||
},
|
||||
onError = {
|
||||
wantsToPay = persistentListOf()
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (wantsToSetCustomZap) {
|
||||
|
||||
@@ -386,6 +386,19 @@ fun UpdateZapAmountDialog(
|
||||
Modifier.weight(1f)
|
||||
)
|
||||
|
||||
/* TODO: Find a way to open this in the PWA
|
||||
IconButton(onClick = {
|
||||
onClose()
|
||||
runCatching { uri.openUri("https://app.mutinywallet.com/settings/connections?callbackUri=nostr+walletconnect&name=Amethyst") }
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.mipmap.mutiny),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}*/
|
||||
|
||||
IconButton(onClick = {
|
||||
onClose()
|
||||
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -144,6 +145,7 @@ fun ZapCustomDialog(
|
||||
onPayViaIntent = onPayViaIntent,
|
||||
zapType = selectedZapType
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,84 +300,84 @@ fun ErrorMessageDialog(
|
||||
fun PayViaIntentDialog(
|
||||
payingInvoices: ImmutableList<ZapPaymentHandler.Payable>,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit
|
||||
onClose: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onPress = onClose)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
payingInvoices.forEachIndexed { index, it ->
|
||||
val paid = remember {
|
||||
mutableStateOf(false)
|
||||
if (payingInvoices.size == 1) {
|
||||
payViaIntent(payingInvoices.first().invoice, context, onError)
|
||||
} else {
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onPress = onClose)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
|
||||
if (it.user != null) {
|
||||
BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
DisplayBlankAuthor(size = Size55dp)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
payingInvoices.forEachIndexed { index, it ->
|
||||
val paid = remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
|
||||
if (it.user != null) {
|
||||
UsernameDisplay(it.user, showPlayButton = false)
|
||||
BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(id = R.string.wallet_number, index + 1),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
DisplayBlankAuthor(size = Size55dp)
|
||||
}
|
||||
Row() {
|
||||
Text(
|
||||
text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Text(
|
||||
text = stringResource(id = R.string.sats),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (it.user != null) {
|
||||
UsernameDisplay(it.user, showPlayButton = false)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(id = R.string.wallet_number, index + 1),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row() {
|
||||
Text(
|
||||
text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
Text(
|
||||
text = stringResource(id = R.string.sats),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
PayButton(isActive = !paid.value) {
|
||||
paid.value = true
|
||||
PayButton(isActive = !paid.value) {
|
||||
paid.value = true
|
||||
|
||||
val uri = "lightning:" + it.invoice
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
payViaIntent(it.invoice, context, onError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,6 +386,21 @@ fun PayViaIntentDialog(
|
||||
}
|
||||
}
|
||||
|
||||
fun payViaIntent(invoice: String, context: Context, onError: (String) -> Unit) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$invoice"))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
} catch (e: Exception) {
|
||||
if (e.message != null) {
|
||||
onError(context.getString(R.string.no_wallet_found_with_error, e.message!!))
|
||||
} else {
|
||||
onError(context.getString(R.string.no_wallet_found))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) {
|
||||
Button(
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.google.zxing.client.android.Intents
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
@@ -40,8 +39,6 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val qrLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) {
|
||||
if (it.contents != null) {
|
||||
@@ -59,7 +56,7 @@ fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
|
||||
addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
DisposableEffect(Unit) {
|
||||
qrLauncher.launch(scanOptions)
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -63,6 +63,7 @@ class NostrChatroomFeedViewModel(val user: ChatroomKey, val account: Account) :
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) {
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <NostrVideoFeedViewModel : ViewModel> create(modelClass: Class<NostrVideoFeedViewModel>): NostrVideoFeedViewModel {
|
||||
|
||||
@@ -87,6 +87,8 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
|
||||
checkNotInMainThread()
|
||||
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
checkNotInMainThread()
|
||||
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,16 +70,20 @@ class RelayFeedViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun subscribeTo(user: User) {
|
||||
currentUser = user
|
||||
user.live().relays.observeForever(listener)
|
||||
user.live().relayInfo.observeForever(listener)
|
||||
invalidateData()
|
||||
if (currentUser != user) {
|
||||
currentUser = user
|
||||
user.live().relays.observeForever(listener)
|
||||
user.live().relayInfo.observeForever(listener)
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun unsubscribeTo(user: User) {
|
||||
user.live().relays.removeObserver(listener)
|
||||
user.live().relayInfo.removeObserver(listener)
|
||||
currentUser = null
|
||||
if (currentUser == user) {
|
||||
user.live().relays.removeObserver(listener)
|
||||
user.live().relayInfo.removeObserver(listener)
|
||||
currentUser = null
|
||||
}
|
||||
}
|
||||
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO)
|
||||
|
||||
@@ -47,7 +47,7 @@ fun rememberForeverLazyListState(
|
||||
savedOffset.roundToInt()
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
DisposableEffect(scrollState) {
|
||||
onDispose {
|
||||
val lastIndex = scrollState.firstVisibleItemIndex
|
||||
val lastOffset = scrollState.firstVisibleItemScrollOffset
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,8 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
|
||||
checkNotInMainThread()
|
||||
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
checkNotInMainThread()
|
||||
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +28,7 @@ import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.actions.Dao
|
||||
import com.vitorpamplona.amethyst.ui.components.MarkdownParser
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
@@ -34,8 +36,10 @@ import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.Participant
|
||||
@@ -94,6 +98,12 @@ class AccountViewModel(val account: Account) : ViewModel(), Dao {
|
||||
account.updateAutomaticallyShowUrlPreview(automaticallyShowUrlPreview)
|
||||
}
|
||||
|
||||
fun updateAutomaticallyHideNavBars(
|
||||
automaticallyHideHavBars: BooleanType
|
||||
) {
|
||||
account.updateAutomaticallyHideHavBars(automaticallyHideHavBars)
|
||||
}
|
||||
|
||||
fun updateAutomaticallyShowImages(
|
||||
automaticallyShowImages: ConnectivityType
|
||||
) {
|
||||
@@ -481,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)
|
||||
@@ -590,7 +606,7 @@ class AccountViewModel(val account: Account) : ViewModel(), Dao {
|
||||
return LocalCache.getUserIfExists(hex)
|
||||
}
|
||||
|
||||
suspend fun checkGetOrCreateNote(key: HexKey): Note? {
|
||||
private suspend fun checkGetOrCreateNote(key: HexKey): Note? {
|
||||
return LocalCache.checkGetOrCreateNote(key)
|
||||
}
|
||||
|
||||
@@ -618,7 +634,7 @@ class AccountViewModel(val account: Account) : ViewModel(), Dao {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? {
|
||||
private suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? {
|
||||
return LocalCache.getOrCreateAddressableNote(key)
|
||||
}
|
||||
|
||||
@@ -638,7 +654,7 @@ class AccountViewModel(val account: Account) : ViewModel(), Dao {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
|
||||
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
|
||||
return LocalCache.checkGetOrCreateChannel(key)
|
||||
}
|
||||
|
||||
@@ -677,9 +693,52 @@ class AccountViewModel(val account: Account) : ViewModel(), Dao {
|
||||
}
|
||||
}
|
||||
|
||||
fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?, onNewReferences: (List<Nip19.Return>) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onNewReferences(MarkdownParser().returnNIP19References(content, tags))
|
||||
}
|
||||
}
|
||||
|
||||
fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?, onNewContent: (String) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onNewContent(MarkdownParser().returnMarkdownWithSpecialContent(content, tags))
|
||||
}
|
||||
}
|
||||
|
||||
fun parseNIP19(str: String, onNote: (LoadedBechLink) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
Nip19.uriToRoute(str)?.let {
|
||||
var returningNote: Note? = null
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
returningNote = note
|
||||
}
|
||||
}
|
||||
|
||||
onNote(LoadedBechLink(returningNote, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAndMarkAsRead(routeForLastRead: String, baseNoteCreatedAt: Long?, onIsNew: (Boolean) -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val lastTime = account.loadLastRead(routeForLastRead)
|
||||
|
||||
if (baseNoteCreatedAt != null) {
|
||||
account.markAsRead(routeForLastRead, baseNoteCreatedAt)
|
||||
onIsNew(baseNoteCreatedAt > lastTime)
|
||||
} else {
|
||||
onIsNew(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
|
||||
return AccountViewModel(account) as AccountViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
|
||||
|
||||
@@ -196,9 +196,6 @@ fun ChannelScreen(
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
NostrChannelDataSource.start()
|
||||
feedViewModel.invalidateData(true)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
newPostModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -209,6 +206,17 @@ fun ChannelScreen(
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel)
|
||||
NostrChannelDataSource.start()
|
||||
feedViewModel.invalidateData(true)
|
||||
|
||||
onDispose {
|
||||
NostrChannelDataSource.clear()
|
||||
NostrChannelDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Channel Start")
|
||||
@@ -240,6 +248,9 @@ fun ChannelScreen(
|
||||
.weight(1f, true)
|
||||
}
|
||||
) {
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
ShowVideoStreaming(channel, accountViewModel)
|
||||
}
|
||||
RefreshingChatroomFeedView(
|
||||
viewModel = feedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
|
||||
@@ -67,7 +67,7 @@ fun ChatroomListScreen(
|
||||
WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel)
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
@@ -234,9 +234,6 @@ fun ChatroomScreen(
|
||||
|
||||
LaunchedEffect(room, accountViewModel) {
|
||||
launch(Dispatchers.IO) {
|
||||
NostrChatroomDataSource.start()
|
||||
feedViewModel.invalidateData()
|
||||
|
||||
newPostModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
@@ -246,6 +243,16 @@ fun ChatroomScreen(
|
||||
}
|
||||
|
||||
DisposableEffect(room, accountViewModel) {
|
||||
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room)
|
||||
NostrChatroomDataSource.start()
|
||||
feedViewModel.invalidateData()
|
||||
|
||||
onDispose {
|
||||
NostrChatroomDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Private Message Start")
|
||||
|
||||
@@ -56,7 +56,7 @@ fun CommunityScreen(note: AddressableNote, feedViewModel: NostrCommunityFeedView
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Community Start")
|
||||
|
||||
@@ -88,7 +88,7 @@ fun DiscoverScreen(
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Discovery Start")
|
||||
|
||||
@@ -65,12 +65,16 @@ fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, account
|
||||
|
||||
NostrGeohashDataSource.loadHashtag(tag)
|
||||
|
||||
LaunchedEffect(tag) {
|
||||
DisposableEffect(tag) {
|
||||
NostrGeohashDataSource.start()
|
||||
feedViewModel.invalidateData()
|
||||
onDispose {
|
||||
NostrGeohashDataSource.loadHashtag(null)
|
||||
NostrGeohashDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Hashtag Start")
|
||||
@@ -88,8 +92,6 @@ fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, account
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
NostrGeohashDataSource.loadHashtag(null)
|
||||
NostrGeohashDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.material.Divider
|
||||
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
|
||||
@@ -61,12 +60,17 @@ fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, account
|
||||
|
||||
NostrHashtagDataSource.loadHashtag(tag)
|
||||
|
||||
LaunchedEffect(tag) {
|
||||
DisposableEffect(tag) {
|
||||
NostrHashtagDataSource.start()
|
||||
feedViewModel.invalidateData()
|
||||
|
||||
onDispose {
|
||||
NostrHashtagDataSource.loadHashtag(null)
|
||||
NostrHashtagDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Hashtag Start")
|
||||
@@ -84,8 +88,6 @@ fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, account
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
NostrHashtagDataSource.loadHashtag(null)
|
||||
NostrHashtagDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,27 +73,33 @@ 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
|
||||
) {
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ fun HomeScreen(
|
||||
}
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrHomeDataSource.invalidateFilters()
|
||||
@@ -122,7 +122,7 @@ private fun HomePages(
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(state = pagerState) { page ->
|
||||
HorizontalPager(state = pagerState, userScrollEnabled = false) { page ->
|
||||
RefresheableFeedView(
|
||||
viewModel = tabs[page].viewModel,
|
||||
routeForLastRead = tabs[page].routeForLastRead,
|
||||
|
||||
@@ -16,7 +16,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -32,11 +34,15 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
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
|
||||
@@ -201,27 +207,33 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val shouldShow = remember {
|
||||
derivedStateOf {
|
||||
abs(bottomBarOffsetHeightPx.value) < bottomBarHeightPx / 2.0f
|
||||
}
|
||||
}
|
||||
|
||||
WatchNavStateToUpdateBarVisibility(navState, bottomBarOffsetHeightPx)
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetContent = {
|
||||
@@ -292,7 +304,7 @@ fun MainScreen(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(bottom = if (shouldShow.value) it.calculateBottomPadding() else 0.dp)
|
||||
.padding(bottom = it.calculateBottomPadding())
|
||||
) {
|
||||
AppNavigation(
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
@@ -314,6 +326,27 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchNavStateToUpdateBarVisibility(navState: State<NavBackStackEntry?>, bottomBarOffsetHeightPx: MutableState<Float>) {
|
||||
LaunchedEffect(key1 = navState.value) {
|
||||
bottomBarOffsetHeightPx.value = 0f
|
||||
}
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
bottomBarOffsetHeightPx.value = 0f
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FloatingButtons(
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
|
||||
@@ -75,7 +75,7 @@ fun NotificationScreen(
|
||||
CheckifItNeedsToRequestNotificationPermission()
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrAccountDataSource.invalidateFilters()
|
||||
|
||||
@@ -238,11 +238,15 @@ fun ProfileScreen(
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
DisposableEffect(accountViewModel) {
|
||||
NostrUserProfileDataSource.start()
|
||||
onDispose {
|
||||
NostrUserProfileDataSource.loadUserProfile(null)
|
||||
NostrUserProfileDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Profidle Start")
|
||||
@@ -259,9 +263,6 @@ fun ProfileScreen(
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
println("Profile Dispose")
|
||||
NostrUserProfileDataSource.loadUserProfile(null)
|
||||
NostrUserProfileDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,7 +955,17 @@ private fun DrawAdditionalInfo(
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(website.removePrefix("https://")),
|
||||
onClick = { website.let { runCatching { uri.openUri(it) } } },
|
||||
onClick = {
|
||||
website.let {
|
||||
runCatching {
|
||||
if (it.contains("://")) {
|
||||
uri.openUri(it)
|
||||
} else {
|
||||
uri.openUri("http://$it")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
|
||||
)
|
||||
@@ -1567,6 +1578,13 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel, nav: (String) -> U
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(user) {
|
||||
feedViewModel.subscribeTo(user)
|
||||
onDispose {
|
||||
feedViewModel.unsubscribeTo(user)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Profile Relay Start")
|
||||
|
||||
@@ -104,7 +104,7 @@ fun SearchScreen(
|
||||
|
||||
WatchAccountForSearchScreen(accountViewModel)
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Search Start")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
@@ -28,11 +27,15 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
|
||||
|
||||
NostrThreadDataSource.loadThread(noteId)
|
||||
|
||||
LaunchedEffect(noteId) {
|
||||
DisposableEffect(noteId) {
|
||||
feedViewModel.invalidateData(true)
|
||||
onDispose {
|
||||
NostrThreadDataSource.loadThread(null)
|
||||
NostrThreadDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Thread Start")
|
||||
|
||||
@@ -89,7 +89,7 @@ fun VideoScreen(
|
||||
|
||||
WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel)
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
DisposableEffect(lifeCycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Video Start")
|
||||
@@ -229,7 +229,7 @@ fun SlidingCarousel(
|
||||
VerticalPager(
|
||||
state = pagerState,
|
||||
beyondBoundsPageCount = 1,
|
||||
modifier = Modifier.fillMaxSize(1f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
key = { index ->
|
||||
feed.value.getOrNull(index)?.idHex ?: "$index"
|
||||
}
|
||||
@@ -246,7 +246,7 @@ fun LoadedVideoCompose(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var state by remember {
|
||||
var state by remember(note) {
|
||||
mutableStateOf(
|
||||
AccountViewModel.NoteComposeReportState()
|
||||
)
|
||||
@@ -445,14 +445,11 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
|
||||
val scope = rememberCoroutineScope()
|
||||
ReplyReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
baseNote,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { nav(it) }
|
||||
}
|
||||
routeFor(
|
||||
baseNote,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { nav(it) }
|
||||
}
|
||||
BoostReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
|
||||
wantsToQuote = baseNote
|
||||
|
||||
@@ -76,6 +76,11 @@ 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 MaxWidthWithHorzPadding = Modifier.fillMaxWidth().padding(horizontal = 10.dp)
|
||||
|
||||
val Size6Modifier = Modifier.size(6.dp)
|
||||
val Size10Modifier = Modifier.size(10.dp)
|
||||
val Size15Modifier = Modifier.size(15.dp)
|
||||
|
||||
BIN
app/src/main/res/mipmap-hdpi/mutiny.png
Normal file
BIN
app/src/main/res/mipmap-hdpi/mutiny.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/mutiny.png
Normal file
BIN
app/src/main/res/mipmap-mdpi/mutiny.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/mutiny.png
Normal file
BIN
app/src/main/res/mipmap-xhdpi/mutiny.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/mutiny.png
Normal file
BIN
app/src/main/res/mipmap-xxhdpi/mutiny.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/mutiny.png
Normal file
BIN
app/src/main/res/mipmap-xxxhdpi/mutiny.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -420,6 +420,8 @@
|
||||
<string name="automatically_load_images_gifs">Automaticky načítat obrázky/gif</string>
|
||||
<string name="automatically_play_videos">Automaticky přehrávat videa</string>
|
||||
<string name="automatically_show_url_preview">Automaticky zobrazit náhled URL</string>
|
||||
<string name="automatically_hide_nav_bars">Imersivní rolování</string>
|
||||
<string name="automatically_hide_nav_bars_description">Skrýt navigační panely při rolování</string>
|
||||
<string name="load_image">Načíst obrázek</string>
|
||||
<string name="spamming_users">Spamovací uživatelé</string>
|
||||
<string name="muted_button">Ztlumené. Klikněte pro odztlumení</string>
|
||||
@@ -448,6 +450,7 @@
|
||||
<string name="messages_group_descriptor">Členové této skupiny</string>
|
||||
<string name="messages_new_subject_message">Vysvětlení členům</string>
|
||||
<string name="messages_new_subject_message_placeholder">Změna názvu pro nové cíle.</string>
|
||||
<string name="paste_from_clipboard">Vložit ze schránky</string>
|
||||
<string name="language_description">Pro rozhraní aplikace</string>
|
||||
<string name="theme_description">Tmavé, světlé nebo systémové téma</string>
|
||||
<string name="automatically_load_images_gifs_description">Automaticky načítat obrázky a GIFy</string>
|
||||
@@ -458,6 +461,7 @@
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
|
||||
<string name="created_at">Vytvořeno</string>
|
||||
<string name="rules">Pravidla</string>
|
||||
<string name="login_with_external_signer">Přihlásit se pomocí Amber</string>
|
||||
<string name="status_update">Aktualizovat svůj stav</string>
|
||||
<string name="lightning_wallets_not_found">Chyba při zpracování chybové zprávy</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Hlasy jsou váženy podle hodnoty zapu. Můžete nastavit minimální částku, abyste se vyhnuli spammerům, a maximální částku, abyste zabránili velkým zapperům, kteří by mohli ovládnout hlasování. Použijte stejnou částku v obou polích, aby byla hodnota každého hlasu stejná. Nechte prázdné pro přijetí libovolné částky.</string>
|
||||
@@ -473,4 +477,21 @@
|
||||
<string name="active_for_chats">Chaty</string>
|
||||
<string name="active_for_global">Celosvětové</string>
|
||||
<string name="active_for_search">Hledání</string>
|
||||
<string name="zap_split_title">Rozdělit a přeposlat Zaps</string>
|
||||
<string name="zap_split_explainer">Podporované klienty budou Zapy rozdělovat a přeposílat uživatelům přidaným zde místo vám</string>
|
||||
<string name="zap_split_serarch_and_add_user">Hledat a přidat uživatele</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Uživatelské jméno nebo zobrazované jméno</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel %1$s nemá nastavenou bleskovou adresu pro přijímání satoshi</string>
|
||||
<string name="zap_split_weight">Procenta</string>
|
||||
<string name="splitting_zaps_with">Rozdělování Zapů s</string>
|
||||
<string name="forwarding_zaps_to">Přeposílání Zapů na</string>
|
||||
<string name="lightning_wallets_not_found2">Bleskové peněženky nenalezený</string>
|
||||
<string name="paid">Zaplaceno</string>
|
||||
<string name="wallet_number">Peněženka %1$s</string>
|
||||
<string name="error_opening_external_signer">Chyba při otevírání aplikace pro podpis</string>
|
||||
<string name="sign_request_rejected">Žádost o podpis byla zamítnuta</string>
|
||||
<string name="no_wallet_found_with_error">Nebyly nalezeny žádné peněženky pro platbu bleskové faktury (Chyba: %1$s). Prosím, nainstalujte si bleskovou peněženku pro použití Zapů</string>
|
||||
<string name="no_wallet_found">Nebyly nalezeny žádné peněženky pro platbu bleskové faktury. Prosím, nainstalujte si bleskovou peněženku pro použití Zapů</string>
|
||||
<string name="hidden_words">Skrytá slova</string>
|
||||
<string name="hide_new_word_label">Skrýt nové slovo nebo větu</string>
|
||||
</resources>
|
||||
|
||||
@@ -418,6 +418,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="automatically_load_images_gifs">Bilder/GIFs automatisch laden</string>
|
||||
<string name="automatically_play_videos">Videos automatisch abspielen</string>
|
||||
<string name="automatically_show_url_preview">URL-Vorschau automatisch anzeigen</string>
|
||||
<string name="automatically_hide_nav_bars">Immersives Scrollen</string>
|
||||
<string name="automatically_hide_nav_bars_description">Navigationsleisten beim Scrollen ausblenden</string>
|
||||
<string name="load_image">Bild laden</string>
|
||||
<string name="spamming_users">Spammer</string>
|
||||
<string name="muted_button">Ton aus. Klicken, um Ton einzuschalten</string>
|
||||
@@ -446,6 +448,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="messages_group_descriptor">Mitglieder dieser Gruppe</string>
|
||||
<string name="messages_new_subject_message">Erklärung an Mitglieder</string>
|
||||
<string name="messages_new_subject_message_placeholder">Ändern des Namens für die neuen Ziele.</string>
|
||||
<string name="paste_from_clipboard">Aus Zwischenablage einfügen</string>
|
||||
<string name="language_description">Für die App-Benutzeroberfläche</string>
|
||||
<string name="theme_description">Dunkles, helles oder Systemdesign</string>
|
||||
<string name="automatically_load_images_gifs_description">Bilder und GIFs automatisch laden</string>
|
||||
@@ -456,6 +459,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
|
||||
<string name="created_at">Erstellt am</string>
|
||||
<string name="rules">Regeln</string>
|
||||
<string name="login_with_external_signer">Mit Amber anmelden</string>
|
||||
<string name="status_update">Status aktualisieren</string>
|
||||
<string name="lightning_wallets_not_found">Fehler beim Verarbeiten der Fehlermeldung</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Die Abstimmungen werden nach der Höhe des Zaps gewichtet. Sie können einen Mindestbetrag festlegen, um Spam zu verhindern, und einen Höchstbetrag, um zu verhindern, dass große Zapper die Abstimmung dominieren. Verwenden Sie denselben Betrag in beiden Feldern, um sicherzustellen, dass jeder Stimme der gleiche Wert zukommt. Lassen Sie es leer, um jeden Betrag zu akzeptieren.</string>
|
||||
@@ -469,4 +473,21 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="active_for_home">Startseite</string>
|
||||
<string name="active_for_msg">Nachrichten</string>
|
||||
<string name="active_for_search">Suche</string>
|
||||
<string name="zap_split_title">Zaps aufteilen und weiterleiten</string>
|
||||
<string name="zap_split_explainer">Unterstützende Clients werden Zaps an die hier hinzugefügten Benutzer aufteilen und weiterleiten, anstatt an Sie</string>
|
||||
<string name="zap_split_serarch_and_add_user">Benutzer suchen und hinzufügen</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Benutzername oder Anzeigename</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Benutzer %1$s hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
|
||||
<string name="zap_split_weight">Prozent</string>
|
||||
<string name="splitting_zaps_with">Zaps aufteilen mit</string>
|
||||
<string name="forwarding_zaps_to">Zaps weiterleiten an</string>
|
||||
<string name="lightning_wallets_not_found2">Lightning-Wallets nicht gefunden</string>
|
||||
<string name="paid">Bezahlt</string>
|
||||
<string name="wallet_number">Brieftasche %1$s</string>
|
||||
<string name="error_opening_external_signer">Fehler beim Öffnen der Signatur-App</string>
|
||||
<string name="sign_request_rejected">Signaturanfrage abgelehnt</string>
|
||||
<string name="no_wallet_found_with_error">Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen (Fehler: %1$s). Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden</string>
|
||||
<string name="no_wallet_found">Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen. Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden</string>
|
||||
<string name="hidden_words">Versteckte Wörter</string>
|
||||
<string name="hide_new_word_label">Neues Wort oder neuen Satz verstecken</string>
|
||||
</resources>
|
||||
|
||||
@@ -430,6 +430,8 @@
|
||||
<string name="automatically_load_images_gifs">Prévisualisation des images</string>
|
||||
<string name="automatically_play_videos">Lecture vidéo</string>
|
||||
<string name="automatically_show_url_preview">Prévisualisation des URLs</string>
|
||||
<string name="automatically_hide_nav_bars">Défilement immersif</string>
|
||||
<string name="automatically_hide_nav_bars_description">Masquer les barres de navigation lors du défilement</string>
|
||||
<string name="load_image">Charger l\'image</string>
|
||||
<string name="spamming_users">Spammeurs</string>
|
||||
<string name="muted_button">Silencieux. Cliquer pour réactiver le son</string>
|
||||
@@ -458,6 +460,7 @@
|
||||
<string name="messages_group_descriptor">Membres de ce groupe</string>
|
||||
<string name="messages_new_subject_message">Explication aux membres</string>
|
||||
<string name="messages_new_subject_message_placeholder">Changement de nom pour les nouveaux objectifs.</string>
|
||||
<string name="paste_from_clipboard">Coller depuis le presse-papiers</string>
|
||||
<string name="language_description">Pour l\'interface de l\'App</string>
|
||||
<string name="theme_description">Sombre, Clair ou thème Système</string>
|
||||
<string name="automatically_load_images_gifs_description">Charger automatiquement les images et les GIFs</string>
|
||||
@@ -468,6 +471,7 @@
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copier l\'ID de la note dans le presse-papiers</string>
|
||||
<string name="created_at">Créé le</string>
|
||||
<string name="rules">Règles</string>
|
||||
<string name="login_with_external_signer">Se connecter avec Amber</string>
|
||||
<string name="status_update">Mettez à jour votre statut</string>
|
||||
<string name="lightning_wallets_not_found">Erreur d\'analyse du message d\'erreur</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Les votes sont pondérés par le montant du zap. Vous pouvez définir un montant minimum pour éviter les spammeurs et un montant maximum pour éviter qu\'un grand nombre de zappeurs ne prenne le contrôle du sondage. Utilisez le même montant dans les deux champs pour vous assurer que chaque vote a la même valeur. Laissez le champ vide pour accepter n\'importe quel montant.</string>
|
||||
@@ -496,4 +500,10 @@
|
||||
<string name="lightning_wallets_not_found2">Portefeuilles Lightning introuvables</string>
|
||||
<string name="paid">Payé</string>
|
||||
<string name="wallet_number">Portefeuille %1$s</string>
|
||||
<string name="error_opening_external_signer">Erreur lors de l\'ouverture de l\'application de signature</string>
|
||||
<string name="sign_request_rejected">Demande de signature rejetée</string>
|
||||
<string name="no_wallet_found_with_error">Aucun portefeuille trouvé pour payer une facture lightning (Erreur : %1$s). Veuillez installer un portefeuille Lightning pour utiliser les zaps</string>
|
||||
<string name="no_wallet_found">Aucun portefeuille trouvé pour payer une facture lightning. Veuillez installer un portefeuille Lightning pour utiliser les zaps</string>
|
||||
<string name="hidden_words">Mots Masqués</string>
|
||||
<string name="hide_new_word_label">Masquer un nouveau mot ou une phrase</string>
|
||||
</resources>
|
||||
|
||||
@@ -458,6 +458,7 @@
|
||||
<string name="messages_group_descriptor">A csoport tagjai</string>
|
||||
<string name="messages_new_subject_message">Magyarázat a csoport tagjainak</string>
|
||||
<string name="messages_new_subject_message_placeholder">Az új célok érdekében, a név megváltoztatása.</string>
|
||||
<string name="paste_from_clipboard">Beszúrás vágólapról</string>
|
||||
<string name="language_description">Az applikáció felülete</string>
|
||||
<string name="theme_description">Sötét, Világos vagy Rendszer által használt téma</string>
|
||||
<string name="automatically_load_images_gifs_description">Képek és GIF-ek automatikus betöltése</string>
|
||||
@@ -468,6 +469,7 @@
|
||||
<string name="copy_the_note_id_to_the_clipboard">A bejegyzésazonosító vágólapra másolása</string>
|
||||
<string name="created_at">Létrehozva</string>
|
||||
<string name="rules">Szabályok</string>
|
||||
<string name="login_with_external_signer">Bejelentkezés Amber-el</string>
|
||||
<string name="status_update">Állapotod megjelenítése</string>
|
||||
<string name="lightning_wallets_not_found">Hiba a hibaüzenet elemzésekor</string>
|
||||
<string name="poll_zap_value_min_max_explainer">A szavazatokat a Zap-ek összegével súlyozzuk. Beállíthatsz egy minimális összeget, hogy a kéretlen leveleket elkerüld, és egy maximális összeget annak elkerülésére, hogy a szavazás feletti irányítást a nagy Zapperek vegyék át. Mindkét mezőben ugyanazt az összeget használd, hogy minden szavazat azonos értéket kapjon. Bármilyen összeg elfogadásához, hagyjd üresen.</string>
|
||||
@@ -484,4 +486,20 @@
|
||||
<string name="active_for_chats">Chat</string>
|
||||
<string name="active_for_global">Globális</string>
|
||||
<string name="active_for_search">Keresés</string>
|
||||
<string name="zap_split_title">Zap-ek megosztása és továbbítása</string>
|
||||
<string name="zap_split_explainer">A támogató kliensek a zapokat, az itt hozzáadott felhasználóknak helyetted felosztják és továbbítják</string>
|
||||
<string name="zap_split_serarch_and_add_user">Felhasználó keresése és hozzáadása</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Felhasználónév vagy megjelenítendő név</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">A %1$s felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
|
||||
<string name="zap_split_weight">Százalék</string>
|
||||
<string name="zap_split_weight_placeholder">25</string>
|
||||
<string name="splitting_zaps_with">Zap-ek megosztása</string>
|
||||
<string name="forwarding_zaps_to">Zap-ek továbbítása</string>
|
||||
<string name="lightning_wallets_not_found2">Lightning tárca nem található</string>
|
||||
<string name="paid">Fizetve</string>
|
||||
<string name="wallet_number">Tárca: %1$s</string>
|
||||
<string name="error_opening_external_signer">Hiba az aláíró alkalmazás megnyitásakor</string>
|
||||
<string name="sign_request_rejected">Aláírási kérés elutasítva</string>
|
||||
<string name="no_wallet_found_with_error">Nem található tárca a Lighning számla kifizetésére (Hiba: %1$s). Kérjük, a zap-ek használatához telepítsen egy Lightning pénztárcát</string>
|
||||
<string name="no_wallet_found">Nem található tárca a Lighning számla kifizetésére. Kérjük, a zap-ek használatához telepítsen egy Lightning pénztárcát</string>
|
||||
</resources>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<string name="login_with_a_private_key_to_be_able_to_unfollow">Faça login com uma chave privada para poder remover seguidores</string>
|
||||
<string name="view_count">Contagem de visualizações</string>
|
||||
<string name="boost">Impulsionar</string>
|
||||
<string name="boosted">impulsionado</string>
|
||||
<string name="quote">Citar</string>
|
||||
<string name="new_amount_in_sats">Novo Valor em Sats</string>
|
||||
<string name="add">Adicionar</string>
|
||||
@@ -44,6 +45,8 @@
|
||||
<string name="and">" e "</string>
|
||||
<string name="in_channel">"no canal "</string>
|
||||
<string name="profile_banner">Banner do perfil</string>
|
||||
<string name="payment_successful">Pagamento bem-sucedido</string>
|
||||
<string name="error_parsing_error_message">Erro ao analisar mensagem de erro</string>
|
||||
<string name="following">" Seguindo"</string>
|
||||
<string name="followers">" Seguidores"</string>
|
||||
<string name="profile">Perfil</string>
|
||||
@@ -72,6 +75,7 @@
|
||||
<string name="cancel">Cancelar</string>
|
||||
<string name="failed_to_upload_the_image">Falha ao enviar imagem</string>
|
||||
<string name="relay_address">Endereço do Relay</string>
|
||||
<string name="posts">Postagens</string>
|
||||
<string name="errors">Erros</string>
|
||||
<string name="home_feed">Feed principal</string>
|
||||
<string name="private_message_feed">Feed de mensagens privadas</string>
|
||||
@@ -159,6 +163,7 @@
|
||||
<string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string>
|
||||
<string name="translations_always_translate_to_lang">Sempre traduzir para %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">Nunca traduzir de %1$s</string>
|
||||
<string name="nip_05">Endereço Nostr</string>
|
||||
<string name="never">nunca</string>
|
||||
<string name="now">agora</string>
|
||||
<string name="nudity">Nudez</string>
|
||||
@@ -239,6 +244,9 @@
|
||||
<string name="remove_from_public_bookmarks">Remover dos Itens Salvos Públicos</string>
|
||||
<string name="wallet_connect_service">Serviço Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Use sua chave privada para pagar zaps sem sair do app. Qualquer pessoa com acesso à sua chave privada Nostr poderá gastar o saldo da sua carteira. Guarde apenas os fundos que você pode perder e use um retransmissor privado, se possível. O operador de Relay pode ver seus metadados de pagamentos.</string>
|
||||
<string name="wallet_connect_service_pubkey">Chave pública do Wallet Connect</string>
|
||||
<string name="wallet_connect_service_relay">Relay do Wallet Connect</string>
|
||||
<string name="wallet_connect_service_secret">Segredo do Wallet Connect</string>
|
||||
<string name="wallet_connect_service_show_secret">Mostrar chave secreta</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">chave privada nsec / hex</string>
|
||||
<string name="pledge_amount_in_sats">Valor em Sats</string>
|
||||
@@ -355,6 +363,7 @@
|
||||
<string name="sats_to_complete">Arrecadação de Zaps em %1$s. %2$s sats para meta</string>
|
||||
<string name="read_from_relay">Ler do Relay</string>
|
||||
<string name="write_to_relay">Enviar para o Relay</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Ocorreu um erro ao tentar obter informações do relay de %1$s</string>
|
||||
<string name="owner">Proprietário</string>
|
||||
<string name="version">Versão</string>
|
||||
<string name="software">Programa</string>
|
||||
@@ -378,6 +387,8 @@
|
||||
<string name="payment">Pagamento</string>
|
||||
<string name="cashu">Token Cashu</string>
|
||||
<string name="cashu_redeem">Resgatar</string>
|
||||
<string name="no_lightning_address_set">Nenhum endereço Lightning definido</string>
|
||||
<string name="copied_token_to_clipboard">Token copiado para a área de transferência</string>
|
||||
<string name="live_stream_live_tag">AO VIVO</string>
|
||||
<string name="live_stream_offline_tag">DESLIGADA</string>
|
||||
<string name="live_stream_ended_tag">FINALIZADA</string>
|
||||
@@ -396,6 +407,7 @@
|
||||
<string name="settings">Configurações</string>
|
||||
<string name="connectivity_type_always">Sempre</string>
|
||||
<string name="connectivity_type_wifi_only">Somente wifi</string>
|
||||
<string name="connectivity_type_never">Nunca</string>
|
||||
<string name="system">Sistema</string>
|
||||
<string name="light">Claro</string>
|
||||
<string name="dark">Escuro</string>
|
||||
@@ -406,5 +418,71 @@
|
||||
<string name="automatically_play_videos">Reproduzir vídeos automaticamente</string>
|
||||
<string name="automatically_show_url_preview">Mostrar automaticamente a visualização da URL</string>
|
||||
<string name="load_image">Carregar imagem</string>
|
||||
<string name="muted_button">Silenciado. Clique para ativar o som</string>
|
||||
<string name="mute_button">Som ligado. Clique para silenciar</string>
|
||||
<string name="search_button">Pesquise registros locais e remotos</string>
|
||||
<string name="nip05_verified">O endereço Nostr foi verificado</string>
|
||||
<string name="nip05_failed">Endereço Nostr falhou na verificação</string>
|
||||
<string name="nip05_checking">Verificando o endereço Nostr</string>
|
||||
<string name="select_deselect_all">Selecionar/desmarcar tudo</string>
|
||||
<string name="default_relays">Padrão</string>
|
||||
<string name="select_a_relay_to_continue">Selecione um relay para continuar</string>
|
||||
<string name="zap_forward_title">Encaminhar Zaps para:</string>
|
||||
<string name="zap_forward_explainer">Os clientes que suportam encaminharão zaps para o endereço lightning ou perfil de usuário abaixo, em vez do seu</string>
|
||||
<string name="geohash_title">Expor localização como </string>
|
||||
<string name="geohash_explainer">Adiciona um Geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual</string>
|
||||
<string name="add_sensitive_content_explainer">Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_title">Novo recurso</string>
|
||||
<string name="new_feature_nip24_might_not_be_available_description">Ativando este modo requer o Amethyst para enviar uma mensagem de NIP-24 (GiftWrapped, Sealed Direct and Group Messages). NIP-24 é novo e a maioria dos clientes ainda não o implementaram. Certifique-se de que o destinatário está usando um cliente compatível.</string>
|
||||
<string name="new_feature_nip24_activate">Ativar</string>
|
||||
<string name="messages_create_public_chat">Público</string>
|
||||
<string name="messages_new_message">Privado</string>
|
||||
<string name="messages_new_message_to">Para</string>
|
||||
<string name="messages_new_message_subject">Assunto</string>
|
||||
<string name="messages_new_message_subject_caption">Tema da conversa</string>
|
||||
<string name="messages_new_message_to_caption">"\@Usuário1, @Usuário2, @Usuário3"</string>
|
||||
<string name="messages_group_descriptor">Membros deste grupo</string>
|
||||
<string name="messages_new_subject_message">Explicação aos membros</string>
|
||||
<string name="messages_new_subject_message_placeholder">Mudando o nome dos novos objetivos.</string>
|
||||
<string name="paste_from_clipboard">Colar da área de transferência</string>
|
||||
<string name="language_description">Para a interface do aplicativo</string>
|
||||
<string name="theme_description">Tema Escuro, Claro ou Sistema</string>
|
||||
<string name="automatically_load_images_gifs_description">Carregar automaticamente imagens e GIFs</string>
|
||||
<string name="automatically_play_videos_description">Reproduz automaticamente vídeos e GIFs</string>
|
||||
<string name="automatically_show_url_preview_description">Mostrar visualizações de URL</string>
|
||||
<string name="load_image_description">Quando carregar imagens</string>
|
||||
<string name="copy_url_to_clipboard">Copiar URL para a área de transferência</string>
|
||||
<string name="copy_the_note_id_to_the_clipboard">Copiar ID da nota para a área de transferência</string>
|
||||
<string name="created_at">Criado em</string>
|
||||
<string name="rules">Regras</string>
|
||||
<string name="login_with_external_signer">Login com Amber</string>
|
||||
<string name="status_update">Atualize seu status</string>
|
||||
<string name="lightning_wallets_not_found">Erro ao analisar mensagem de erro</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Os votos são ponderados pelo valor do zap. Você pode definir um valor mínimo para evitar spammers e um valor máximo para evitar que grandes zappers assumam o controle da enquete. Use o mesmo valor em ambos os campos para garantir que cada voto tenha o mesmo valor. Deixe em branco para aceitar qualquer valor.</string>
|
||||
<string name="error_dialog_zap_error">Não foi possível enviar o Zap</string>
|
||||
<string name="error_dialog_talk_to_user">Mensagem ao usuário</string>
|
||||
<string name="relay_information_document_error_assemble_url">Falha ao acessar %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_reach_server">Falha ao acessar %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_parse_result">Falha ao analisar o resultado de %1$s: %2$s</string>
|
||||
<string name="relay_information_document_error_http_status">%1$s falhou com o código %2$s</string>
|
||||
<string name="active_for">Ativo para: </string>
|
||||
<string name="active_for_home">Início</string>
|
||||
<string name="active_for_msg">Mensagens diretas</string>
|
||||
<string name="active_for_chats">Bate-papos</string>
|
||||
<string name="active_for_search">Pesquisa</string>
|
||||
<string name="zap_split_title">Dividir e encaminhar Zaps</string>
|
||||
<string name="zap_split_explainer">Os clientes que suportam dividirão e encaminharão zaps para os usuários adicionados aqui em vez dos seus</string>
|
||||
<string name="zap_split_serarch_and_add_user">Pesquisar e adicionar usuário</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">Nome de usuário ou nome de exibição</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">O usuário %1$s não possui um endereço lightning configurado para receber sats</string>
|
||||
<string name="zap_split_weight">Percentual</string>
|
||||
<string name="splitting_zaps_with">Dividindo zaps com</string>
|
||||
<string name="forwarding_zaps_to">Encaminhando zaps para</string>
|
||||
<string name="lightning_wallets_not_found2">Carteira Lightning não encontrada</string>
|
||||
<string name="paid">Pago</string>
|
||||
<string name="wallet_number">Carteira %1$s</string>
|
||||
<string name="error_opening_external_signer">Erro ao abrir o aplicativo para assinar evento</string>
|
||||
<string name="sign_request_rejected">Solicitação de assinatura rejeitada</string>
|
||||
<string name="no_wallet_found_with_error">Nenhuma carteira encontrada para pagar uma fatura Lightning (Erro: %1$s). Instale uma carteira Lightning para usar zaps</string>
|
||||
<string name="no_wallet_found">Nenhuma carteira encontrada para pagar uma fatura Lightning. Instale uma carteira Lightning para usar zaps</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
@@ -456,6 +458,7 @@
|
||||
<string name="messages_group_descriptor">Medlemmar i denna grupp</string>
|
||||
<string name="messages_new_subject_message">Förklaring till medlemmar</string>
|
||||
<string name="messages_new_subject_message_placeholder">Ändra namnet för de nya målen.</string>
|
||||
<string name="paste_from_clipboard">Klistra in från urklipp</string>
|
||||
<string name="language_description">För appens gränssnitt</string>
|
||||
<string name="theme_description">Mörkt, Ljust eller Systemtema</string>
|
||||
<string name="automatically_load_images_gifs_description">Ladda automatiskt bilder och GIFs</string>
|
||||
@@ -466,6 +469,7 @@
|
||||
<string name="copy_the_note_id_to_the_clipboard">Kopiera anteckningens ID till urklipp</string>
|
||||
<string name="created_at">Skapad den</string>
|
||||
<string name="rules">Regler</string>
|
||||
<string name="login_with_external_signer">Logga in med Amber</string>
|
||||
<string name="status_update">Uppdatera din status</string>
|
||||
<string name="lightning_wallets_not_found">Fel vid tolkning av felmeddelande</string>
|
||||
<string name="poll_zap_value_min_max_explainer">Röster viktas av zap-beloppet. Du kan ställa in en minsta mängd för att undvika spammare och en maximal mängd för att undvika att en stor zapper tar över omröstningen. Använd samma belopp i båda fälten för att se till att varje röst värderas till samma belopp. Lämna tomt för att acceptera valfritt belopp.</string>
|
||||
@@ -482,4 +486,22 @@
|
||||
<string name="active_for_chats">Chattar</string>
|
||||
<string name="active_for_global">Globalt</string>
|
||||
<string name="active_for_search">Sök</string>
|
||||
<string name="zap_split_title">Dela och vidarebefordra Zaps</string>
|
||||
<string name="zap_split_explainer">Stödjande klienter kommer att dela och vidarebefordra zaps till användarna som har lagts till här istället för dig</string>
|
||||
<string name="zap_split_serarch_and_add_user">Sök och lägg till användare</string>
|
||||
<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>
|
||||
<string name="paid">Betald</string>
|
||||
<string name="wallet_number">Plånbok %1$s</string>
|
||||
<string name="error_opening_external_signer">Fel vid öppning av signeringsapp</string>
|
||||
<string name="sign_request_rejected">Underteckningsbegäran avvisad</string>
|
||||
<string name="no_wallet_found_with_error">Inga plånböcker hittades för att betala en blixtfaktura (Fel: %1$s). Installera en blixtpengaplånbok för att använda zaps</string>
|
||||
<string name="no_wallet_found">Inga plånböcker hittades för att betala en blixtfaktura. Installera en blixtpengaplånbok för att använda zaps</string>
|
||||
<string name="hidden_words">Dolda ord</string>
|
||||
<string name="hide_new_word_label">Dölj nytt ord eller mening</string>
|
||||
</resources>
|
||||
|
||||
@@ -457,6 +457,7 @@
|
||||
<string name="messages_group_descriptor">此群组成员</string>
|
||||
<string name="messages_new_subject_message">对成员的解释</string>
|
||||
<string name="messages_new_subject_message_placeholder">为新目标更改名称。</string>
|
||||
<string name="paste_from_clipboard">粘贴自剪贴板</string>
|
||||
<string name="language_description">用于应用程序界面</string>
|
||||
<string name="theme_description">暗色、亮色或系统主题</string>
|
||||
<string name="automatically_load_images_gifs_description">自动加载图像和 GIF</string>
|
||||
@@ -467,6 +468,7 @@
|
||||
<string name="copy_the_note_id_to_the_clipboard">复制笔记 ID 到剪贴板</string>
|
||||
<string name="created_at">创建于</string>
|
||||
<string name="rules">规则</string>
|
||||
<string name="login_with_external_signer">使用 Amber 登录</string>
|
||||
<string name="status_update">更新你的状态</string>
|
||||
<string name="lightning_wallets_not_found">错误解析错误消息</string>
|
||||
<string name="poll_zap_value_min_max_explainer">投票按照打闪金额进行加权。 你可以设置最小金额以避免垃圾邮件,也可以设置最大金额以避免大型攻击者攻击民意调查。在两个字段中使用相同的金额以确保每张投票的价值相同。 留空即可接受任何金额。</string>
|
||||
@@ -483,4 +485,20 @@
|
||||
<string name="active_for_chats">聊天</string>
|
||||
<string name="active_for_global">全球</string>
|
||||
<string name="active_for_search">搜索</string>
|
||||
<string name="zap_split_title">拆分及转发打闪</string>
|
||||
<string name="zap_split_explainer">支持的客户端将会把打闪拆分及转发到在这里添加的用户,而不是你的</string>
|
||||
<string name="zap_split_serarch_and_add_user">搜索并添加用户</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">用户名或显示名</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">用户 %1$s 尚未设置接收聪的闪电地址</string>
|
||||
<string name="zap_split_weight">百分比</string>
|
||||
<string name="zap_split_weight_placeholder">25</string>
|
||||
<string name="splitting_zaps_with">与此用户拆分打闪</string>
|
||||
<string name="forwarding_zaps_to">将打闪转发到</string>
|
||||
<string name="lightning_wallets_not_found2">找不到闪电钱包</string>
|
||||
<string name="paid">已支付</string>
|
||||
<string name="wallet_number">钱包 %1$s</string>
|
||||
<string name="error_opening_external_signer">打开签名应用时出错</string>
|
||||
<string name="sign_request_rejected">签名请求被拒绝了</string>
|
||||
<string name="no_wallet_found_with_error">找不到支付闪电发票的钱包(错误:%1$s)。请安装闪电钱包来使用打闪</string>
|
||||
<string name="no_wallet_found">找不到支付闪电发票的钱包。请安装闪电钱包来使用打闪</string>
|
||||
</resources>
|
||||
|
||||
@@ -458,6 +458,7 @@
|
||||
<string name="messages_group_descriptor">此群組成員</string>
|
||||
<string name="messages_new_subject_message">對成員的解釋</string>
|
||||
<string name="messages_new_subject_message_placeholder">為新目標更改名稱。</string>
|
||||
<string name="paste_from_clipboard">粘貼自剪貼板</string>
|
||||
<string name="language_description">用於應用程式界面</string>
|
||||
<string name="theme_description">暗色、亮色或系統主題</string>
|
||||
<string name="automatically_load_images_gifs_description">自動加載圖像和 GIF</string>
|
||||
@@ -468,6 +469,7 @@
|
||||
<string name="copy_the_note_id_to_the_clipboard">將筆記 ID 複製到剪貼簿</string>
|
||||
<string name="created_at">創建於</string>
|
||||
<string name="rules">規則</string>
|
||||
<string name="login_with_external_signer">使用 Amber 登錄</string>
|
||||
<string name="status_update">更新你的狀態</string>
|
||||
<string name="lightning_wallets_not_found">錯誤解析錯誤消息</string>
|
||||
<string name="poll_zap_value_min_max_explainer">投票按照打閃金額進行加權。你可以設置最小金額以避免垃圾郵件,也可以設置最大金額以避免大型攻擊者攻擊民意調查。在兩個字段中使用相同的金額則確保每張投票的價值相同。留空即可接受任何金額。</string>
|
||||
@@ -484,4 +486,20 @@
|
||||
<string name="active_for_chats">聊天</string>
|
||||
<string name="active_for_global">全球</string>
|
||||
<string name="active_for_search">搜索</string>
|
||||
<string name="zap_split_title">拆分及轉發打閃</string>
|
||||
<string name="zap_split_explainer">支持的客戶端將會把打閃拆分及轉發到在這裡添加的用戶,而不是你的</string>
|
||||
<string name="zap_split_serarch_and_add_user">搜索並添加用戶</string>
|
||||
<string name="zap_split_serarch_and_add_user_placeholder">用戶名或顯示名</string>
|
||||
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">用户 %1$s 尚未設置接收聰的閃電地址</string>
|
||||
<string name="zap_split_weight">百分比</string>
|
||||
<string name="zap_split_weight_placeholder">25</string>
|
||||
<string name="splitting_zaps_with">與此用戶拆分打閃</string>
|
||||
<string name="forwarding_zaps_to">將打閃轉發到</string>
|
||||
<string name="lightning_wallets_not_found2">找不到閃電錢包</string>
|
||||
<string name="paid">已支付</string>
|
||||
<string name="wallet_number">錢包:%1$s</string>
|
||||
<string name="error_opening_external_signer">打開簽署程式時出錯</string>
|
||||
<string name="sign_request_rejected">簽署請求被拒絕了</string>
|
||||
<string name="no_wallet_found_with_error">找不到支付閃電發票的錢包(錯誤:%1$s)。請安裝一個閃電錢包來使用打閃</string>
|
||||
<string name="no_wallet_found">找不到支付閃電發票的錢包。請安裝一個閃電錢包來使用打閃</string>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
@@ -590,4 +592,10 @@
|
||||
<string name="wallet_number">Wallet %1$s</string>
|
||||
<string name="error_opening_external_signer">Error opening signer app</string>
|
||||
<string name="sign_request_rejected">Sign request rejected</string>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,7 @@ buildscript {
|
||||
fragment_version = "1.6.1"
|
||||
lifecycle_version = '2.6.2'
|
||||
compose_ui_version = '1.5.1'
|
||||
nav_version = '2.7.2'
|
||||
nav_version = '2.7.3'
|
||||
room_version = "2.4.3"
|
||||
accompanist_version = '0.30.1'
|
||||
coil_version = '2.4.0'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class FileHeaderEvent(
|
||||
val tags = listOfNotNull(
|
||||
listOf(URL, url),
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
alt?.let { listOf(ALT, it) },
|
||||
alt?.ifBlank { null }?.let { listOf(ALT, it) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
|
||||
@@ -59,7 +59,7 @@ class FileStorageHeaderEvent(
|
||||
val tags = listOfNotNull(
|
||||
listOf("e", storageEvent.id),
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
alt?.let { listOf(ALT, it) },
|
||||
alt?.ifBlank { null }?.let { listOf(ALT, it) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -27,13 +27,16 @@ class PollNoteEvent(
|
||||
tags.filter { it.size > 2 && it[0] == POLL_OPTION }
|
||||
.associate { it[1].toInt() to it[2] }
|
||||
|
||||
fun getTagInt(property: String): Int? {
|
||||
fun minimumAmount() = tags.firstOrNull() { it.size > 1 && it[0] == VALUE_MINIMUM }?.getOrNull(1)?.toLongOrNull()
|
||||
fun maximumAmount() = tags.firstOrNull() { it.size > 1 && it[0] == VALUE_MAXIMUM }?.getOrNull(1)?.toLongOrNull()
|
||||
|
||||
fun getTagLong(property: String): Long? {
|
||||
val number = tags.firstOrNull() { it.size > 1 && it[0] == property }?.get(1)
|
||||
|
||||
return if (number.isNullOrBlank() || number == "null") {
|
||||
null
|
||||
} else {
|
||||
number.toInt()
|
||||
number.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +74,18 @@ class PollNoteEvent(
|
||||
pollOptions.forEach { poll_op ->
|
||||
tags.add(listOf(POLL_OPTION, poll_op.key.toString(), poll_op.value))
|
||||
}
|
||||
tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString()))
|
||||
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
|
||||
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
|
||||
tags.add(listOf(CLOSED_AT, closedAt.toString()))
|
||||
|
||||
valueMaximum?.let {
|
||||
tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString()))
|
||||
}
|
||||
valueMinimum?.let {
|
||||
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
|
||||
}
|
||||
consensusThreshold?.let {
|
||||
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
|
||||
}
|
||||
closedAt?.let {
|
||||
tags.add(listOf(CLOSED_AT, closedAt.toString()))
|
||||
}
|
||||
zapReceiver?.forEach {
|
||||
tags.add(listOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user