mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-06 22:54:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7596f2a43e | ||
|
|
6b7bb209d4 | ||
|
|
fcdb863c2b | ||
|
|
4e5a9f7366 | ||
|
|
758f3636f3 | ||
|
|
7dafb8317a | ||
|
|
21a5827719 | ||
|
|
b9486eb3e6 | ||
|
|
dbbfaae02c | ||
|
|
44b25b7c7c | ||
|
|
0dabf3f2d3 | ||
|
|
a85ea70e4f | ||
|
|
a02519b021 | ||
|
|
04e70a7886 | ||
|
|
3bfaef2b45 | ||
|
|
e992e72f4d | ||
|
|
1c545ee33a | ||
|
|
ab93a39ad6 | ||
|
|
7f25e1c434 | ||
|
|
67b7bd3258 | ||
|
|
e2aaa8efe0 | ||
|
|
5356d869fd | ||
|
|
d87542a949 | ||
|
|
98d29ac6dc | ||
|
|
532048c35e | ||
|
|
386960dc62 | ||
|
|
ae3cc5da2a | ||
|
|
34c2ba4922 | ||
|
|
074a7d41fd | ||
|
|
7d4fbc3e04 | ||
|
|
a2d38381b1 | ||
|
|
19fb129f7c | ||
|
|
5bfc53136b | ||
|
|
1677606b15 | ||
|
|
8c10173b68 | ||
|
|
285ae056a2 | ||
|
|
8cdd3be085 | ||
|
|
8f586780a1 | ||
|
|
dc3c730e3b | ||
|
|
df823102da | ||
|
|
1ab857d72b | ||
|
|
78863b26d0 | ||
|
|
0f74bc7b74 | ||
|
|
e7893cacd5 | ||
|
|
8a18781048 | ||
|
|
ad52855f5a | ||
|
|
50fbde17df | ||
|
|
c6d47ca9c6 | ||
|
|
072b14bdda | ||
|
|
70964e680a | ||
|
|
954330064d | ||
|
|
3666a0b980 | ||
|
|
61f52ef62e | ||
|
|
74d66f2035 | ||
|
|
0de4cd5c18 | ||
|
|
d1bef11f88 | ||
|
|
3253b8528f | ||
|
|
81661c003a | ||
|
|
fbe395f071 | ||
|
|
d5cf64f048 | ||
|
|
7212d566c9 | ||
|
|
17490e948c | ||
|
|
b392ea0dca | ||
|
|
9fc0c37845 | ||
|
|
6db031b208 | ||
|
|
7246a545af | ||
|
|
c8365439ee | ||
|
|
003d6bf018 | ||
|
|
060286c292 | ||
|
|
270935a30a | ||
|
|
05e9a1ac3f | ||
|
|
9acad8b3f5 | ||
|
|
5a9d6929e5 | ||
|
|
e45f02c110 | ||
|
|
4f1ae72ab5 | ||
|
|
d4ed6661dd | ||
|
|
2976349114 | ||
|
|
b1cfa2219d | ||
|
|
df9b764c1d | ||
|
|
69941002df | ||
|
|
afc6a91c6b | ||
|
|
c8b9858e4d | ||
|
|
c4ea6f3619 | ||
|
|
872d70c175 | ||
|
|
5ad6a2c70b | ||
|
|
408b6793c0 | ||
|
|
43d03bc109 | ||
|
|
9377c902a1 | ||
|
|
57d97447f4 | ||
|
|
5493ce0dcf | ||
|
|
499149b7d9 | ||
|
|
3fd0c61c0e | ||
|
|
1d62d8ed73 | ||
|
|
2c3a7daa65 | ||
|
|
1771b6e667 | ||
|
|
853dcb2127 | ||
|
|
b25f346370 | ||
|
|
edd13fbe5b | ||
|
|
92c72e5a7b | ||
|
|
e8e676afd5 | ||
|
|
bb16ee7f0f | ||
|
|
4eb1c101b9 | ||
|
|
eb67b6e6cf | ||
|
|
29541bd1e3 | ||
|
|
08f94e9199 | ||
|
|
a7c7773a81 | ||
|
|
75f2d5710a | ||
|
|
2bff9fe891 | ||
|
|
4d2c405aea | ||
|
|
ca24afd23e | ||
|
|
47e124c7fc | ||
|
|
885aded701 | ||
|
|
e0729b7236 | ||
|
|
a058b75ee4 | ||
|
|
0a5e84fd3c | ||
|
|
6fa36255b2 | ||
|
|
3de52a6c0d | ||
|
|
6d17c8d79f | ||
|
|
26c4677b75 | ||
|
|
c5882a9f05 | ||
|
|
f99dc471a1 | ||
|
|
25e31b00d2 | ||
|
|
c1a59a1d21 | ||
|
|
e0d2a71b47 | ||
|
|
ef90b82396 | ||
|
|
9ec6e6d955 | ||
|
|
6b7ec2caa9 | ||
|
|
17130c81c8 | ||
|
|
0571312dca | ||
|
|
717c3bc45e | ||
|
|
c304f7c69d | ||
|
|
6d2a1ec1b9 | ||
|
|
59f7f5257b | ||
|
|
ba1f2dc17d | ||
|
|
820def620c | ||
|
|
9fef3e9e3e | ||
|
|
e8998a26d7 | ||
|
|
221ab1ca1a | ||
|
|
2f1e67779b | ||
|
|
96663b822c | ||
|
|
c7895d1cdd | ||
|
|
85762c581a | ||
|
|
e189a4d68d | ||
|
|
3306dd88d2 | ||
|
|
18952bc8a7 | ||
|
|
f6cfba6e1f | ||
|
|
fb37ccd14f | ||
|
|
3858c57816 | ||
|
|
572c11e847 | ||
|
|
bd70bd708b | ||
|
|
9a2ecfc85d | ||
|
|
f20fd1aebe | ||
|
|
2f2914077b | ||
|
|
712d498788 | ||
|
|
1f28d3f32f |
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.0" />
|
||||
<option name="version" value="1.9.10" />
|
||||
</component>
|
||||
</project>
|
||||
+78
-12
@@ -13,14 +13,78 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 325
|
||||
versionName "0.80.2"
|
||||
versionCode 333
|
||||
versionName "0.81.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resourceConfigurations += ['ar', 'cs', 'de', 'eo', 'es', 'fa', 'fr', 'hu', 'in', 'ja', 'night', 'nl', 'pt-rBR', 'ru', 'sv-rSE', 'ta', 'th', 'tr', 'uk', 'zh', 'sh-rHK', 'zh-rTW']
|
||||
resourceConfigurations += [
|
||||
'ar',
|
||||
'bn-rBD',
|
||||
'cs',
|
||||
'cy-rGB',
|
||||
'da-rDK',
|
||||
'de',
|
||||
'el-rGB',
|
||||
'en-rGB',
|
||||
'eo',
|
||||
'es',
|
||||
'es-rES',
|
||||
'es-rMX',
|
||||
'es-rUS',
|
||||
'et-rEE',
|
||||
'fa',
|
||||
'fi-rFI',
|
||||
'fo-rFO',
|
||||
'fr',
|
||||
'fr-rCA',
|
||||
'gu-rIN',
|
||||
'hi-rIN',
|
||||
'hr-rHR',
|
||||
'hu',
|
||||
'in',
|
||||
'in-rID',
|
||||
'it-rIT',
|
||||
'iw-rIL',
|
||||
'ja',
|
||||
'kk-rKZ',
|
||||
'ko-rKR',
|
||||
'ks-rIN',
|
||||
'ku-rTR',
|
||||
'lt-rLT',
|
||||
'ne-rNP',
|
||||
'night',
|
||||
'nl',
|
||||
'nl-rBE',
|
||||
'pcm-rNG',
|
||||
'pl-rPL',
|
||||
'pt-rBR',
|
||||
'pt-rPT',
|
||||
'ru',
|
||||
'ru-rUA',
|
||||
'sa-rIN',
|
||||
'sl-rSI',
|
||||
'so-rSO',
|
||||
'sr-rSP',
|
||||
'ss-rZA',
|
||||
'sv-rSE',
|
||||
'sw-rKE',
|
||||
'sw-rTZ',
|
||||
'ta',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'ur-rIN',
|
||||
'uz-rUZ',
|
||||
'vi-rVN',
|
||||
'zh',
|
||||
'zh-rCN',
|
||||
'zh-rHK',
|
||||
'zh-rSG',
|
||||
'zh-rTW'
|
||||
]
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -73,7 +137,7 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion "1.5.1"
|
||||
kotlinCompilerExtensionVersion "1.5.3"
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
@@ -94,7 +158,7 @@ android {
|
||||
dependencies {
|
||||
implementation project(path: ':quartz')
|
||||
implementation "androidx.core:core-ktx:$core_ktx_version"
|
||||
implementation 'androidx.activity:activity-compose:1.8.0'
|
||||
implementation 'androidx.activity:activity-compose:1.8.1'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -138,9 +202,13 @@ dependencies {
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
|
||||
// view videos
|
||||
implementation "androidx.media3:media3-exoplayer:${exoplayer_version}"
|
||||
implementation "androidx.media3:media3-exoplayer:$media3_version"
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-session:$media3_version"
|
||||
|
||||
// important for proxy / tor
|
||||
implementation "androidx.media3:media3-datasource-okhttp:${exoplayer_version}"
|
||||
implementation "androidx.media3:media3-datasource-okhttp:$media3_version"
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -171,18 +239,15 @@ dependencies {
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation "androidx.media3:media3-exoplayer-hls:$media3_version"
|
||||
implementation "androidx.media3:media3-ui:$media3_version"
|
||||
implementation "androidx.media3:media3-session:$media3_version"
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation 'com.google.mlkit:language-id:17.0.4'
|
||||
|
||||
// Google services model the translate text
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
playImplementation 'com.google.mlkit:translate:17.0.2'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.4.1')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.6.0')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
//PushNotifications(FDroid)
|
||||
@@ -208,6 +273,7 @@ dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'io.mockk:mockk:1.13.8'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
|
||||
androidTestImplementation "androidx.test.ext:junit-ktx:1.2.0-alpha01"
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
|
||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
|
||||
|
||||
@@ -29,11 +29,7 @@ class ImageUploadTesting {
|
||||
var url: String? = null
|
||||
var error: String? = null
|
||||
|
||||
ImageUploader.account = Account(
|
||||
KeyPair()
|
||||
)
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
ImageUploader(Account(KeyPair())).uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class Amethyst : Application() {
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
applicationIOScope.cancel()
|
||||
}
|
||||
|
||||
val videoCache: VideoCache by lazy {
|
||||
val newCache = VideoCache()
|
||||
newCache.initFileCache(instance)
|
||||
newCache.initFileCache(this)
|
||||
newCache
|
||||
}
|
||||
|
||||
private val imageCache: DiskCache by lazy {
|
||||
DiskCache.Builder()
|
||||
.directory(applicationContext.safeCacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
@@ -32,14 +53,8 @@ class Amethyst : Application() {
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectContentUriWithoutPermission()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
// .penaltyDeath()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
@@ -54,9 +69,7 @@ class Amethyst : Application() {
|
||||
}
|
||||
|
||||
fun imageLoaderBuilder(): ImageLoader.Builder {
|
||||
return ImageLoader.Builder(applicationContext).diskCache {
|
||||
SingletonDiskCache.get(applicationContext)
|
||||
}
|
||||
return ImageLoader.Builder(applicationContext).diskCache { imageCache }
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -64,3 +77,9 @@ class Amethyst : Application() {
|
||||
private set
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.safeCacheDir: File
|
||||
get() {
|
||||
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
|
||||
return cacheDir.apply { mkdirs() }
|
||||
}
|
||||
|
||||
@@ -26,10 +26,15 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
@@ -86,6 +91,7 @@ private object PrefKeys {
|
||||
const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars"
|
||||
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
|
||||
const val AUTOMATICALLY_SHOW_PROFILE_PICTURE = "automatically_show_profile_picture"
|
||||
const val SIGNER_PACKAGE_NAME = "signer_package_name"
|
||||
|
||||
const val ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
@@ -134,8 +140,6 @@ object LocalPreferences {
|
||||
)
|
||||
}
|
||||
|
||||
println("AAA migrated: $migrated")
|
||||
|
||||
_savedAccounts = migrated
|
||||
}
|
||||
}
|
||||
@@ -166,7 +170,7 @@ object LocalPreferences {
|
||||
val accInfo = AccountInfo(
|
||||
npub,
|
||||
account.isWriteable(),
|
||||
account.loginWithExternalSigner
|
||||
account.signer is NostrSignerExternal
|
||||
)
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(accInfo)
|
||||
@@ -245,16 +249,14 @@ object LocalPreferences {
|
||||
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs.edit().apply {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
|
||||
if (account.loginWithExternalSigner) {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal)
|
||||
if (account.signer is NostrSignerExternal) {
|
||||
remove(PrefKeys.NOSTR_PRIVKEY)
|
||||
putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName)
|
||||
} else {
|
||||
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
}
|
||||
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putStringSet(PrefKeys.FOLLOWING_CHANNELS, account.followingChannels)
|
||||
putStringSet(PrefKeys.FOLLOWING_COMMUNITIES, account.followingCommunities)
|
||||
putStringSet(PrefKeys.HIDDEN_USERS, account.hiddenUsers)
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putString(PrefKeys.LANGUAGE_PREFS, Event.mapper.writeValueAsString(account.languagePreferences))
|
||||
@@ -263,10 +265,10 @@ object LocalPreferences {
|
||||
putString(PrefKeys.REACTION_CHOICES, Event.mapper.writeValueAsString(account.reactionChoices))
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
|
||||
putString(PrefKeys.DEFAULT_FILE_SERVER, account.defaultFileServer.name)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList)
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, account.defaultNotificationFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, account.defaultDiscoveryFollowList.value)
|
||||
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, Event.mapper.writeValueAsString(account.zapPaymentRequest))
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.mapper.writeValueAsString(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
@@ -384,6 +386,7 @@ object LocalPreferences {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return@with null
|
||||
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
val privKey = if (loginWithExternalSigner) null else getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
|
||||
val followingChannels = getStringSet(PrefKeys.FOLLOWING_CHANNELS, null) ?: setOf()
|
||||
val followingCommunities = getStringSet(PrefKeys.FOLLOWING_COMMUNITIES, null) ?: setOf()
|
||||
val hiddenUsers = getStringSet(PrefKeys.HIDDEN_USERS, emptySet()) ?: setOf()
|
||||
@@ -474,11 +477,17 @@ object LocalPreferences {
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
|
||||
val signer = if (loginWithExternalSigner) {
|
||||
val packageName = getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner"
|
||||
NostrSignerExternal(pubKey, ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName))
|
||||
} else {
|
||||
NostrSignerInternal(keyPair)
|
||||
}
|
||||
|
||||
return@with Account(
|
||||
keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels = followingChannels,
|
||||
followingCommunities = followingCommunities,
|
||||
hiddenUsers = hiddenUsers,
|
||||
keyPair = keyPair,
|
||||
signer = signer,
|
||||
localRelays = localRelays,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
@@ -487,10 +496,10 @@ object LocalPreferences {
|
||||
reactionChoices = reactionChoices,
|
||||
defaultZapType = defaultZapType,
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = defaultHomeFollowList,
|
||||
defaultStoriesFollowList = defaultStoriesFollowList,
|
||||
defaultNotificationFollowList = defaultNotificationFollowList,
|
||||
defaultDiscoveryFollowList = defaultDiscoveryFollowList,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
@@ -501,8 +510,7 @@ object LocalPreferences {
|
||||
showSensitiveContent = showSensitiveContent,
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
loginWithExternalSigner = loginWithExternalSigner
|
||||
lastReadPerRoute = lastReadPerRoute
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import coil.Coil
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.disk.DiskCache
|
||||
import coil.util.DebugLogger
|
||||
import coil.size.Precision
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
@@ -30,27 +28,23 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageUploader
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
object ServiceManager {
|
||||
var shouldPauseService: Boolean = true // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
@Stable
|
||||
class ServiceManager {
|
||||
private var isStarted: Boolean = false // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var account: Account? = null
|
||||
|
||||
private fun start(account: Account) {
|
||||
this.account = account
|
||||
ExternalSignerUtils.account = account
|
||||
start()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
private fun start() {
|
||||
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
|
||||
if (isStarted && account != null) {
|
||||
return
|
||||
@@ -60,7 +54,7 @@ object ServiceManager {
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClient.start(account)
|
||||
HttpClient.start(account?.proxy)
|
||||
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
|
||||
Coil.setImageLoader {
|
||||
Amethyst.instance.imageLoaderBuilder().components {
|
||||
@@ -70,8 +64,9 @@ object ServiceManager {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
}.logger(DebugLogger())
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.precision(Precision.INEXACT)
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
@@ -85,7 +80,6 @@ object ServiceManager {
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
ImageUploader.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
@@ -105,26 +99,26 @@ object ServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
private fun pause() {
|
||||
Log.d("ServiceManager", "Pausing Relay Services")
|
||||
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrDiscoveryDataSource.stop()
|
||||
NostrAccountDataSource.stopSync()
|
||||
NostrHomeDataSource.stopSync()
|
||||
NostrChannelDataSource.stopSync()
|
||||
NostrChatroomDataSource.stopSync()
|
||||
NostrChatroomListDataSource.stopSync()
|
||||
NostrDiscoveryDataSource.stopSync()
|
||||
|
||||
NostrCommunityDataSource.stop()
|
||||
NostrHashtagDataSource.stop()
|
||||
NostrGeohashDataSource.stop()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrVideoDataSource.stop()
|
||||
NostrCommunityDataSource.stopSync()
|
||||
NostrHashtagDataSource.stopSync()
|
||||
NostrGeohashDataSource.stopSync()
|
||||
NostrSearchEventOrUserDataSource.stopSync()
|
||||
NostrSingleChannelDataSource.stopSync()
|
||||
NostrSingleEventDataSource.stopSync()
|
||||
NostrSingleUserDataSource.stopSync()
|
||||
NostrThreadDataSource.stopSync()
|
||||
NostrUserProfileDataSource.stopSync()
|
||||
NostrVideoDataSource.stopSync()
|
||||
|
||||
Client.disconnect()
|
||||
isStarted = false
|
||||
@@ -153,42 +147,38 @@ object ServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
// This method keeps the pause/start in a Syncronized block to
|
||||
// avoid concurrent pauses and starts.
|
||||
@Synchronized
|
||||
fun forceRestart(account: Account? = null, start: Boolean = true, pause: Boolean = true) {
|
||||
if (pause) {
|
||||
pause()
|
||||
}
|
||||
|
||||
if (start) {
|
||||
if (account != null) {
|
||||
start(account)
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restartIfDifferentAccount(account: Account) {
|
||||
if (this.account != account) {
|
||||
pause()
|
||||
start(account)
|
||||
forceRestart(account, true, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceRestartIfItShould() {
|
||||
if (shouldPauseService) {
|
||||
pause()
|
||||
start()
|
||||
}
|
||||
fun forceRestart() {
|
||||
forceRestart(null, true, true)
|
||||
}
|
||||
|
||||
fun justStart() {
|
||||
forceRestart(null, true, false)
|
||||
}
|
||||
|
||||
fun pauseForGood() {
|
||||
forceRestart(null, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
object SingletonDiskCache {
|
||||
|
||||
private const val DIRECTORY = "image_cache"
|
||||
private var instance: DiskCache? = null
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context): DiskCache {
|
||||
return instance ?: run {
|
||||
// Create the singleton disk cache instance.
|
||||
DiskCache.Builder()
|
||||
.directory(context.safeCacheDir.resolve(DIRECTORY))
|
||||
.maxSizePercent(0.2)
|
||||
.maximumMaxSizeBytes(500L * 1024 * 1024) // 250MB
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val Context.safeCacheDir: File
|
||||
get() {
|
||||
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
|
||||
return cacheDir.apply { mkdirs() }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
@@ -121,7 +122,7 @@ object LocalCache {
|
||||
|
||||
fun getUserIfExists(key: String): User? {
|
||||
if (key.isEmpty()) return null
|
||||
return users.get(key)
|
||||
return users[key]
|
||||
}
|
||||
|
||||
fun getAddressableNoteIfExists(key: String): AddressableNote? {
|
||||
@@ -425,6 +426,7 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: MuteListEvent) { consumeBaseReplaceable(event) }
|
||||
fun consume(event: PeopleListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: AdvertisedRelayListEvent) { consumeBaseReplaceable(event) }
|
||||
private fun consume(event: CommunityDefinitionEvent, relay: Relay?) { consumeBaseReplaceable(event) }
|
||||
@@ -696,7 +698,7 @@ object LocalCache {
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val repliesTo = event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
@@ -1278,7 +1280,7 @@ object LocalCache {
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 100 || it.value.notes.size > 100) {
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed from ${it.value.toBestDisplayName()}. ${it.value.notes.size} kept")
|
||||
@@ -1297,7 +1299,7 @@ object LocalCache {
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} private messages with ${userPair.value.toBestDisplayName()} removed. ${it.roomMessages.size} kept")
|
||||
@@ -1328,7 +1330,7 @@ object LocalCache {
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
|
||||
@@ -1357,7 +1359,7 @@ object LocalCache {
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
|
||||
toBeRemoved.forEach {
|
||||
it.replyTo?.forEach { masterNote ->
|
||||
@@ -1380,22 +1382,24 @@ object LocalCache {
|
||||
masterNote.clearEOSE() // allows reloading of these events if needed
|
||||
}
|
||||
|
||||
if (note.event is LnZapEvent) {
|
||||
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
|
||||
val noteEvent = note.event
|
||||
|
||||
if (noteEvent is LnZapEvent) {
|
||||
noteEvent.zappedAuthor().forEach {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is LnZapRequestEvent) {
|
||||
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
|
||||
if (noteEvent is LnZapRequestEvent) {
|
||||
noteEvent.zappedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is ReportEvent) {
|
||||
(note.event as ReportEvent).reportedAuthor().mapNotNull {
|
||||
if (noteEvent is ReportEvent) {
|
||||
noteEvent.reportedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it.key)
|
||||
author?.removeReport(note)
|
||||
author?.clearEOSE()
|
||||
@@ -1405,31 +1409,7 @@ object LocalCache {
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun removeAuthorLinkTo(note: Note) {
|
||||
if (note.event is LnZapEvent) {
|
||||
(note.event as LnZapEvent).zappedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is LnZapRequestEvent) {
|
||||
(note.event as LnZapRequestEvent).zappedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it)
|
||||
author?.removeZap(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
if (note.event is ReportEvent) {
|
||||
(note.event as ReportEvent).reportedAuthor().mapNotNull {
|
||||
val author = getUserIfExists(it.key)
|
||||
author?.removeReport(note)
|
||||
author?.clearEOSE()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeChildrenOf(nextToBeRemoved: List<Note>) {
|
||||
fun removeFromCache(nextToBeRemoved: List<Note>) {
|
||||
nextToBeRemoved.forEach { note ->
|
||||
removeFromCache(note)
|
||||
}
|
||||
@@ -1449,7 +1429,7 @@ object LocalCache {
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
|
||||
@@ -1461,7 +1441,7 @@ object LocalCache {
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
val toBeRemoved = account.hiddenUsers.map { userHex ->
|
||||
val toBeRemoved = account.liveHiddenUsers.value?.hiddenUsers?.map { userHex ->
|
||||
(
|
||||
notes.values.filter {
|
||||
it.event?.pubKey() == userHex
|
||||
@@ -1469,14 +1449,14 @@ object LocalCache {
|
||||
it.event?.pubKey() == userHex
|
||||
}
|
||||
).toSet()
|
||||
}.flatten()
|
||||
}?.flatten() ?: emptyList()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
removeFromCache(it)
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
}
|
||||
|
||||
removeChildrenOf(childrenToBeRemoved)
|
||||
removeFromCache(childrenToBeRemoved)
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
@@ -1583,6 +1563,7 @@ object LocalCache {
|
||||
is LnZapPaymentResponseEvent -> consume(event)
|
||||
is LongTextNoteEvent -> consume(event, relay)
|
||||
is MetadataEvent -> consume(event)
|
||||
is MuteListEvent -> consume(event)
|
||||
is NNSEvent -> comsume(event)
|
||||
is PrivateDmEvent -> consume(event, relay)
|
||||
is PinListEvent -> consume(event)
|
||||
|
||||
@@ -39,9 +39,11 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -86,6 +88,8 @@ open class Note(val idHex: String) {
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapsAmount: BigDecimal = BigDecimal.ZERO
|
||||
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
@@ -150,6 +154,7 @@ open class Note(val idHex: String) {
|
||||
this.replyTo = replyTo
|
||||
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
flowSet?.metadata?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +270,7 @@ open class Note(val idHex: String) {
|
||||
reports = mapOf<User, List<Note>>()
|
||||
zaps = mapOf<Note, Note?>()
|
||||
zapPayments = mapOf<Note, Note?>()
|
||||
zapsAmount = BigDecimal.ZERO
|
||||
relays = listOf<String>()
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
|
||||
@@ -278,7 +284,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun removeReaction(note: Note) {
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val tags = note.event?.tags() ?: emptyArray()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) {
|
||||
@@ -311,9 +317,11 @@ open class Note(val idHex: String) {
|
||||
fun removeZap(note: Note) {
|
||||
if (zaps[note] != null) {
|
||||
zaps = zaps.minus(note)
|
||||
updateZapTotal()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
} else if (zaps.containsValue(note)) {
|
||||
zaps = zaps.filterValues { it != note }
|
||||
updateZapTotal()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -354,11 +362,13 @@ open class Note(val idHex: String) {
|
||||
if (zapRequest !in zaps.keys) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
updateZapTotal()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
} else if (zaps[zapRequest] == null) {
|
||||
val inserted = innerAddZap(zapRequest, zap)
|
||||
if (inserted) {
|
||||
updateZapTotal()
|
||||
liveSet?.innerZaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -393,7 +403,7 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
val tags = note.event?.tags() ?: emptyList()
|
||||
val tags = note.event?.tags() ?: emptyArray()
|
||||
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
|
||||
|
||||
if (reaction !in reactions.keys) {
|
||||
@@ -431,68 +441,68 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(user: User, account: Account): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any {
|
||||
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent)
|
||||
} else {
|
||||
null
|
||||
private fun recursiveIsPaidByCalculation(
|
||||
account: Account,
|
||||
remainingZapPayments: List<Pair<Note, Note?>>,
|
||||
onWasZappedByAuthor: () -> Unit
|
||||
) {
|
||||
if (remainingZapPayments.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val next = remainingZapPayments.first()
|
||||
|
||||
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
|
||||
if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
|
||||
if (response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent.requestAuthor())) {
|
||||
onWasZappedByAuthor()
|
||||
} else {
|
||||
recursiveIsPaidByCalculation(
|
||||
account,
|
||||
remainingZapPayments.minus(next),
|
||||
onWasZappedByAuthor
|
||||
)
|
||||
}
|
||||
}
|
||||
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
|
||||
}
|
||||
}
|
||||
|
||||
fun publicZapAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author
|
||||
}.toSet()
|
||||
private fun recursiveIsZappedByCalculation(
|
||||
option: Int?,
|
||||
user: User,
|
||||
account: Account,
|
||||
remainingZapEvents: List<Pair<Note, Note?>>,
|
||||
onWasZappedByAuthor: () -> Unit
|
||||
) {
|
||||
if (remainingZapEvents.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val next = remainingZapEvents.first()
|
||||
|
||||
if (next.first.author?.pubkeyHex == user.pubkeyHex) {
|
||||
onWasZappedByAuthor()
|
||||
} else {
|
||||
account.decryptZapContentAuthor(next.first) {
|
||||
if (it.pubKey == user.pubkeyHex && (option == null || option == (it as? LnZapEvent)?.zappedPollOption())) {
|
||||
onWasZappedByAuthor()
|
||||
} else {
|
||||
recursiveIsZappedByCalculation(option, user, account, remainingZapEvents.minus(next), onWasZappedByAuthor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun publicZapAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.mapNotNull {
|
||||
it.key.author?.pubkeyHex
|
||||
}.toSet()
|
||||
fun isZappedBy(user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
|
||||
recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
if (account.userProfile() == user) {
|
||||
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
|
||||
}
|
||||
}
|
||||
|
||||
fun reactionAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun reactionAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return reactions.values.map {
|
||||
it.mapNotNull { it.author?.pubkeyHex }
|
||||
}.flatten().toSet()
|
||||
}
|
||||
|
||||
fun replyAuthorHexes(): Set<HexKey> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author?.pubkeyHex
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun replyAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return replies.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
fun boostAuthors(): Set<User> {
|
||||
// Zaps who the requester was the user
|
||||
return boosts.mapNotNull {
|
||||
it.author
|
||||
}.toSet()
|
||||
fun isZappedBy(option: Int?, user: User, account: Account, onWasZappedByAuthor: () -> Unit) {
|
||||
recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
|
||||
}
|
||||
|
||||
fun getReactionBy(user: User): String? {
|
||||
@@ -527,44 +537,81 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.filter { it.amount != null }
|
||||
.associate {
|
||||
it.lnInvoice() to it.amount
|
||||
}
|
||||
.toMap()
|
||||
private fun updateZapTotal() {
|
||||
var sumOfAmounts = BigDecimal.ZERO
|
||||
|
||||
val completedPayments = if (privKey != null && walletServicePubkey != null) {
|
||||
// Payments confirmed by the User's Wallet
|
||||
zapPayments
|
||||
.asSequence()
|
||||
.filter {
|
||||
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
|
||||
response is PayInvoiceSuccessResponse
|
||||
}
|
||||
.associate {
|
||||
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
|
||||
// Regular Zap Receipts
|
||||
zaps.values.forEach {
|
||||
val noteEvent = it?.event
|
||||
if (noteEvent is LnZapEvent) {
|
||||
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
|
||||
}
|
||||
}
|
||||
|
||||
zapsAmount = sumOfAmounts
|
||||
}
|
||||
|
||||
private fun recursiveZappedAmountCalculation(
|
||||
invoiceSet: LinkedHashSet<String>,
|
||||
remainingZapPayments: List<Pair<Note, Note?>>,
|
||||
signer: NostrSigner,
|
||||
output: BigDecimal,
|
||||
onReady: (BigDecimal) -> Unit
|
||||
) {
|
||||
if (remainingZapPayments.isEmpty()) {
|
||||
onReady(output)
|
||||
return
|
||||
}
|
||||
|
||||
val next = remainingZapPayments.first()
|
||||
|
||||
(next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
|
||||
if (noteEvent is PayInvoiceSuccessResponse) {
|
||||
(next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
|
||||
val amount = try {
|
||||
if (lnInvoice == null) {
|
||||
null
|
||||
} else {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
}
|
||||
LnInvoiceUtil.getAmountInSats(invoice)
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
lnInvoice to amount
|
||||
|
||||
var newAmount = output
|
||||
|
||||
if (amount != null && !invoiceSet.contains(invoice)) {
|
||||
invoiceSet.add(invoice)
|
||||
newAmount += amount
|
||||
}
|
||||
|
||||
recursiveZappedAmountCalculation(
|
||||
invoiceSet,
|
||||
remainingZapPayments.minus(next),
|
||||
signer,
|
||||
newAmount,
|
||||
onReady
|
||||
)
|
||||
}
|
||||
.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedAmountWithNWCPayments(signer: NostrSigner, onReady: (BigDecimal) -> Unit) {
|
||||
if (zapPayments.isEmpty()) {
|
||||
onReady(zapsAmount)
|
||||
}
|
||||
|
||||
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
|
||||
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
|
||||
zaps.forEach {
|
||||
(it.value?.event as? LnZapEvent)?.lnInvoice()?.let {
|
||||
invoiceSet.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
recursiveZappedAmountCalculation(
|
||||
invoiceSet,
|
||||
zapPayments.toList(),
|
||||
signer,
|
||||
zapsAmount,
|
||||
onReady
|
||||
)
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
@@ -675,28 +722,13 @@ open class Note(val idHex: String) {
|
||||
boosts = emptyList()
|
||||
reports = emptyMap()
|
||||
zaps = emptyMap()
|
||||
zapsAmount = BigDecimal.ZERO
|
||||
}
|
||||
|
||||
fun clearEOSE() {
|
||||
lastReactionsDownloadTime = emptyMap()
|
||||
}
|
||||
|
||||
var liveSet: NoteLiveSet? = null
|
||||
|
||||
fun live(): NoteLiveSet {
|
||||
if (liveSet == null) {
|
||||
liveSet = NoteLiveSet(this)
|
||||
}
|
||||
return liveSet!!
|
||||
}
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
|
||||
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
|
||||
val thisEvent = event ?: return false
|
||||
|
||||
@@ -720,6 +752,77 @@ open class Note(val idHex: String) {
|
||||
accountChoices.spammers.contains(author?.pubkeyHex) ||
|
||||
(isSensitive && accountChoices.showSensitiveContent == false)
|
||||
}
|
||||
|
||||
var liveSet: NoteLiveSet? = null
|
||||
var flowSet: NoteFlowSet? = null
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyLiveSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (liveSet == null) {
|
||||
liveSet = NoteLiveSet(this)
|
||||
}
|
||||
} else {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun live(): NoteLiveSet {
|
||||
if (liveSet == null) {
|
||||
createOrDestroyLiveSync(true)
|
||||
}
|
||||
return liveSet!!
|
||||
}
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
createOrDestroyLiveSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyFlowSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (flowSet == null) {
|
||||
flowSet = NoteFlowSet(this)
|
||||
}
|
||||
} else {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
flowSet?.destroy()
|
||||
flowSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flow(): NoteFlowSet {
|
||||
if (flowSet == null) {
|
||||
createOrDestroyFlowSync(true)
|
||||
}
|
||||
return flowSet!!
|
||||
}
|
||||
|
||||
fun clearFlow() {
|
||||
if (flowSet != null && flowSet?.isInUse() == false) {
|
||||
createOrDestroyFlowSync(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteFlowSet(u: Note) {
|
||||
// Observers line up here.
|
||||
val metadata = NoteBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.stateFlow.subscriptionCount.value > 0
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
metadata.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -743,7 +846,7 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
val authorChanges = innerMetadata.map {
|
||||
it.note.author
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val hasEvent = innerMetadata.map {
|
||||
it.note.event != null
|
||||
@@ -809,6 +912,28 @@ class NoteLiveSet(u: Note) {
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteBundledRefresherFlow(val note: Note) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
val stateFlow = MutableStateFlow(NoteState(note))
|
||||
|
||||
fun destroy() {
|
||||
bundler.cancel()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate() {
|
||||
checkNotInMainThread()
|
||||
|
||||
stateFlow.emit(NoteState(note))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
@@ -837,6 +962,7 @@ class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteSta
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
|
||||
@@ -359,15 +359,28 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
fun live(): UserLiveSet {
|
||||
if (liveSet == null) {
|
||||
liveSet = UserLiveSet(this)
|
||||
createOrDestroyLiveSync(true)
|
||||
}
|
||||
return liveSet!!
|
||||
}
|
||||
|
||||
fun clearLive() {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
createOrDestroyLiveSync(false)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun createOrDestroyLiveSync(create: Boolean) {
|
||||
if (create) {
|
||||
if (liveSet == null) {
|
||||
liveSet = UserLiveSet(this)
|
||||
}
|
||||
} else {
|
||||
if (liveSet != null && liveSet?.isInUse() == false) {
|
||||
liveSet?.destroy()
|
||||
liveSet = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,7 +409,9 @@ class UserLiveSet(u: User) {
|
||||
val relays = innerRelays.map { it }
|
||||
val relayInfo = innerRelayInfo.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
val bookmarks = innerBookmarks.map { it }
|
||||
val bookmarks = innerBookmarks.map {
|
||||
it
|
||||
}
|
||||
val statuses = innerStatuses.map { it }
|
||||
|
||||
val profilePictureChanges = innerMetadata.map {
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class SignerType {
|
||||
SIGN_EVENT,
|
||||
NIP04_ENCRYPT,
|
||||
NIP04_DECRYPT,
|
||||
NIP44_ENCRYPT,
|
||||
NIP44_DECRYPT,
|
||||
GET_PUBLIC_KEY,
|
||||
DECRYPT_ZAP_EVENT
|
||||
}
|
||||
|
||||
object ExternalSignerUtils {
|
||||
val content = LruCache<String, String>(10)
|
||||
var isActivityRunning: Boolean = false
|
||||
val cachedDecryptedContent = mutableMapOf<HexKey, String>()
|
||||
lateinit var account: Account
|
||||
private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var decryptResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var blockListResultLauncher: ActivityResultLauncher<Intent>
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun requestRejectedToast() {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
Amethyst.instance,
|
||||
Amethyst.instance.getString(R.string.sign_request_rejected),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun default() {
|
||||
isActivityRunning = false
|
||||
ServiceManager.shouldPauseService = true
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
isActivityRunning = false
|
||||
ServiceManager.shouldPauseService = true
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun start(activity: MainActivity) {
|
||||
activityResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val event = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
content.put(id, event)
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
|
||||
decryptResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val event = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
content.put(id, event)
|
||||
cachedDecryptedContent[id] = event
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
|
||||
blockListResultLauncher = activity.registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode != Activity.RESULT_OK) {
|
||||
requestRejectedToast()
|
||||
} else {
|
||||
val decryptedContent = it.data?.getStringExtra("signature") ?: ""
|
||||
val id = it.data?.getStringExtra("id") ?: ""
|
||||
if (id.isNotBlank()) {
|
||||
cachedDecryptedContent[id] = decryptedContent
|
||||
account.live.invalidateData()
|
||||
}
|
||||
}
|
||||
default()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun openSigner(
|
||||
data: String,
|
||||
type: SignerType,
|
||||
intentResult: ActivityResultLauncher<Intent>,
|
||||
pubKey: HexKey,
|
||||
id: String
|
||||
) {
|
||||
try {
|
||||
ServiceManager.shouldPauseService = false
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:$data"))
|
||||
val signerType = when (type) {
|
||||
SignerType.SIGN_EVENT -> "sign_event"
|
||||
SignerType.NIP04_ENCRYPT -> "nip04_encrypt"
|
||||
SignerType.NIP04_DECRYPT -> "nip04_decrypt"
|
||||
SignerType.NIP44_ENCRYPT -> "nip44_encrypt"
|
||||
SignerType.NIP44_DECRYPT -> "nip44_decrypt"
|
||||
SignerType.GET_PUBLIC_KEY -> "get_public_key"
|
||||
SignerType.DECRYPT_ZAP_EVENT -> "decrypt_zap_event"
|
||||
}
|
||||
intent.putExtra("type", signerType)
|
||||
intent.putExtra("pubKey", pubKey)
|
||||
intent.putExtra("id", id)
|
||||
if (type !== SignerType.GET_PUBLIC_KEY) {
|
||||
intent.putExtra("current_user", account.keyPair.pubKey.toNpub())
|
||||
}
|
||||
intent.`package` = "com.greenart7c3.nostrsigner"
|
||||
intentResult.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Signer", "Error opening Signer app", e)
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
Amethyst.instance,
|
||||
Amethyst.instance.getString(R.string.error_opening_external_signer),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openSigner(event: EventInterface, columnName: String = "signature") {
|
||||
checkNotInMainThread()
|
||||
|
||||
val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName)
|
||||
if (result == null) {
|
||||
ServiceManager.shouldPauseService = false
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.SIGN_EVENT,
|
||||
activityResultLauncher,
|
||||
"",
|
||||
event.id()
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
} else {
|
||||
content.put(event.id(), result)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptBlockList(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
blockListResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun getDataFromResolver(signerType: SignerType, data: Array<out String>, columnName: String = "signature"): String? {
|
||||
val localData = if (signerType !== SignerType.GET_PUBLIC_KEY) {
|
||||
data.toList().plus(account.keyPair.pubKey.toNpub()).toTypedArray()
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
Amethyst.instance.contentResolver.query(
|
||||
Uri.parse("content://com.greenart7c3.nostrsigner.$signerType"),
|
||||
localData,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use {
|
||||
if (it == null) {
|
||||
return null
|
||||
}
|
||||
if (it.moveToFirst()) {
|
||||
val index = it.getColumnIndex(columnName)
|
||||
if (index < 0) {
|
||||
Log.d("getDataFromResolver", "column '$columnName' not found")
|
||||
return null
|
||||
}
|
||||
return it.getString(index)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun decrypt(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
// do nothing
|
||||
}
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptDM(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptBookmark(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
}
|
||||
}
|
||||
|
||||
fun encrypt(decryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_ENCRYPT) {
|
||||
content.remove(id)
|
||||
cachedDecryptedContent.remove(id)
|
||||
val result = getDataFromResolver(signerType, arrayOf(decryptedContent, pubKey))
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
decryptedContent,
|
||||
signerType,
|
||||
activityResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
} else {
|
||||
content.put(id, result)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapEvent(event: LnZapRequestEvent) {
|
||||
val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey))
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.DECRYPT_ZAP_EVENT,
|
||||
decryptResultLauncher,
|
||||
event.pubKey,
|
||||
event.id
|
||||
)
|
||||
} else {
|
||||
content.put(event.id, result)
|
||||
cachedDecryptedContent[event.id] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
@@ -19,8 +18,8 @@ object HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
fun start(account: Account?) {
|
||||
this.internalProxy = account?.proxy
|
||||
fun start(proxy: Proxy?) {
|
||||
this.internalProxy = proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
@@ -23,6 +24,7 @@ import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
@@ -31,7 +33,9 @@ import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
// TODO: Migrate this to a property of AccountVi
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
@@ -86,7 +90,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, MuteListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 100
|
||||
)
|
||||
@@ -99,7 +103,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -114,26 +118,32 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createNotificationFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapPaymentResponseEvent.kind,
|
||||
ChannelMessageEvent.kind,
|
||||
BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 4000,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
|
||||
fun createNotificationFilter(): TypedFilter {
|
||||
val since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList.value)?.relayList
|
||||
?: account.activeRelays()?.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
LnZapPaymentResponseEvent.kind,
|
||||
ChannelMessageEvent.kind,
|
||||
BadgeAwardEvent.kind
|
||||
),
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 4000,
|
||||
since = since
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createGiftWrapsToMeFilter() = TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
@@ -145,7 +155,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
val accountChannel = requestNewChannel { time, relayUrl ->
|
||||
if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList.value, relayUrl, time)
|
||||
} else {
|
||||
hasLoadedTheBasics[account.userProfile()] = true
|
||||
|
||||
@@ -158,51 +168,21 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
|
||||
if (LocalCache.justVerify(event)) {
|
||||
if (event is GiftWrapEvent) {
|
||||
val privateKey = account.keyPair.privKey
|
||||
if (privateKey != null) {
|
||||
event.cachedGift(privateKey)?.let {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGift(account.keyPair.pubKey, cached)?.let {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
if (LocalCache.getNoteIfExists(event.id) != null) return
|
||||
|
||||
event.cachedGift(account.signer) {
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
|
||||
if (event is SealedGossipEvent) {
|
||||
val privateKey = account.keyPair.privKey
|
||||
if (privateKey != null) {
|
||||
event.cachedGossip(privateKey)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
if (LocalCache.getNoteIfExists(event.id) != null) return
|
||||
|
||||
// Don't store sealed gossips to avoid rebroadcasting by mistake.
|
||||
event.cachedGossip(account.signer) {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
} else {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
@@ -210,6 +190,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
}
|
||||
|
||||
override fun markAsSeenOnRelay(eventId: String, relay: Relay) {
|
||||
checkNotInMainThread()
|
||||
|
||||
super.markAsSeenOnRelay(eventId, relay)
|
||||
|
||||
val note = LocalCache.getNoteIfExists(eventId) ?: return
|
||||
@@ -223,11 +205,13 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
|
||||
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
val gift = noteEvent.cachedGift(privKey) ?: return
|
||||
markInnerAsSeenOnRelay(gift, privKey, relay)
|
||||
noteEvent.cachedGift(account.signer) { gift ->
|
||||
markInnerAsSeenOnRelay(gift, privKey, relay)
|
||||
}
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
val rumor = noteEvent.cachedGossip(privKey) ?: return
|
||||
markInnerAsSeenOnRelay(rumor, privKey, relay)
|
||||
noteEvent.cachedGossip(account.signer) { rumor ->
|
||||
markInnerAsSeenOnRelay(rumor, privKey, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,13 +244,20 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
if (event != null) {
|
||||
account.createAuthEvent(relay, challenge) {
|
||||
Client.send(
|
||||
event,
|
||||
it,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun notify(relay: Relay, description: String) {
|
||||
super.notify(relay, description)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -25,6 +26,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
private var eventCounter = mapOf<String, Counter>()
|
||||
var changingFilters = AtomicBoolean()
|
||||
|
||||
private var active: Boolean = false
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
Log.d("STATE DUMP", "Received Events ${it.key}: ${it.value.counter}")
|
||||
@@ -61,7 +64,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
if (type == Relay.StateType.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[subscriptionId]?.updateEOSE(
|
||||
TimeUtils.fiveMinutesAgo(), // in case people's clock is slighly off.
|
||||
TimeUtils.oneMinuteAgo(), // in case people's clock is slighly off.
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
@@ -76,6 +79,13 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
auth(relay, challenge)
|
||||
}
|
||||
|
||||
override fun onNotify(
|
||||
relay: Relay,
|
||||
description: String
|
||||
) {
|
||||
notify(relay, description)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -84,6 +94,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
// makes sure to run
|
||||
stop()
|
||||
Client.unsubscribe(clientListener)
|
||||
scope.cancel()
|
||||
@@ -92,14 +103,29 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
open fun start() {
|
||||
println("DataSource: ${this.javaClass.simpleName} Start")
|
||||
active = true
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
active = false
|
||||
println("DataSource: ${this.javaClass.simpleName} Stop")
|
||||
subscriptions.values.forEach { channel ->
|
||||
Client.close(channel.id)
|
||||
channel.typedFilters = null
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
subscriptions.values.forEach { subscription ->
|
||||
Client.close(subscription.id)
|
||||
subscription.typedFilters = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun stopSync() {
|
||||
active = false
|
||||
println("DataSource: ${this.javaClass.simpleName} Stop")
|
||||
|
||||
subscriptions.values.forEach { subscription ->
|
||||
Client.close(subscription.id)
|
||||
subscription.typedFilters = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +162,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
fun resetFiltersSuspend() {
|
||||
println("DataSource: ${this.javaClass.simpleName} resetFiltersSuspend $active")
|
||||
checkNotInMainThread()
|
||||
|
||||
// saves the channels that are currently active
|
||||
@@ -149,29 +176,35 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
subscriptions.values.forEach { updatedSubscription ->
|
||||
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
|
||||
val updatedSubscriptionNewFilters = updatedSubscription.typedFilters
|
||||
|
||||
if (updatedSubscription.id in currentFilters.keys) {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
if (updatedSubscriptionNewFilters == null) {
|
||||
// was active and is not active anymore, just close.
|
||||
Client.close(updatedSubscription.id)
|
||||
} else {
|
||||
// was active and is still active, check if it has changed.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.close(updatedSubscription.id)
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
if (active) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
|
||||
}
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
if (active) {
|
||||
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriptionNewFilters)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (updatedSubscriotionNewFilters == null) {
|
||||
if (updatedSubscriptionNewFilters == null) {
|
||||
// was not active and is still not active, does nothing
|
||||
} else {
|
||||
// was not active and becomes active, sends the filter.
|
||||
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
|
||||
if (active) {
|
||||
Client.sendFilter(updatedSubscription.id, updatedSubscriptionNewFilters)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,4 +223,5 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
open fun notify(relay: Relay, description: String) = Unit
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -12,14 +13,37 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val scope = Amethyst.instance.applicationIOScope
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
var job: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
account.liveDiscoveryFollowLists.collect {
|
||||
if (this@NostrDiscoveryDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createLiveStreamFilter(): List<TypedFilter> {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
@@ -28,7 +52,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
authors = follows,
|
||||
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
),
|
||||
follows?.let {
|
||||
@@ -38,7 +62,7 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
tags = mapOf("p" to it),
|
||||
kinds = listOf(LiveActivitiesEvent.kind),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -46,33 +70,37 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}
|
||||
|
||||
fun createPublicChatFilter(): List<TypedFilter> {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
val followChats = account.selectedChatsFollowList().toList()
|
||||
|
||||
return listOf(
|
||||
return listOfNotNull(
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
authors = follows,
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
ids = followChats,
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
if (followChats.isNotEmpty()) {
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.PUBLIC_CHATS),
|
||||
filter = JsonFilter(
|
||||
ids = followChats,
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
|
||||
val follows = account.liveDiscoveryFollowLists.value?.users?.toList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
@@ -80,13 +108,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
authors = follows,
|
||||
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
@@ -100,13 +128,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLiveStreamGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
@@ -120,13 +148,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
@@ -140,13 +168,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createPublicChatsGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
@@ -160,13 +188,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultDiscoveryFollowList)
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.hashtags?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
@@ -180,13 +208,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createCommunitiesGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList)
|
||||
val hashToLoad = account.liveDiscoveryFollowLists.value?.geotags?.toList()
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
@@ -200,13 +228,13 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 300,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val discoveryFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList, relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultDiscoveryFollowList.value, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -19,42 +19,37 @@ import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val scope = Amethyst.instance.applicationIOScope
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
invalidateFilters()
|
||||
}
|
||||
var job: Job? = null
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun start() {
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.observeForever(cacheListener)
|
||||
job?.cancel()
|
||||
job = account.scope.launch(Dispatchers.IO) {
|
||||
account.liveHomeFollowLists.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized) {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
account.userProfile().live().follows.removeObserver(cacheListener)
|
||||
}
|
||||
}
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList)
|
||||
val follows = account.liveHomeFollowLists.value?.users
|
||||
val followSet = follows?.plus(account.userProfile().pubkeyHex)?.toList()?.ifEmpty { null }
|
||||
|
||||
return TypedFilter(
|
||||
@@ -76,13 +71,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
),
|
||||
authors = followSet,
|
||||
limit = 400,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val hashToLoad = account.liveHomeFollowLists.value?.hashtags ?: return null
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
@@ -96,13 +91,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val hashToLoad = account.liveHomeFollowLists.value?.geotags ?: return null
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
@@ -116,13 +111,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowCommunitiesFilter(): TypedFilter? {
|
||||
val communitiesToLoad = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val communitiesToLoad = account.liveHomeFollowLists.value?.communities ?: return null
|
||||
|
||||
if (communitiesToLoad.isEmpty()) return null
|
||||
|
||||
@@ -143,13 +138,13 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
"a" to communitiesToLoad.toList()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultHomeFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val followAccountChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList, relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultHomeFollowList.value, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
+8
-6
@@ -7,12 +7,13 @@ import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
|
||||
class NostrLnZapPaymentResponseDataSource(
|
||||
private val fromServiceHex: String,
|
||||
private val toUserHex: String,
|
||||
private val replyingToHex: String,
|
||||
private val authSigningKey: ByteArray
|
||||
private val authSigner: NostrSigner
|
||||
) : NostrDataSource("LnZapPaymentResponseFeed") {
|
||||
|
||||
val feedTypes = setOf(FeedType.WALLET_CONNECT)
|
||||
@@ -44,10 +45,11 @@ class NostrLnZapPaymentResponseDataSource(
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
val event = RelayAuthEvent.create(relay.url, challenge, "", authSigningKey)
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
RelayAuthEvent.create(relay.url, challenge, authSigner) {
|
||||
Client.send(
|
||||
it,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-4
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexValidator
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
@@ -36,7 +37,13 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
}
|
||||
|
||||
val hexToWatch = try {
|
||||
Nip19.uriToRoute(mySearchString)?.hex ?: Hex.decode(mySearchString).toHexKey()
|
||||
val isAStraightHex = if (HexValidator.isHex(mySearchString)) {
|
||||
Hex.decode(mySearchString).toHexKey()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Nip19.uriToRoute(mySearchString)?.hex ?: isAStraightHex
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -108,11 +115,18 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
}
|
||||
|
||||
fun search(searchString: String) {
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
if (this.searchString != searchString) {
|
||||
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
|
||||
this.searchString = searchString
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
searchString = null
|
||||
if (searchString != null) {
|
||||
println("DataSource: ${this.javaClass.simpleName} Clear")
|
||||
searchString = null
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+78
-30
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -21,34 +22,32 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var addressesToWatch = setOf<Note>()
|
||||
|
||||
private fun createReactionsToWatchInAddressFilter(): List<TypedFilter>? {
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch
|
||||
val addressesToWatch = eventsToWatch.filter { it.address() != null } + addressesToWatch.filter { it.address() != null }
|
||||
|
||||
if (addressesToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
CommunityPostApprovalEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime,
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
return groupByEOSEPresence(eventsToWatch).mapNotNull {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind,
|
||||
ReactionEvent.kind,
|
||||
RepostEvent.kind,
|
||||
GenericRepostEvent.kind,
|
||||
ReportEvent.kind,
|
||||
LnZapEvent.kind,
|
||||
PollNoteEvent.kind,
|
||||
CommunityPostApprovalEvent.kind,
|
||||
LiveActivitiesChatMessageEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
|
||||
since = findMinimumEOSEs(it),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +66,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(aTag.kind),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
limit = 5
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@@ -77,7 +76,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
kinds = listOf(aTag.kind),
|
||||
tags = mapOf("d" to listOf(aTag.dTag)),
|
||||
authors = listOf(aTag.pubKeyHex),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
limit = 5
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -86,13 +85,11 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
|
||||
val reactionsToWatch = eventsToWatch
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
if (eventsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return reactionsToWatch.map {
|
||||
return groupByEOSEPresence(eventsToWatch).map {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
@@ -105,8 +102,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
LnZapEvent.kind,
|
||||
PollNoteEvent.kind
|
||||
),
|
||||
tags = mapOf("e" to listOf(it.idHex)),
|
||||
since = it.lastReactionsDownloadTime,
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
limit = 1000 // Max amount of "replies" to download on a specific event.
|
||||
)
|
||||
)
|
||||
@@ -156,6 +153,15 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
addressesToWatch.forEach {
|
||||
val eose = it.lastReactionsDownloadTime[relayUrl]
|
||||
if (eose == null) {
|
||||
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
@@ -198,3 +204,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun groupByEOSEPresence(notes: Set<Note>): Collection<List<Note>> {
|
||||
return notes.groupBy { it.lastReactionsDownloadTime.keys.sorted().joinToString(",") }.values
|
||||
}
|
||||
|
||||
fun groupByEOSEPresence(users: Iterable<User>): Collection<List<User>> {
|
||||
return users.groupBy { it.latestEOSEs.keys.sorted().joinToString(",") }.values
|
||||
}
|
||||
|
||||
fun findMinimumEOSEs(notes: List<Note>): Map<String, EOSETime> {
|
||||
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
|
||||
|
||||
notes.forEach {
|
||||
it.lastReactionsDownloadTime.forEach {
|
||||
val minEose = minLatestEOSEs[it.key]
|
||||
if (minEose == null) {
|
||||
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
|
||||
} else if (it.value.time < minEose.time) {
|
||||
minEose.time = it.value.time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minLatestEOSEs
|
||||
}
|
||||
|
||||
fun findMinimumEOSEsForUsers(users: List<User>): Map<String, EOSETime> {
|
||||
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
|
||||
|
||||
users.forEach {
|
||||
it.latestEOSEs.forEach {
|
||||
val minEose = minLatestEOSEs[it.key]
|
||||
if (minEose == null) {
|
||||
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
|
||||
} else if (it.value.time < minEose.time) {
|
||||
minEose.time = it.value.time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minLatestEOSEs
|
||||
}
|
||||
|
||||
@@ -17,99 +17,71 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
|
||||
val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex }
|
||||
|
||||
if (firstTimers.isEmpty()) return null
|
||||
|
||||
return listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
|
||||
authors = firstTimers,
|
||||
limit = 10 * firstTimers.size
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = firstTimers
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
fun createUserMetadataStatusReportFilter(): List<TypedFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(StatusEvent.kind),
|
||||
authors = usersToWatch.map { it.pubkeyHex },
|
||||
since = minLatestEOSEs
|
||||
val secondTimers = usersToWatch.filter { it.info?.latestMetadata != null }
|
||||
|
||||
if (secondTimers.isEmpty()) return null
|
||||
|
||||
return groupByEOSEPresence(secondTimers).map { group ->
|
||||
val groupIds = group.map { it.pubkeyHex }
|
||||
val minEOSEs = findMinimumEOSEsForUsers(group)
|
||||
listOf(
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
|
||||
authors = groupIds,
|
||||
since = minEOSEs
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to groupIds),
|
||||
since = minEOSEs
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(StatusEvent.kind),
|
||||
authors = usersToWatch.map { it.pubkeyHex },
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
|
||||
since = minLatestEOSEs
|
||||
)
|
||||
)
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel() { time, relayUrl ->
|
||||
usersToWatch.forEach {
|
||||
val eose = it.latestEOSEs[relayUrl]
|
||||
if (eose == null) {
|
||||
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
}
|
||||
checkNotInMainThread()
|
||||
|
||||
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val minLatestEOSEs = mutableMapOf<String, EOSETime>()
|
||||
val neverGottenAnEOSE = mutableSetOf<String>()
|
||||
usersToWatch.forEach {
|
||||
if (it.latestEOSEs.isEmpty()) { // first time
|
||||
neverGottenAnEOSE.add(it.pubkeyHex)
|
||||
} else {
|
||||
it.latestEOSEs.forEach {
|
||||
val minEose = minLatestEOSEs[it.key]
|
||||
if (minEose == null) {
|
||||
minLatestEOSEs.put(it.key, EOSETime(it.value.time))
|
||||
} else if (it.value.time < minEose.time) {
|
||||
minEose.time = it.value.time
|
||||
}
|
||||
if (it.info?.latestMetadata != null) {
|
||||
val eose = it.latestEOSEs[relayUrl]
|
||||
if (eose == null) {
|
||||
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, EOSETime(time))
|
||||
} else {
|
||||
eose.time = time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
checkNotInMainThread()
|
||||
|
||||
userChannel.typedFilters = listOfNotNull(
|
||||
createUserMetadataFilter(minLatestEOSEs),
|
||||
createUserStatusFilter(minLatestEOSEs),
|
||||
createUserReportFilter(minLatestEOSEs)
|
||||
).ifEmpty { null }
|
||||
userChannelFirstTimers.typedFilters = listOfNotNull(
|
||||
createUserMetadataFilter()
|
||||
createUserMetadataFilter(),
|
||||
createUserMetadataStatusReportFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
.toSet()
|
||||
.ifEmpty { null } ?: return null
|
||||
|
||||
if (eventsToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
ids = eventsToLoad.map { it.substring(0, 8) }
|
||||
ids = eventsToLoad.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -7,14 +8,37 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
val scope = Amethyst.instance.applicationIOScope
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
|
||||
var job: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job = scope.launch(Dispatchers.IO) {
|
||||
account.liveStoriesFollowLists.collect {
|
||||
if (this@NostrVideoDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
job?.cancel()
|
||||
}
|
||||
|
||||
fun createContextualFilter(): TypedFilter {
|
||||
val follows = account.liveStoriesFollowLists.value?.users?.toList()
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
@@ -22,15 +46,15 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
authors = follows,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
|
||||
val hashToLoad = account.liveStoriesFollowLists.value?.hashtags?.toList() ?: return null
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
@@ -42,13 +66,13 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowGeohashesFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedGeohashesFollowList(account.defaultStoriesFollowList)
|
||||
val hashToLoad = account.liveStoriesFollowLists.value?.geotags?.toList() ?: return null
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
@@ -62,13 +86,13 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100,
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
|
||||
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList.value)?.relayList
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel() { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList, relayUrl, time)
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultStoriesFollowList.value, relayUrl, time)
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -13,6 +13,14 @@ object OnlineChecker {
|
||||
val checkOnlineCache = LruCache<String, OnlineCheckResult>(100)
|
||||
val fiveMinutes = 1000 * 60 * 5
|
||||
|
||||
fun isOnlineCached(url: String?): Boolean {
|
||||
if (url.isNullOrBlank()) return false
|
||||
if ((checkOnlineCache.get(url)?.timeInMs ?: 0) > System.currentTimeMillis() - fiveMinutes) {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -21,6 +29,8 @@ object OnlineChecker {
|
||||
return checkOnlineCache.get(url).online
|
||||
}
|
||||
|
||||
Log.d("OnlineChecker", "isOnline $url")
|
||||
|
||||
return try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
@@ -29,6 +39,7 @@ object OnlineChecker {
|
||||
.build()
|
||||
|
||||
val result = HttpClient.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
checkOnlineCache.put(url, OnlineCheckResult(System.currentTimeMillis(), result))
|
||||
|
||||
@@ -148,6 +148,23 @@ class ZapPaymentHandler(val account: Account) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareZapRequestIfNeeded(
|
||||
note: Note,
|
||||
pollOption: Int?,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
overrideUser: User? = null,
|
||||
onReady: (String?) -> Unit
|
||||
) {
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP) {
|
||||
account.createZapRequestFor(note, pollOption, message, zapType, overrideUser) { zapRequest ->
|
||||
onReady(zapRequest.toJson())
|
||||
}
|
||||
} else {
|
||||
onReady(null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun innerZap(
|
||||
lud16: String,
|
||||
note: Note,
|
||||
@@ -161,54 +178,49 @@ class ZapPaymentHandler(val account: Account) {
|
||||
zapType: LnZapEvent.ZapType,
|
||||
overrideUser: User? = null
|
||||
) {
|
||||
var zapRequestJson = ""
|
||||
onProgress(0.05f)
|
||||
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP) {
|
||||
val zapRequest = account.createZapRequestFor(note, pollOption, message, zapType, overrideUser)
|
||||
if (zapRequest != null) {
|
||||
zapRequestJson = zapRequest.toJson()
|
||||
}
|
||||
}
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, overrideUser) { zapRequestJson ->
|
||||
onProgress(0.10f)
|
||||
|
||||
onProgress(0.10f)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount,
|
||||
message,
|
||||
zapRequestJson,
|
||||
onSuccess = {
|
||||
onProgress(0.7f)
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_dialog_pay_invoice_error),
|
||||
context.getString(
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString()
|
||||
?: "Error parsing error message"
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount,
|
||||
message,
|
||||
zapRequestJson,
|
||||
onSuccess = {
|
||||
onProgress(0.7f)
|
||||
if (account.hasWalletConnectSetup()) {
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = it,
|
||||
note,
|
||||
onResponse = { response ->
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
onProgress(0.0f)
|
||||
onError(
|
||||
context.getString(R.string.error_dialog_pay_invoice_error),
|
||||
context.getString(
|
||||
R.string.wallet_connect_pay_invoice_error_error,
|
||||
response.error?.message
|
||||
?: response.error?.code?.toString()
|
||||
?: "Error parsing error message"
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
} else {
|
||||
onProgress(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
onProgress(0.8f)
|
||||
} else {
|
||||
onPayInvoiceThroughIntent(it)
|
||||
onProgress(0f)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
context = context
|
||||
)
|
||||
)
|
||||
onProgress(0.8f)
|
||||
} else {
|
||||
onPayInvoiceThroughIntent(it)
|
||||
onProgress(0f)
|
||||
}
|
||||
},
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
context = context
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -9,8 +9,6 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Lud06
|
||||
import com.vitorpamplona.quartz.encoders.toLnUrl
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
@@ -33,12 +31,12 @@ class LightningAddressResolver() {
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJson(
|
||||
private fun fetchLightningAddressJson(
|
||||
lnaddress: String,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(lnaddress)
|
||||
@@ -51,7 +49,7 @@ class LightningAddressResolver() {
|
||||
lnaddress
|
||||
)
|
||||
)
|
||||
return@withContext
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -88,15 +86,17 @@ class LightningAddressResolver() {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchLightningInvoice(
|
||||
fun fetchLightningInvoice(
|
||||
lnCallback: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val encodedMessage = URLEncoder.encode(message, "utf-8")
|
||||
|
||||
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
|
||||
@@ -124,7 +124,7 @@ class LightningAddressResolver() {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
fun lnAddressToLnUrl(lnaddress: String, onSuccess: (String) -> Unit, onError: (String, String) -> Unit, context: Context) {
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = {
|
||||
@@ -135,12 +135,12 @@ class LightningAddressResolver() {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun lnAddressInvoice(
|
||||
fun lnAddressInvoice(
|
||||
lnaddress: String,
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context
|
||||
|
||||
+47
-122
@@ -7,8 +7,6 @@ import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.SignerType
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
@@ -42,110 +40,40 @@ 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(
|
||||
pushWrappedEvent.content,
|
||||
pushWrappedEvent.pubKey,
|
||||
pushWrappedEvent.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[pushWrappedEvent.id] ?: ""
|
||||
}
|
||||
pushWrappedEvent.unwrap(cached)?.let { notificationEvent ->
|
||||
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
|
||||
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
|
||||
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (key != null) {
|
||||
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
unwrapAndConsume(notificationEvent, account) { innerEvent ->
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unwrapAndConsume(event: Event, account: Account): Event? {
|
||||
if (!LocalCache.justVerify(event)) return null
|
||||
private fun unwrapAndConsume(event: Event, account: Account, onReady: (Event) -> Unit) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
|
||||
return when (event) {
|
||||
when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
val key = account.keyPair.privKey
|
||||
if (key != null) {
|
||||
event.cachedGift(key)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGift(account.keyPair.pubKey, cached)?.let {
|
||||
unwrapAndConsume(it, account)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
event.cachedGift(account.signer) {
|
||||
unwrapAndConsume(it, account, onReady)
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
val key = account.keyPair.privKey
|
||||
if (key != null) {
|
||||
event.cachedGossip(key)?.let {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
} else if (account.loginWithExternalSigner) {
|
||||
var cached = ExternalSignerUtils.cachedDecryptedContent[event.id]
|
||||
if (cached == null) {
|
||||
ExternalSignerUtils.decrypt(
|
||||
event.content,
|
||||
event.pubKey,
|
||||
event.id,
|
||||
SignerType.NIP44_DECRYPT
|
||||
)
|
||||
cached = ExternalSignerUtils.cachedDecryptedContent[event.id] ?: ""
|
||||
}
|
||||
event.cachedGossip(account.keyPair.pubKey, cached)?.let {
|
||||
LocalCache.justConsume(it, null)
|
||||
it
|
||||
}
|
||||
} else {
|
||||
null
|
||||
event.cachedGossip(account.signer) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LocalCache.justConsume(event, null)
|
||||
event
|
||||
onReady(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,11 +128,12 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
val content = acc.decryptContent(note) ?: ""
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
acc.decryptContent(note) { content ->
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,39 +146,35 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
if (acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
|
||||
val amount = showAmount(event.amount)
|
||||
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
Pair(author, decryptedContent.content)
|
||||
} else if (!noteZapRequest.event?.content().isNullOrBlank()) {
|
||||
Pair(noteZapRequest.author, noteZapRequest.event?.content())
|
||||
} else {
|
||||
Pair(noteZapRequest.author, null)
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
acc.decryptZapContentAuthor(noteZapRequest) {
|
||||
val author = LocalCache.getOrCreateUser(it.pubKey)
|
||||
val senderInfo = Pair(author, it.content.ifBlank { null })
|
||||
|
||||
acc.decryptContent(noteZapped) {
|
||||
val zappedContent = it.split("\n").get(0)
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
zappedContent?.let {
|
||||
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-13
@@ -4,7 +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.model.Account
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,24 +16,39 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
class RegisterAccounts(
|
||||
private val accounts: List<AccountInfo>
|
||||
) {
|
||||
private fun recursiveAuthCreation(
|
||||
notificationToken: String,
|
||||
remainingTos: List<Pair<Account, String>>,
|
||||
output: MutableList<RelayAuthEvent>,
|
||||
onReady: (List<RelayAuthEvent>) -> Unit
|
||||
) {
|
||||
if (remainingTos.isEmpty()) {
|
||||
onReady(output)
|
||||
return
|
||||
}
|
||||
|
||||
val next = remainingTos.first()
|
||||
|
||||
next.first.createAuthEvent(next.second, notificationToken) {
|
||||
output.add(it)
|
||||
recursiveAuthCreation(notificationToken, remainingTos.filter { next != it }, output, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
// creates proof that it controls all accounts
|
||||
private suspend fun signEventsToProveControlOfAccounts(
|
||||
accounts: List<AccountInfo>,
|
||||
notificationToken: String
|
||||
): List<RelayAuthEvent> {
|
||||
return accounts.mapNotNull {
|
||||
notificationToken: String,
|
||||
onReady: (List<RelayAuthEvent>) -> Unit
|
||||
) {
|
||||
val readyToSend = accounts.mapNotNull {
|
||||
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
|
||||
if (acc != null && (acc.isWriteable() || acc.loginWithExternalSigner)) {
|
||||
if (acc.loginWithExternalSigner) {
|
||||
ExternalSignerUtils.account = acc
|
||||
}
|
||||
|
||||
if (acc != null && acc.isWriteable()) {
|
||||
val readRelays = acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays()
|
||||
|
||||
val relayToUse = readRelays?.firstNotNullOfOrNull { if (it.value.read) it.key else null }
|
||||
if (relayToUse != null) {
|
||||
acc.createAuthEvent(relayToUse, notificationToken)
|
||||
Pair(acc, relayToUse)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -41,6 +56,14 @@ class RegisterAccounts(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val listOfAuthEvents = mutableListOf<RelayAuthEvent>()
|
||||
recursiveAuthCreation(
|
||||
notificationToken,
|
||||
readyToSend,
|
||||
listOfAuthEvents,
|
||||
onReady
|
||||
)
|
||||
}
|
||||
|
||||
fun postRegistrationEvent(events: List<RelayAuthEvent>) {
|
||||
@@ -75,9 +98,10 @@ class RegisterAccounts(
|
||||
}
|
||||
|
||||
suspend fun go(notificationToken: String) = withContext(Dispatchers.IO) {
|
||||
postRegistrationEvent(
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken)
|
||||
)
|
||||
signEventsToProveControlOfAccounts(accounts, notificationToken) {
|
||||
postRegistrationEvent(it)
|
||||
}
|
||||
|
||||
PushNotificationUtils.hasInit = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.playback
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
@@ -58,6 +60,8 @@ class PlaybackService : MediaSessionService() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
Log.d("Lifetime Event", "PlaybackService.onCreate")
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClient.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
}
|
||||
@@ -73,7 +77,15 @@ class PlaybackService : MediaSessionService() {
|
||||
toDestroyProgressive?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
|
||||
Log.d("Lifetime Event", "onTaskRemoved")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d("Lifetime Event", "PlaybackService.onDestroy")
|
||||
|
||||
HttpClient.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
|
||||
managerHls?.releaseAppPlayers()
|
||||
|
||||
@@ -12,18 +12,9 @@ private const val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
private const val ATTRIBUTE_VALUE_NAME = "name"
|
||||
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
|
||||
|
||||
/* for <meta property="og:" to get title */
|
||||
private val META_OG_TITLE = arrayOf("og:title", "\"og:title\"", "'og:title'")
|
||||
|
||||
/* for <meta property="og:" to get description */
|
||||
private val META_OG_DESCRIPTION =
|
||||
arrayOf("og:description", "\"og:description\"", "'og:description'")
|
||||
|
||||
/* for <meta property="og:" to get image */
|
||||
private val META_OG_IMAGE = arrayOf("og:image", "\"og:image\"", "'og:image'")
|
||||
|
||||
/*for <meta name=... to get title */
|
||||
private val META_NAME_TITLE = arrayOf(
|
||||
/*for <meta itemprop=... to get title */
|
||||
private val META_X_TITLE = arrayOf(
|
||||
"og:title", "\"og:title\"", "'og:title'", "name", "\"name\"", "'name'",
|
||||
"twitter:title",
|
||||
"\"twitter:title\"",
|
||||
"'twitter:title'",
|
||||
@@ -32,8 +23,10 @@ private val META_NAME_TITLE = arrayOf(
|
||||
"'title'"
|
||||
)
|
||||
|
||||
/*for <meta name=... to get description */
|
||||
private val META_NAME_DESCRIPTION = arrayOf(
|
||||
/*for <meta itemprop=... to get description */
|
||||
private val META_X_DESCRIPTION = arrayOf(
|
||||
"og:description", "\"og:description\"", "'og:description'",
|
||||
"description", "\"description\"", "'description'",
|
||||
"twitter:description",
|
||||
"\"twitter:description\"",
|
||||
"'twitter:description'",
|
||||
@@ -42,22 +35,15 @@ private val META_NAME_DESCRIPTION = arrayOf(
|
||||
"'description'"
|
||||
)
|
||||
|
||||
/*for <meta name=... to get image */
|
||||
private val META_NAME_IMAGE = arrayOf(
|
||||
/*for <meta itemprop=... to get image */
|
||||
private val META_X_IMAGE = arrayOf(
|
||||
"og:image", "\"og:image\"", "'og:image'",
|
||||
"image", "\"image\"", "'image'",
|
||||
"twitter:image",
|
||||
"\"twitter:image\"",
|
||||
"'twitter:image'"
|
||||
)
|
||||
|
||||
/*for <meta itemprop=... to get title */
|
||||
private val META_ITEMPROP_TITLE = arrayOf("name", "\"name\"", "'name'")
|
||||
|
||||
/*for <meta itemprop=... to get description */
|
||||
private val META_ITEMPROP_DESCRIPTION = arrayOf("description", "\"description\"", "'description'")
|
||||
|
||||
/*for <meta itemprop=... to get image */
|
||||
private val META_ITEMPROP_IMAGE = arrayOf("image", "\"image\"", "'image'")
|
||||
|
||||
private const val CONTENT = "content"
|
||||
|
||||
suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
|
||||
@@ -83,31 +69,38 @@ suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
|
||||
when (propertyTag) {
|
||||
in META_OG_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
|
||||
in META_OG_DESCRIPTION -> if (description.isEmpty()) {
|
||||
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
|
||||
in META_X_TITLE -> if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
|
||||
in META_X_IMAGE -> if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_NAME_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
|
||||
in META_NAME_DESCRIPTION -> if (description.isEmpty()) {
|
||||
in META_X_TITLE -> if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_X_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
|
||||
in META_X_IMAGE -> if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_ITEMPROP_TITLE -> if (title.isEmpty()) {
|
||||
in META_X_TITLE -> if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_ITEMPROP_DESCRIPTION -> if (description.isEmpty()) {
|
||||
in META_X_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_ITEMPROP_IMAGE -> if (image.isEmpty()) {
|
||||
in META_X_IMAGE -> if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,14 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotify(relay: Relay, description: String) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onNotify(relay, description) }
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -215,5 +223,7 @@ object Client : RelayPool.Listener {
|
||||
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
|
||||
|
||||
open fun onAuth(relay: Relay, challenge: String) = Unit
|
||||
|
||||
open fun onNotify(relay: Relay, description: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class Relay(
|
||||
|
||||
var closingTimeInSeconds = 0L
|
||||
|
||||
var afterEOSE = false
|
||||
var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
|
||||
|
||||
val authResponse = mutableMapOf<HexKey, Boolean>()
|
||||
|
||||
@@ -87,6 +87,7 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
Log.d("Client", "Relay.connect $url")
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
@@ -184,7 +185,7 @@ class Relay(
|
||||
}
|
||||
|
||||
fun markConnectionAsReady(pingInMs: Long, usingCompression: Boolean) {
|
||||
this.afterEOSE = false
|
||||
this.afterEOSEPerSubscription.clear()
|
||||
this.isReady = true
|
||||
this.pingInMs = pingInMs
|
||||
this.usingCompression = usingCompression
|
||||
@@ -194,76 +195,87 @@ class Relay(
|
||||
this.socket = null
|
||||
this.isReady = false
|
||||
this.usingCompression = false
|
||||
this.afterEOSE = false
|
||||
this.afterEOSEPerSubscription.clear()
|
||||
this.closingTimeInSeconds = TimeUtils.now()
|
||||
}
|
||||
|
||||
fun processNewRelayMessage(newMessage: String) {
|
||||
val msgArray = Event.mapper.readTree(newMessage)
|
||||
val type = msgArray.get(0).asText()
|
||||
val channel = msgArray.get(1).asText()
|
||||
|
||||
when (type) {
|
||||
when (val type = msgArray.get(0).asText()) {
|
||||
"EVENT" -> {
|
||||
val subscriptionId = msgArray.get(1).asText()
|
||||
val event = Event.fromJson(msgArray.get(2))
|
||||
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
// Log.w("Relay", "Relay onEVENT ${event.kind} $url, $subscriptionId ${msgArray.get(2)}")
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, channel, event)
|
||||
if (afterEOSE) {
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, channel)
|
||||
it.onEvent(this@Relay, subscriptionId, event)
|
||||
if (afterEOSEPerSubscription[subscriptionId] == true) {
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
afterEOSE = true
|
||||
// Log.w("Relay", "Relay onEOSE $url, $channel")
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, channel)
|
||||
val subscriptionId = msgArray.get(1).asText()
|
||||
|
||||
afterEOSEPerSubscription[subscriptionId] = true
|
||||
// Log.w("Relay", "Relay onEOSE $url $subscriptionId")
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, subscriptionId)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
val message = msgArray.get(1).asText()
|
||||
Log.w("Relay", "Relay onNotice $url, $message")
|
||||
|
||||
it.onError(this@Relay, message, Error("Relay sent notice: $message"))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
val eventId = msgArray[1].asText()
|
||||
val sucess = msgArray[2].asBoolean()
|
||||
val message = msgArray[3].asText()
|
||||
val success = msgArray[2].asBoolean()
|
||||
val message = if (msgArray.size() > 2) msgArray[3].asText() else ""
|
||||
|
||||
if (authResponse.containsKey(eventId)) {
|
||||
val wasAlreadyAuthenticated = authResponse.get(eventId)
|
||||
authResponse.put(eventId, sucess)
|
||||
if (wasAlreadyAuthenticated != true && sucess) {
|
||||
authResponse.put(eventId, success)
|
||||
if (wasAlreadyAuthenticated != true && success) {
|
||||
renewFilters()
|
||||
}
|
||||
}
|
||||
|
||||
Log.w("Relay", "Relay on OK $url, $eventId, $sucess, $message")
|
||||
it.onSendResponse(this@Relay, eventId, sucess, message)
|
||||
Log.w("Relay", "Relay on OK $url, $eventId, $success, $message")
|
||||
it.onSendResponse(this@Relay, eventId, success, message)
|
||||
}
|
||||
"AUTH" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
|
||||
// Log.w("Relay", "Relay onAuth $url, ${msg[1].asString}")
|
||||
it.onAuth(this@Relay, msgArray[1].asText())
|
||||
}
|
||||
"NOTIFY" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onNotify $url, ${msg[1].asString}")
|
||||
it.onNotify(this@Relay, msgArray[1].asText())
|
||||
}
|
||||
"CLOSED" -> listeners.forEach {
|
||||
Log.w("Relay", "Relay onClosed $url, $newMessage")
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay something else $url, $channel")
|
||||
Log.w("Relay", "Unsupported message: $newMessage")
|
||||
it.onError(
|
||||
this@Relay,
|
||||
channel,
|
||||
Error("Unknown type $type on channel $channel. Msg was $newMessage")
|
||||
"",
|
||||
Error("Unknown type $type on channel. Msg was $newMessage")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
Log.d("Client", "Relay.disconnect $url")
|
||||
checkNotInMainThread()
|
||||
|
||||
closingTimeInSeconds = TimeUtils.now()
|
||||
socket?.close(1000, "Normal close")
|
||||
socket?.cancel()
|
||||
socket = null
|
||||
isReady = false
|
||||
usingCompression = false
|
||||
afterEOSE = false
|
||||
afterEOSEPerSubscription.clear()
|
||||
}
|
||||
|
||||
fun sendFilter(requestId: String) {
|
||||
@@ -276,10 +288,12 @@ class Relay(
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
// println("FILTERSSENT $url $request")
|
||||
|
||||
// Log.d("Relay", "onFilterSent $url $requestId $request")
|
||||
|
||||
socket?.send(request)
|
||||
eventUploadCounterInBytes += request.bytesUsedInMemory()
|
||||
afterEOSE = false
|
||||
afterEOSEPerSubscription.clear()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -322,27 +336,23 @@ class Relay(
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
} else {
|
||||
if (write) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
} else {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (TimeUtils.now() > closingTimeInSeconds + RECONNECTING_IN_SECONDS) {
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
// sends all filters after connection is successful.
|
||||
connectAndRun {
|
||||
checkNotInMainThread()
|
||||
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +361,11 @@ class Relay(
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
socket?.send("""["CLOSE","$subscriptionId"]""")
|
||||
checkNotInMainThread()
|
||||
|
||||
val msg = """["CLOSE","$subscriptionId"]"""
|
||||
Log.d("Relay", "Close Subscription $url $msg")
|
||||
socket?.send(msg)
|
||||
}
|
||||
|
||||
fun isSameRelayConfig(other: Relay): Boolean {
|
||||
@@ -393,5 +407,10 @@ class Relay(
|
||||
* @param type is 0 for disconnect and 1 for connect
|
||||
*/
|
||||
fun onRelayStateChange(relay: Relay, type: StateType, channel: String?)
|
||||
|
||||
/**
|
||||
* Relay sent an invoice
|
||||
*/
|
||||
fun onNotify(relay: Relay, description: String)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ object RelayPool : Relay.Listener {
|
||||
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
|
||||
fun onNotify(relay: Relay, description: String)
|
||||
}
|
||||
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
@@ -138,6 +140,10 @@ object RelayPool : Relay.Listener {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
override fun onNotify(relay: Relay, description: String) {
|
||||
listeners.forEach { it.onNotify(relay, description) }
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
val connected = connectedRelays()
|
||||
val available = availableRelays()
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
@@ -50,17 +49,24 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Timer
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val isOnMobileDataState = mutableStateOf(false)
|
||||
private val isOnWifiDataState = mutableStateOf(false)
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager()
|
||||
private var shouldPauseService = true
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ExternalSignerUtils.start(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Log.d("Lifetime Event", "MainActivity.onCreate")
|
||||
|
||||
setContent {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
@@ -69,11 +75,11 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(isOnMobileDataState) {
|
||||
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
@@ -83,6 +89,7 @@ class MainActivity : AppCompatActivity() {
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
@@ -94,17 +101,24 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareToLaunchSigner() {
|
||||
shouldPauseService = false
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
Log.d("Lifetime Event", "MainActivity.onResume")
|
||||
|
||||
// starts muted every time
|
||||
DefaultMutedSetting.value = true
|
||||
|
||||
// Only starts after login
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
// Keep connection alive if it's calling the signer app
|
||||
Log.d("shouldPauseService", "shouldPauseService onResume: $shouldPauseService")
|
||||
if (shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start()
|
||||
serviceManager.justStart()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,12 +126,22 @@ class MainActivity : AppCompatActivity() {
|
||||
PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager).registerDefaultNetworkCallback(networkCallback)
|
||||
val connectivityManager = (getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
|
||||
connectivityManager.registerDefaultNetworkCallback(networkCallback)
|
||||
connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
?.let { updateNetworkCapabilities(it) }
|
||||
|
||||
// resets state until next External Signer Call
|
||||
Timer().schedule(350) {
|
||||
shouldPauseService = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
Log.d("Lifetime Event", "MainActivity.onPause")
|
||||
|
||||
LanguageTranslatorService.clear()
|
||||
ServiceManager.cleanObservers()
|
||||
serviceManager.cleanObservers()
|
||||
|
||||
// if (BuildConfig.DEBUG) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@@ -125,9 +149,10 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
// }
|
||||
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
Log.d("shouldPauseService", "shouldPauseService onPause: $shouldPauseService")
|
||||
if (shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.pause()
|
||||
serviceManager.pauseForGood()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,11 +162,33 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
Log.d("Lifetime Event", "MainActivity.onStart")
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
|
||||
// Graph doesn't completely clear.
|
||||
// GlobalScope.launch(Dispatchers.Default) {
|
||||
// serviceManager.trimMemory()
|
||||
// }
|
||||
|
||||
Log.d("Lifetime Event", "MainActivity.onStop")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d("Lifetime Event", "MainActivity.onDestroy")
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,12 +200,48 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
ServiceManager.trimMemory()
|
||||
serviceManager.trimMemory()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNetworkCapabilities(networkCapabilities: NetworkCapabilities): Boolean {
|
||||
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
|
||||
var changedNetwork = false
|
||||
|
||||
if (isOnMobileDataState.value != isOnMobileData) {
|
||||
isOnMobileDataState.value = isOnMobileData
|
||||
|
||||
changedNetwork = true
|
||||
}
|
||||
|
||||
if (isOnWifiDataState.value != isOnWifi) {
|
||||
isOnWifiDataState.value = isOnWifi
|
||||
|
||||
changedNetwork = true
|
||||
}
|
||||
|
||||
return changedNetwork
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
var lastNetwork: Network? = null
|
||||
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
|
||||
Log.d("ServiceManager NetworkCallback", "onAvailable: $shouldPauseService")
|
||||
if (shouldPauseService && lastNetwork != null && lastNetwork != network) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
serviceManager.forceRestart()
|
||||
}
|
||||
}
|
||||
|
||||
lastNetwork = network
|
||||
}
|
||||
|
||||
// Network capabilities have changed for the network
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
@@ -167,14 +250,11 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val isOnMobileData = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val isOnWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
Log.d("ServiceManager NetworkCallback", "onCapabilitiesChanged: ${network.networkHandle} hasMobileData $isOnMobileData hasWifi $isOnWifi")
|
||||
|
||||
if (isOnMobileDataState.value != isOnMobileData) {
|
||||
isOnMobileDataState.value = isOnMobileData
|
||||
|
||||
ServiceManager.forceRestartIfItShould()
|
||||
Log.d("ServiceManager NetworkCallback", "onCapabilitiesChanged: ${network.networkHandle} hasMobileData ${isOnMobileDataState.value} hasWifi ${isOnWifiDataState.value}")
|
||||
if (updateNetworkCapabilities(networkCapabilities) && shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
serviceManager.forceRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -26,9 +27,7 @@ import java.util.Base64
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
|
||||
fun randomChars() = List(16) { charPool.random() }.joinToString("")
|
||||
object ImageUploader {
|
||||
lateinit var account: Account
|
||||
|
||||
class ImageUploader(val account: Account?) {
|
||||
fun uploadImage(
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
@@ -109,61 +108,73 @@ object ImageUploader {
|
||||
)
|
||||
.build()
|
||||
|
||||
server.clientID(requestBody.toString())?.let {
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
server.authorizationToken(account, requestBody.toString()) { authorizationToken ->
|
||||
if (authorizationToken != null) {
|
||||
requestBuilder.addHeader("Authorization", authorizationToken)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
val request = requestBuilder.build()
|
||||
requestBuilder
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val url = server.parseUrlFromSuccess(body.string())
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val url = server.parseUrlFromSuccess(body.string(), authorizationToken)
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
|
||||
onSuccess(url, contentType)
|
||||
}
|
||||
|
||||
onSuccess(url, contentType)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
onError(e)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun NIP98Header(url: String, method: String, body: String): String {
|
||||
val noteJson = account.createHTTPAuthorization(url, method, body)?.toJson() ?: ""
|
||||
fun NIP98Header(url: String, method: String, body: String, onReady: (String?) -> Unit) {
|
||||
val myAccount = account
|
||||
|
||||
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
|
||||
return "Nostr " + encodedNIP98Event
|
||||
if (myAccount == null) {
|
||||
onReady(null)
|
||||
return
|
||||
}
|
||||
|
||||
myAccount.createHTTPAuthorization(url, method, body) {
|
||||
val noteJson = it.toJson()
|
||||
val encodedNIP98Event: String = Base64.getEncoder().encodeToString(noteJson.toByteArray())
|
||||
onReady("Nostr $encodedNIP98Event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSuccess(body: String): String?
|
||||
abstract fun parseUrlFromSuccess(body: String, authorizationToken: String?): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun clientID(info: String): String? = null
|
||||
open fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
onReady(null)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
@@ -172,8 +183,6 @@ class NostrImgServer : FileServer() {
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID(info: String) = null
|
||||
}
|
||||
|
||||
class ImgurServer : FileServer() {
|
||||
@@ -182,7 +191,7 @@ class ImgurServer : FileServer() {
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
@@ -192,12 +201,14 @@ class ImgurServer : FileServer() {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID(info: String) = "Client-ID e6aea87296f3f96"
|
||||
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
onReady("Client-ID e6aea87296f3f96")
|
||||
}
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/v2/upload/files"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val data = tree?.get("data")
|
||||
val data0 = data?.get(0)
|
||||
@@ -209,12 +220,14 @@ class NostrBuildServer : FileServer() {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID(info: String) = ImageUploader.NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info)
|
||||
override fun authorizationToken(account: Account?, info: String, onReady: (String?) -> Unit) {
|
||||
ImageUploader(account).NIP98Header("https://nostr.build/api/v2/upload/files", "POST", info, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
return tree?.get("url")?.asText()
|
||||
}
|
||||
@@ -222,26 +235,29 @@ class NostrFilesDevServer : FileServer() {
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID(info: String) = null
|
||||
}
|
||||
|
||||
class NostrCheckMeServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrcheck.me/api/v1/media"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String, authorizationToken: String?): String? {
|
||||
checkNotInMainThread()
|
||||
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("url")?.asText()
|
||||
var id = tree?.get("id")?.asText()
|
||||
val id = tree?.get("id")?.asText()
|
||||
var isCompleted = false
|
||||
|
||||
val client = HttpClient.getHttpClient()
|
||||
var requrl = "https://nostrcheck.me/api/v1/media?id=" + id // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
|
||||
val requrl =
|
||||
"https://nostrcheck.me/api/v1/media?id=$id" // + "&apikey=26d075787d261660682fb9d20dbffa538c708b1eda921d0efa2be95fbef4910a"
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(requrl)
|
||||
.addHeader("Authorization", ImageUploader.NIP98Header(requrl, "GET", ""))
|
||||
.get()
|
||||
.build()
|
||||
val requestBuilder = Request.Builder().url(requrl)
|
||||
|
||||
if (authorizationToken != null) {
|
||||
requestBuilder.addHeader("Authorization", authorizationToken)
|
||||
}
|
||||
|
||||
val request = requestBuilder.get().build()
|
||||
|
||||
while (!isCompleted) {
|
||||
client.newCall(request).execute().use {
|
||||
@@ -261,5 +277,7 @@ class NostrCheckMeServer : FileServer() {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
override fun clientID(body: String) = ImageUploader.NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body)
|
||||
override fun authorizationToken(account: Account?, body: String, onReady: (String?) -> Unit) {
|
||||
ImageUploader(account).NIP98Header("https://nostrcheck.me/api/v1/media", "POST", body, onReady)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
@@ -64,6 +63,8 @@ import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
@@ -313,10 +314,7 @@ private fun RenderSearchResults(
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
contentPadding = FeedPadding,
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(
|
||||
@@ -411,7 +409,7 @@ fun UserComposeForChat(
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingPercentage.value = 0.2f
|
||||
uploadingDescription.value = "Uploading"
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader.uploadImage(
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
@@ -173,11 +173,12 @@ open class NewMediaModel : ViewModel() {
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(it, relayList)
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
account?.sendHeader(it, relayList) {
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
@@ -216,18 +217,16 @@ open class NewMediaModel : ViewModel() {
|
||||
onReady = {
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.40f
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
|
||||
if (nip95 != null) {
|
||||
account?.createNip95(bytes, headerInfo = it) { nip95 ->
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second, relayList)
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
uploadingDescription.value = null
|
||||
|
||||
@@ -81,7 +81,9 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeWriteRelays()
|
||||
var relayList = remember {
|
||||
accountViewModel.account.activeWriteRelays().toImmutableList()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.Bech32
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
@@ -69,19 +70,19 @@ class NewMessageTagger(
|
||||
if (results?.key?.type == Nip19.Type.USER) {
|
||||
val user = dao.getOrCreateUser(results.key.hex)
|
||||
|
||||
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
|
||||
getNostrAddress(user.pubkeyNpub(), results.restOfWord)
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = dao.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
getNostrAddress(note.toNEvent(), results.restOfWord)
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = dao.getOrCreateNote(results.key.hex)
|
||||
|
||||
"nostr:${note.toNEvent()}${results.restOfWord}"
|
||||
getNostrAddress(note.toNEvent(), results.restOfWord)
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
"nostr:${note.idNote()}${results.restOfWord}"
|
||||
getNostrAddress(note.idNote(), results.restOfWord)
|
||||
} else {
|
||||
word
|
||||
}
|
||||
@@ -92,6 +93,18 @@ class NewMessageTagger(
|
||||
}.joinToString("\n")
|
||||
}
|
||||
|
||||
fun getNostrAddress(bechAddress: String, restOfTheWord: String): String {
|
||||
return if (restOfTheWord.isEmpty()) {
|
||||
"nostr:$bechAddress"
|
||||
} else {
|
||||
if (Bech32.alphabet.contains(restOfTheWord.get(0), true)) {
|
||||
"nostr:$bechAddress $restOfTheWord"
|
||||
} else {
|
||||
"nostr:${bechAddress}$restOfTheWord"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.BechLink
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
@@ -172,8 +172,6 @@ fun NewPostView(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val account = remember(accountViewModel) { accountViewModel.account }
|
||||
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
postViewModel.wantsDirectMessage = enableMessageInterface
|
||||
|
||||
@@ -184,7 +182,9 @@ fun NewPostView(
|
||||
var showRelaysDialog by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var relayList = account.activeWriteRelays()
|
||||
var relayList = remember {
|
||||
accountViewModel.account.activeWriteRelays().toImmutableList()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote)
|
||||
@@ -199,8 +199,11 @@ fun NewPostView(
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
|
||||
onDispose {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,10 +390,10 @@ fun NewPostView(
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
account.defaultFileServer,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, server, context, relayList)
|
||||
account.changeDefaultFileServer(server)
|
||||
accountViewModel.account.changeDefaultFileServer(server)
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
@@ -414,7 +417,7 @@ fun NewPostView(
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
user.pubkeyHex,
|
||||
account,
|
||||
accountViewModel.account,
|
||||
stringResource(id = R.string.lightning_invoice),
|
||||
stringResource(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
@@ -465,7 +468,7 @@ fun NewPostView(
|
||||
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
|
||||
VideoView(myUrlPreview, roundedCorner = true, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel)
|
||||
}
|
||||
} else if (startsWithNIP19Scheme(myUrlPreview)) {
|
||||
val bgColor = MaterialTheme.colorScheme.background
|
||||
@@ -481,7 +484,7 @@ fun NewPostView(
|
||||
nav
|
||||
)
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,10 +98,6 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
var wantsInvoice by mutableStateOf(false)
|
||||
|
||||
data class ForwardZapSetup(val user: User) {
|
||||
var percentage by mutableStateOf(100)
|
||||
}
|
||||
|
||||
// Forward Zap to
|
||||
var wantsForwardZapTo by mutableStateOf(false)
|
||||
var forwardZapTo by mutableStateOf<Split<User>>(Split())
|
||||
@@ -124,6 +120,9 @@ open class NewPostViewModel() : ViewModel() {
|
||||
var nip24 by mutableStateOf(false)
|
||||
|
||||
open fun load(accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
if (replyNote.event is BaseTextNoteEvent) {
|
||||
@@ -151,7 +150,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
@@ -167,9 +166,6 @@ open class NewPostViewModel() : ViewModel() {
|
||||
zapRaiserAmount = null
|
||||
forwardZapTo = Split()
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<Relay>? = null) {
|
||||
@@ -283,6 +279,8 @@ open class NewPostViewModel() : ViewModel() {
|
||||
(originalNote?.event as? TextNoteEvent)?.root() // if it has a marker as root
|
||||
?: originalNote?.replyTo?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true }?.idHex // if it has loaded events with zero replies in the reply list
|
||||
?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root.
|
||||
?: originalNote?.idHex
|
||||
|
||||
val replyId = originalNote?.idHex
|
||||
|
||||
account?.sendPost(
|
||||
@@ -324,7 +322,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader.uploadImage(
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
@@ -335,7 +333,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
message = TextFieldValue(message.text + "\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
@@ -554,17 +552,13 @@ open class NewPostViewModel() : ViewModel() {
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it, relayList = relayList)
|
||||
account?.sendHeader(it, relayList = relayList) { note ->
|
||||
isUploadingImage = false
|
||||
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\nnostr:" + note.toNEvent())
|
||||
|
||||
if (note == null) {
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
} else {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent())
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
@@ -585,16 +579,17 @@ open class NewPostViewModel() : ViewModel() {
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onReady = {
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
|
||||
account?.createNip95(bytes, headerInfo = it) { nip95 ->
|
||||
val note = nip95.let { it1 -> account?.sendNip95(it1.first, it1.second, relayList = relayList) }
|
||||
|
||||
isUploadingImage = false
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent())
|
||||
note?.let {
|
||||
message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
@@ -693,7 +688,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
fun updateZapPercentage(index: Int, sliderValue: Float) {
|
||||
forwardZapTo?.updatePercentage(index, sliderValue)
|
||||
forwardZapTo.updatePercentage(index, sliderValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -67,6 +66,8 @@ import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
|
||||
@@ -102,8 +103,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -154,10 +154,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
contentPadding = FeedPadding
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
|
||||
ServerConfig(
|
||||
@@ -252,7 +249,7 @@ fun ServerConfigHeader() {
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -409,7 +406,7 @@ fun ServerConfigClickableLine(
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context.applicationContext,
|
||||
onReady = { fileUri, contentType, size ->
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader.uploadImage(
|
||||
ImageUploader(account).uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Done
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
|
||||
@Composable
|
||||
fun NotifyRequestDialog(
|
||||
title: String,
|
||||
textContent: String,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(title)
|
||||
},
|
||||
text = {
|
||||
val defaultBackground = MaterialTheme.colorScheme.background
|
||||
val background = remember {
|
||||
mutableStateOf(defaultBackground)
|
||||
}
|
||||
|
||||
TranslatableRichTextViewer(
|
||||
textContent,
|
||||
canPreview = true,
|
||||
Modifier.fillMaxWidth(),
|
||||
EmptyTagList,
|
||||
background,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = onDismiss, colors = buttonColors, contentPadding = PaddingValues(horizontal = Size16dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Done,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
Text(stringResource(R.string.error_dialog_button_ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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
|
||||
@@ -32,6 +31,9 @@ import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class RelayList(
|
||||
val relay: Relay,
|
||||
@@ -46,9 +48,9 @@ data class RelayInfoDialog(
|
||||
|
||||
@Composable
|
||||
fun RelaySelectionDialog(
|
||||
preSelectedList: List<Relay>,
|
||||
preSelectedList: ImmutableList<Relay>,
|
||||
onClose: () -> Unit,
|
||||
onPost: (list: List<Relay>) -> Unit,
|
||||
onPost: (list: ImmutableList<Relay>) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
@@ -124,7 +126,7 @@ fun RelaySelectionDialog(
|
||||
SaveButton(
|
||||
onPost = {
|
||||
val selectedRelays = relays.filter { it.isSelected }
|
||||
onPost(selectedRelays.map { it.relay })
|
||||
onPost(selectedRelays.map { it.relay }.toImmutableList())
|
||||
onClose()
|
||||
},
|
||||
isActive = hasSelectedRelay
|
||||
@@ -143,10 +145,7 @@ fun RelaySelectionDialog(
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
contentPadding = FeedPadding
|
||||
) {
|
||||
itemsIndexed(
|
||||
relays,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.buttons
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -21,6 +20,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
|
||||
@Composable
|
||||
fun NewChannelButton(accountViewModel: AccountViewModel) {
|
||||
@@ -34,10 +35,10 @@ fun NewChannelButton(accountViewModel: AccountViewModel) {
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { wantsToPost = true },
|
||||
modifier = Modifier.size(55.dp),
|
||||
modifier = Size55Modifier,
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(containerColor = MaterialTheme.colorScheme.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
contentPadding = ZeroPadding
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
|
||||
@@ -685,15 +685,17 @@ fun InLineIconRenderer(
|
||||
}
|
||||
}.associate { it.first to it.second }
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
wordsInOrder.forEachIndexed { idx, value ->
|
||||
withStyle(
|
||||
style
|
||||
) {
|
||||
if (value is TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
val annotatedText = remember {
|
||||
buildAnnotatedString {
|
||||
wordsInOrder.forEachIndexed { idx, value ->
|
||||
withStyle(
|
||||
style
|
||||
) {
|
||||
if (value is TextType) {
|
||||
append(value.text)
|
||||
} else if (value is ImageUrlType) {
|
||||
appendInlineContent("inlineContent$idx", "[icon]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -27,6 +26,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.getGradient
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
@@ -114,7 +114,7 @@ fun ShowMoreButton(onClick: () -> Unit) {
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryButtonBackground
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
contentPadding = ButtonPadding
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_more), color = Color.White)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -41,9 +42,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Stable
|
||||
data class InvoiceAmount(val invoice: String, val amount: String?)
|
||||
|
||||
@Composable
|
||||
fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||||
fun LoadValueFromInvoice(lnbcWord: String, inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit) {
|
||||
var lnInvoice by remember { mutableStateOf<InvoiceAmount?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnbcWord) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -56,19 +60,26 @@ fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
null
|
||||
}
|
||||
|
||||
lnInvoice = Pair(myInvoice, myInvoiceAmount)
|
||||
lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = lnInvoice) {
|
||||
if (it != null) {
|
||||
InvoicePreview(it.first, it.second)
|
||||
} else {
|
||||
Text(
|
||||
text = lnbcWord,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
inner(lnInvoice)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount ->
|
||||
Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") {
|
||||
if (it != null) {
|
||||
InvoicePreview(it.invoice, it.amount)
|
||||
} else {
|
||||
Text(
|
||||
text = lnbcWord,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,12 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -155,19 +157,34 @@ fun InvoiceRequest(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
zapRequest?.toJson(),
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
},
|
||||
context = context
|
||||
)
|
||||
if (account.defaultZapType == LnZapEvent.ZapType.NONZAP) {
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
null,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
},
|
||||
context = context
|
||||
)
|
||||
} else {
|
||||
account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) { zapRequest ->
|
||||
LocalCache.justConsume(zapRequest, null)
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
zapRequest.toJson(),
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
},
|
||||
context = context
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
|
||||
+4
-3
@@ -12,7 +12,7 @@ import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
|
||||
fun LoadUrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
|
||||
val automaticallyShowUrlPreview = remember {
|
||||
accountViewModel.settings.showUrlPreview.value
|
||||
}
|
||||
@@ -37,11 +37,12 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
|
||||
|
||||
Crossfade(
|
||||
targetState = urlPreviewState,
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
animationSpec = tween(durationMillis = 100),
|
||||
label = "UrlPreview"
|
||||
) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
UrlPreviewCard(url, state.previewInfo, accountViewModel)
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -278,7 +278,7 @@ private fun RenderWordWithPreview(
|
||||
) {
|
||||
when (word) {
|
||||
is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is LinkSegment -> UrlPreview(word.segmentText, word.segmentText, accountViewModel)
|
||||
is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, accountViewModel)
|
||||
is EmojiSegment -> RenderCustomEmoji(word.segmentText, state)
|
||||
is InvoiceSegment -> MayBeInvoicePreview(word.segmentText)
|
||||
is WithdrawSegment -> MayBeWithdrawal(word.segmentText)
|
||||
@@ -556,32 +556,32 @@ private fun RenderHashtag(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val background = MaterialTheme.colorScheme.onBackground
|
||||
val hashtagIcon = remember(segment.hashtag) {
|
||||
checkForHashtagWithIcon(segment.hashtag, primary)
|
||||
}
|
||||
|
||||
val regularText =
|
||||
SpanStyle(color = MaterialTheme.colorScheme.onBackground)
|
||||
val regularText = remember { SpanStyle(color = background) }
|
||||
val clickableTextStyle = remember { SpanStyle(color = primary) }
|
||||
|
||||
val clickableTextStyle =
|
||||
SpanStyle(color = primary)
|
||||
|
||||
val annotatedTermsString = buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
append("#${segment.hashtag}")
|
||||
}
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
val annotatedTermsString = remember {
|
||||
buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
append("#${segment.hashtag}")
|
||||
}
|
||||
}
|
||||
|
||||
segment.extras?.ifBlank { "" }?.let {
|
||||
withStyle(regularText) {
|
||||
append(it)
|
||||
if (hashtagIcon != null) {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
}
|
||||
}
|
||||
|
||||
segment.extras?.ifBlank { "" }?.let {
|
||||
withStyle(regularText) {
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,8 +592,10 @@ private fun RenderHashtag(
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val pressIndicator = Modifier.clickable {
|
||||
nav("Hashtag/${segment.hashtag}")
|
||||
val pressIndicator = remember {
|
||||
Modifier.clickable {
|
||||
nav("Hashtag/${segment.hashtag}")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -37,6 +36,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
|
||||
@Composable
|
||||
@@ -143,7 +143,7 @@ fun ContentWarningNote(onDismiss: () -> Unit) {
|
||||
.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
contentPadding = ButtonPadding
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.show_anyway),
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@@ -143,7 +144,7 @@ fun <T> SpinnerSelectionDialog(
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Divider(color = Color.LightGray, thickness = 0.25.dp)
|
||||
Divider(color = Color.LightGray, thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
itemsIndexed(options) { index, item ->
|
||||
@@ -160,7 +161,7 @@ fun <T> SpinnerSelectionDialog(
|
||||
}
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
Divider(color = Color.LightGray, thickness = 0.25.dp)
|
||||
Divider(color = Color.LightGray, thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ 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.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
@@ -18,7 +16,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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
|
||||
@@ -27,62 +24,51 @@ import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
url: String,
|
||||
previewInfo: UrlInfoItem,
|
||||
accountViewModel: AccountViewModel
|
||||
previewInfo: UrlInfoItem
|
||||
) {
|
||||
val automaticallyShowUrlPreview = remember {
|
||||
accountViewModel.settings.showUrlPreview.value
|
||||
}
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
if (!automaticallyShowUrlPreview) {
|
||||
ClickableUrl(url, url)
|
||||
} else {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
Row(
|
||||
modifier = MaterialTheme.colorScheme.innerPostModifier
|
||||
.clickable {
|
||||
runCatching { uri.openUri(url) }
|
||||
}
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
model = previewInfo.imageUrlFullPath,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Text(
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Column(
|
||||
modifier = MaterialTheme.colorScheme.innerPostModifier
|
||||
.clickable {
|
||||
runCatching { uri.openUri(url) }
|
||||
}
|
||||
}
|
||||
) {
|
||||
AsyncImage(
|
||||
model = previewInfo.imageUrlFullPath,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
|
||||
Text(
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = MaxWidthWithHorzPadding,
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
@@ -25,10 +26,12 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -44,12 +47,11 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
@@ -71,12 +73,14 @@ import com.vitorpamplona.amethyst.ui.note.MuteIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MutedIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.flow
|
||||
@@ -209,7 +213,7 @@ fun VideoViewInner(
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
VideoPlayerActiveMutex(videoUri) { activeOnScreen ->
|
||||
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
mutableStateOf(
|
||||
MediaItem.Builder()
|
||||
@@ -251,6 +255,7 @@ fun VideoViewInner(
|
||||
keepPlaying = keepPlaying,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
activeOnScreen = activeOnScreen,
|
||||
modifier = modifier,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
@@ -259,6 +264,18 @@ fun VideoViewInner(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed class MediaControllerState {
|
||||
@Immutable
|
||||
object NotStarted : MediaControllerState()
|
||||
|
||||
@Immutable
|
||||
object Loading : MediaControllerState()
|
||||
|
||||
@Stable
|
||||
class Loaded(val instance: MediaController) : MediaControllerState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(UnstableApi::class)
|
||||
fun GetVideoController(
|
||||
@@ -271,8 +288,13 @@ fun GetVideoController(
|
||||
val context = LocalContext.current
|
||||
|
||||
val controller = remember(videoUri) {
|
||||
mutableStateOf<MediaController?>(
|
||||
if (videoUri == keepPlayingMutex?.currentMediaItem?.mediaId) keepPlayingMutex else null
|
||||
val globalMutex = keepPlayingMutex
|
||||
mutableStateOf<MediaControllerState>(
|
||||
if (videoUri == globalMutex?.currentMediaItem?.mediaId) {
|
||||
MediaControllerState.Loaded(globalMutex)
|
||||
} else {
|
||||
MediaControllerState.NotStarted
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -292,80 +314,71 @@ fun GetVideoController(
|
||||
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) {
|
||||
if (controller.value == MediaControllerState.NotStarted) {
|
||||
controller.value = MediaControllerState.Loading
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("PlaybackService", "Preparing Video $videoUri ")
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context
|
||||
) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
|
||||
// checks again because of race conditions.
|
||||
if (controller.value == null) { // still prone to race conditions.
|
||||
controller.value = it
|
||||
val newState = MediaControllerState.Loaded(it)
|
||||
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
controller.value?.volume = 0f
|
||||
newState.instance.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
controller.value?.volume = if (defaultToStart) 0f else 1f
|
||||
newState.instance.volume = if (defaultToStart) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
controller.value?.setMediaItem(mediaItem.value)
|
||||
controller.value?.prepare()
|
||||
} else if (controller.value != it) {
|
||||
// discards the new controller because there is an existing one
|
||||
it.stop()
|
||||
it.release()
|
||||
newState.instance.setMediaItem(mediaItem.value)
|
||||
newState.instance.prepare()
|
||||
|
||||
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.value)
|
||||
it.prepare()
|
||||
}
|
||||
}
|
||||
controller.value = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 if (controller.value is MediaControllerState.Loaded) {
|
||||
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
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.value)
|
||||
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
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
|
||||
it.stop()
|
||||
it.release()
|
||||
Log.d("PlaybackService", "Releasing Video $videoUri ")
|
||||
controller.value = MediaControllerState.NotStarted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,47 +391,55 @@ fun GetVideoController(
|
||||
// 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
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (controller.value == null) {
|
||||
if (controller.value == MediaControllerState.NotStarted) {
|
||||
controller.value = MediaControllerState.Loading
|
||||
|
||||
Log.d("PlaybackService", "Preparing Video from Resume $videoUri ")
|
||||
|
||||
PlaybackClientController.prepareController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
context
|
||||
) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
scope.launch(Dispatchers.Main) {
|
||||
// REQUIRED TO BE RUN IN THE MAIN THREAD
|
||||
|
||||
// checks again to make sure no other thread has created a controller.
|
||||
if (controller.value == null) {
|
||||
controller.value = it
|
||||
val newState = MediaControllerState.Loaded(it)
|
||||
|
||||
// checks again to make sure no other thread has created a controller.
|
||||
if (!it.isPlaying) {
|
||||
if (keepPlayingMutex?.isPlaying == true) {
|
||||
// There is a video playing, start this one on mute.
|
||||
controller.value?.volume = 0f
|
||||
newState.instance.volume = 0f
|
||||
} else {
|
||||
// There is no other video playing. Use the default mute state to
|
||||
// decide if sound is on or not.
|
||||
controller.value?.volume = if (defaultToStart) 0f else 1f
|
||||
newState.instance.volume =
|
||||
if (defaultToStart) 0f else 1f
|
||||
}
|
||||
}
|
||||
|
||||
controller.value?.setMediaItem(mediaItem.value)
|
||||
controller.value?.prepare()
|
||||
} else if (controller.value != it) {
|
||||
// discards the new controller because there is an existing one
|
||||
it.stop()
|
||||
it.release()
|
||||
newState.instance.setMediaItem(mediaItem.value)
|
||||
newState.instance.prepare()
|
||||
|
||||
controller.value = newState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
controller.value?.stop()
|
||||
controller.value?.release()
|
||||
controller.value = null
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
if (!keepPlaying.value) {
|
||||
// Stops and releases the media.
|
||||
(controller.value as? MediaControllerState.Loaded)?.instance?.let {
|
||||
Log.d("PlaybackService", "Releasing Video from Pause $videoUri ")
|
||||
it.stop()
|
||||
it.release()
|
||||
controller.value = MediaControllerState.NotStarted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -429,8 +450,8 @@ fun GetVideoController(
|
||||
}
|
||||
}
|
||||
|
||||
controller.value?.let {
|
||||
inner(it, keepPlaying)
|
||||
(controller.value as? MediaControllerState.Loaded) ?.let {
|
||||
inner(it.instance, keepPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +471,7 @@ class VisibilityData() {
|
||||
* the screen wins the mutex.
|
||||
*/
|
||||
@Composable
|
||||
fun VideoPlayerActiveMutex(videoUri: String, inner: @Composable (MutableState<Boolean>) -> Unit) {
|
||||
fun VideoPlayerActiveMutex(videoUri: String, inner: @Composable (Modifier, MutableState<Boolean>) -> Unit) {
|
||||
val myCache = remember(videoUri) {
|
||||
VisibilityData()
|
||||
}
|
||||
@@ -499,9 +520,7 @@ fun VideoPlayerActiveMutex(videoUri: String, inner: @Composable (MutableState<Bo
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = myModifier) {
|
||||
inner(active)
|
||||
}
|
||||
inner(myModifier, active)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -520,6 +539,7 @@ private fun RenderVideoPlayer(
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
activeOnScreen: MutableState<Boolean>,
|
||||
modifier: Modifier,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)?
|
||||
) {
|
||||
@@ -529,134 +549,132 @@ private fun RenderVideoPlayer(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val parentVideoPlaybackSize = remember {
|
||||
mutableStateOf<IntSize>(IntSize.Zero)
|
||||
val videoPlaybackHeight = remember {
|
||||
mutableStateOf<Dp>(Dp.Unspecified)
|
||||
}
|
||||
|
||||
val videoPlaybackSize = remember {
|
||||
mutableStateOf<IntSize>(IntSize.Zero)
|
||||
}
|
||||
val localDensity = LocalDensity.current
|
||||
|
||||
BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize().onSizeChanged {
|
||||
parentVideoPlaybackSize.value = it
|
||||
}
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { coordinates ->
|
||||
videoPlaybackHeight.value = with(localDensity) { coordinates.size.height.toDp() }
|
||||
}
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.onSizeChanged {
|
||||
videoPlaybackSize.value = it
|
||||
}
|
||||
) {
|
||||
val borders = MaterialTheme.colorScheme.imageModifier
|
||||
val borders = MaterialTheme.colorScheme.imageModifier
|
||||
|
||||
val myModifier = remember {
|
||||
if (roundedCorner) {
|
||||
val myModifier = remember {
|
||||
if (roundedCorner) {
|
||||
modifier.then(
|
||||
borders
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.defaultMinSize(minHeight = 75.dp)
|
||||
.align(Alignment.Center)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.align(Alignment.Center)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 75.dp)
|
||||
.align(Alignment.Center)
|
||||
}
|
||||
}
|
||||
|
||||
val factory = remember(controller) {
|
||||
{ context: Context ->
|
||||
PlayerView(context).apply {
|
||||
player = controller
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
resizeMode =
|
||||
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
controller.pause()
|
||||
innerOnDialog(it)
|
||||
val factory = remember(controller) {
|
||||
{ context: Context ->
|
||||
PlayerView(context).apply {
|
||||
player = controller
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
resizeMode =
|
||||
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
controller.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener { visible ->
|
||||
controllerVisible.value = visible == View.VISIBLE
|
||||
onControllerVisibilityChanged?.let { callback ->
|
||||
callback(visible == View.VISIBLE)
|
||||
}
|
||||
}
|
||||
setControllerVisibilityListener(
|
||||
PlayerView.ControllerVisibilityListener { visible ->
|
||||
controllerVisible.value = visible == View.VISIBLE
|
||||
onControllerVisibilityChanged?.let { callback ->
|
||||
callback(visible == View.VISIBLE)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory
|
||||
)
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory
|
||||
)
|
||||
|
||||
waveform?.let {
|
||||
Waveform(it, controller, remember { Modifier.align(Alignment.Center) })
|
||||
}
|
||||
waveform?.let {
|
||||
Waveform(it, controller, remember { Modifier.align(Alignment.Center) })
|
||||
}
|
||||
|
||||
val startingMuteState = remember(controller) {
|
||||
controller.volume < 0.001
|
||||
}
|
||||
val startingMuteState = remember(controller) {
|
||||
controller.volume < 0.001
|
||||
}
|
||||
|
||||
val spaceModifier =
|
||||
if (topPaddingForControllers.isSpecified && videoPlaybackSize.value.height > 0) {
|
||||
val space = (abs(parentVideoPlaybackSize.value.height - videoPlaybackSize.value.height) / 2).dp
|
||||
val topPadding = remember {
|
||||
derivedStateOf {
|
||||
if (topPaddingForControllers.isSpecified && videoPlaybackHeight.value.value > 0) {
|
||||
val space = (abs(this.maxHeight.value - videoPlaybackHeight.value.value) / 2).dp
|
||||
if (space > topPaddingForControllers) {
|
||||
Modifier
|
||||
Size0dp
|
||||
} else {
|
||||
Modifier.padding(top = topPaddingForControllers - space)
|
||||
topPaddingForControllers - space
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
Size0dp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MuteButton(
|
||||
controllerVisible,
|
||||
startingMuteState,
|
||||
spaceModifier
|
||||
) { mute: Boolean ->
|
||||
// makes the new setting the default for new creations.
|
||||
DefaultMutedSetting.value = mute
|
||||
MuteButton(
|
||||
controllerVisible,
|
||||
startingMuteState,
|
||||
topPadding
|
||||
) { mute: Boolean ->
|
||||
// makes the new setting the default for new creations.
|
||||
DefaultMutedSetting.value = mute
|
||||
|
||||
// if the user unmutes a video and it's not the current playing, switches to that one.
|
||||
if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
// if the user unmutes a video and it's not the current playing, switches to that one.
|
||||
if (!mute && keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
KeepPlayingButton(
|
||||
keepPlaying,
|
||||
controllerVisible,
|
||||
topPadding,
|
||||
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) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
}
|
||||
keepPlayingMutex = controller
|
||||
} else {
|
||||
if (keepPlayingMutex == controller) {
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
KeepPlayingButton(
|
||||
keepPlaying,
|
||||
controllerVisible,
|
||||
spaceModifier.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) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
}
|
||||
keepPlayingMutex = controller
|
||||
} else {
|
||||
if (keepPlayingMutex == controller) {
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
}
|
||||
|
||||
keepPlaying.value = newKeepPlaying
|
||||
}
|
||||
keepPlaying.value = newKeepPlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,7 +834,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
|
||||
private fun MuteButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
modifier: Modifier,
|
||||
topPadding: State<Dp>,
|
||||
toggle: (Boolean) -> Unit
|
||||
) {
|
||||
val holdOn = remember {
|
||||
@@ -836,9 +854,9 @@ private fun MuteButton(
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
modifier = modifier,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
modifier = Modifier.padding(top = topPadding.value),
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() }
|
||||
) {
|
||||
Box(modifier = VolumeBottomIconSize) {
|
||||
Box(
|
||||
@@ -870,6 +888,7 @@ private fun MuteButton(
|
||||
private fun KeepPlayingButton(
|
||||
keepPlayingStart: MutableState<Boolean>,
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
topPadding: State<Dp>,
|
||||
alignment: Modifier,
|
||||
toggle: (Boolean) -> Unit
|
||||
) {
|
||||
@@ -877,9 +896,9 @@ private fun KeepPlayingButton(
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = alignment,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
modifier = alignment.padding(top = topPadding.value),
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() }
|
||||
) {
|
||||
Box(modifier = PinBottomIconSize) {
|
||||
Box(
|
||||
|
||||
+154
-38
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -41,7 +40,6 @@ import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -62,11 +60,13 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -293,11 +293,13 @@ private fun LocalImageView(
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = maxHeight)
|
||||
/*
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
val contentScale = remember {
|
||||
@@ -305,7 +307,9 @@ private fun LocalImageView(
|
||||
}
|
||||
|
||||
val verifierModifier = if (topPaddingForControllers.isSpecified) {
|
||||
Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd)
|
||||
Modifier
|
||||
.padding(top = topPaddingForControllers)
|
||||
.align(Alignment.TopEnd)
|
||||
} else {
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
}
|
||||
@@ -373,7 +377,9 @@ private fun UrlImageView(
|
||||
}
|
||||
|
||||
val verifierModifier = if (topPaddingForControllers.isSpecified) {
|
||||
Modifier.padding(top = topPaddingForControllers).align(Alignment.TopEnd)
|
||||
Modifier
|
||||
.padding(top = topPaddingForControllers)
|
||||
.align(Alignment.TopEnd)
|
||||
} else {
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
}
|
||||
@@ -405,11 +411,58 @@ private fun UrlImageView(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ImageUrlWithDownloadButton(url: String, showImage: MutableState<Boolean>) {
|
||||
FlowRow() {
|
||||
ClickableUrl(urlText = url, url = url)
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val background = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
val regularText = remember { SpanStyle(color = background) }
|
||||
val clickableTextStyle = remember { SpanStyle(color = primary) }
|
||||
|
||||
val annotatedTermsString = remember {
|
||||
buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
append("$url ")
|
||||
}
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
}
|
||||
|
||||
withStyle(regularText) {
|
||||
append(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inlineContent = mapOf("inlineContent" to InlineDownloadIcon(showImage))
|
||||
|
||||
val pressIndicator = remember {
|
||||
Modifier.clickable {
|
||||
runCatching { uri.openUri(url) }
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedTermsString,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineDownloadIcon(showImage: MutableState<Boolean>) =
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(Size20dp),
|
||||
onClick = { showImage.value = true }
|
||||
@@ -417,7 +470,6 @@ fun ImageUrlWithDownloadButton(url: String, showImage: MutableState<Boolean>) {
|
||||
DownloadForOfflineIcon(Size24dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -548,43 +600,69 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
if (content is ZoomableUrlContent) {
|
||||
ClickableUrl(urlText = remember { "${content.url} " }, url = content.url)
|
||||
} else {
|
||||
Text("Loading content... ")
|
||||
}
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val background = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
val regularText = remember { SpanStyle(color = background) }
|
||||
val clickableTextStyle = remember { SpanStyle(color = primary) }
|
||||
|
||||
val annotatedTermsString = remember {
|
||||
buildAnnotatedString {
|
||||
if (content is ZoomableUrlContent) {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
append(content.url + " ")
|
||||
}
|
||||
} else {
|
||||
withStyle(regularText) {
|
||||
append("Loading content...")
|
||||
}
|
||||
}
|
||||
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToImage", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
}
|
||||
|
||||
withStyle(regularText) {
|
||||
append(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val inlineContent = mapOf("inlineContent" to InlineLoadingIcon())
|
||||
|
||||
val pressIndicator = remember {
|
||||
if (content is ZoomableUrlContent) {
|
||||
Modifier.clickable {
|
||||
runCatching { uri.openUri(content.url) }
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
val inlineContent = mapOf(
|
||||
Pair(
|
||||
myId,
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
text = annotatedTermsString,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InlineLoadingIcon() =
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = Font17SP,
|
||||
height = Font17SP,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlurHash(
|
||||
blurhash: String?,
|
||||
@@ -726,8 +804,8 @@ private fun DialogContent(
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -755,6 +833,44 @@ private fun DialogContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun InlineCarrousel(
|
||||
allImages: ImmutableList<String>,
|
||||
imageUrl: String
|
||||
) {
|
||||
val pagerState: PagerState = rememberPagerState() { allImages.size }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
launch {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState
|
||||
) { index ->
|
||||
AsyncImage(
|
||||
model = allImages[index],
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CopyToClipboard(
|
||||
content: ZoomableContent
|
||||
|
||||
@@ -3,12 +3,8 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
|
||||
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
class BookmarkPrivateFeedFilter(val account: Account) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().latestBookmarkList?.id ?: ""
|
||||
}
|
||||
@@ -16,42 +12,18 @@ object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
val bookmarks = account.userProfile().latestBookmarkList
|
||||
|
||||
if (account.loginWithExternalSigner) {
|
||||
val id = bookmarks?.id
|
||||
if (id != null) {
|
||||
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id]
|
||||
if (decryptedContent == null) {
|
||||
ExternalSignerUtils.decryptBookmark(
|
||||
bookmarks.content,
|
||||
account.keyPair.pubKey.toHexKey(),
|
||||
id
|
||||
)
|
||||
} else {
|
||||
bookmarks.decryptedContent = decryptedContent
|
||||
}
|
||||
}
|
||||
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id] ?: ""
|
||||
if (!account.isWriteable()) return emptyList()
|
||||
|
||||
val notes = bookmarks?.privateTaggedEvents(decryptedContent)
|
||||
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
|
||||
val addresses = bookmarks?.privateTaggedAddresses(decryptedContent)
|
||||
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
val privateTags = bookmarks?.cachedPrivateTags() ?: return emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
} else {
|
||||
val privKey = account.keyPair.privKey ?: return emptyList()
|
||||
val notes = bookmarks.filterEvents(privateTags)
|
||||
.mapNotNull { LocalCache.checkGetOrCreateNote(it) }
|
||||
|
||||
val notes = bookmarks?.privateTaggedEvents(privKey)
|
||||
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
|
||||
val addresses = bookmarks.filterAddresses(privateTags)
|
||||
.map { LocalCache.getOrCreateAddressableNote(it) }
|
||||
|
||||
val addresses = bookmarks?.privateTaggedAddresses(privKey)
|
||||
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object BookmarkPublicFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
class BookmarkPublicFeedFilter(val account: Account) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return BookmarkPrivateFeedFilter.account.userProfile().latestBookmarkList?.id ?: ""
|
||||
return account.userProfile().latestBookmarkList?.id ?: ""
|
||||
}
|
||||
override fun feed(): List<Note> {
|
||||
val bookmarks = account.userProfile().latestBookmarkList
|
||||
|
||||
@@ -7,16 +7,18 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultDiscoveryFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultDiscoveryFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultDiscoveryFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -33,12 +35,12 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingGeohashSet = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val createEvents = collection.filter { it.event is ChannelCreateEvent }
|
||||
val anyOtherChannelEvent = collection
|
||||
@@ -60,7 +62,7 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts = collection.associate {
|
||||
|
||||
@@ -7,16 +7,18 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultDiscoveryFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultDiscoveryFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultDiscoveryFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -33,12 +35,12 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingGeohashSet = account.selectedGeohashesFollowList(account.defaultDiscoveryFollowList) ?: emptySet()
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val createEvents = collection.filter { it.event is CommunityDefinitionEvent }
|
||||
val anyOtherCommunityEvent = collection
|
||||
@@ -60,16 +62,21 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts = collection.associate {
|
||||
it to counter.countFollowsThatParticipateOn(it, followingKeySet)
|
||||
}
|
||||
|
||||
val allParticipants = collection.associate {
|
||||
it to counter.countFollowsThatParticipateOn(it, null)
|
||||
}
|
||||
|
||||
return collection.sortedWith(
|
||||
compareBy(
|
||||
{ participantCounts[it] },
|
||||
{ allParticipants[it] },
|
||||
{ it.createdAt() },
|
||||
{ it.idHex }
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -20,11 +21,12 @@ open class DiscoverLiveFeedFilter(
|
||||
}
|
||||
|
||||
open fun followList(): String {
|
||||
return account.defaultDiscoveryFollowList
|
||||
return account.defaultDiscoveryFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -43,12 +45,12 @@ open class DiscoverLiveFeedFilter(
|
||||
|
||||
protected open fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultDiscoveryFollowList == GLOBAL_FOLLOWS
|
||||
val isGlobal = account.defaultDiscoveryFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(followList()) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(followList()) ?: emptySet()
|
||||
val followingGeohashSet = account.selectedGeohashesFollowList(followList()) ?: emptySet()
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveDiscoveryFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveDiscoveryFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val activities = collection
|
||||
.asSequence()
|
||||
@@ -68,17 +70,22 @@ open class DiscoverLiveFeedFilter(
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
val followingKeySet = account.selectedUsersFollowList(followList())
|
||||
val followingKeySet = account.liveDiscoveryFollowLists.value?.users ?: account.liveKind3Follows.value.users
|
||||
|
||||
val counter = ParticipantListBuilder()
|
||||
val participantCounts = collection.associate {
|
||||
it to counter.countFollowsThatParticipateOn(it, followingKeySet)
|
||||
}
|
||||
|
||||
val allParticipants = collection.associate {
|
||||
it to counter.countFollowsThatParticipateOn(it, null)
|
||||
}
|
||||
|
||||
return collection.sortedWith(
|
||||
compareBy(
|
||||
{ convertStatusToOrder((it.event as? LiveActivitiesEvent)?.status()) },
|
||||
{ participantCounts[it] },
|
||||
{ allParticipants[it] },
|
||||
{ (it.event as? LiveActivitiesEvent)?.starts() ?: it.createdAt() },
|
||||
{ it.idHex }
|
||||
)
|
||||
|
||||
@@ -14,21 +14,9 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
val blockList = account.getBlockList()
|
||||
val decryptedContent = blockList?.decryptedContent ?: ""
|
||||
if (account.loginWithExternalSigner) {
|
||||
if (decryptedContent.isEmpty()) return emptyList()
|
||||
|
||||
return blockList
|
||||
?.publicAndPrivateUsers(decryptedContent)
|
||||
?.map { LocalCache.getOrCreateUser(it) }
|
||||
?: emptyList()
|
||||
return account.flowHiddenUsers.value.hiddenUsers.map {
|
||||
LocalCache.getOrCreateUser(it)
|
||||
}
|
||||
|
||||
return blockList
|
||||
?.publicAndPrivateUsers(account.keyPair.privKey)
|
||||
?.map { LocalCache.getOrCreateUser(it) }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,19 +30,7 @@ class HiddenWordsFeedFilter(val account: Account) : FeedFilter<String>() {
|
||||
}
|
||||
|
||||
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()
|
||||
return account.flowHiddenUsers.value.hiddenWords.toList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
@@ -14,11 +15,12 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultHomeFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -30,12 +32,12 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
|
||||
val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingGeoHashSet = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
val now = TimeUtils.now()
|
||||
|
||||
@@ -43,7 +45,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent) &&
|
||||
(isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) ?: false || it.event?.isTaggedGeoHashes(followingGeoHashSet) ?: false) &&
|
||||
(isGlobal || it.author?.pubkeyHex in followingKeySet || it.event?.isTaggedHashes(followingTagSet) ?: false || it.event?.isTaggedGeoHashes(followingGeohashSet) ?: false) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
(isHiddenList || it.author?.let { !account.isHidden(it) } ?: true) &&
|
||||
((it.event?.createdAt() ?: 0) < now) &&
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
@@ -19,11 +20,12 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultHomeFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -38,14 +40,14 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>, ignoreAddressables: Boolean): Set<Note> {
|
||||
val isGlobal = account.defaultHomeFollowList == GLOBAL_FOLLOWS
|
||||
val isGlobal = account.defaultHomeFollowList.value == GLOBAL_FOLLOWS
|
||||
val gRelays = account.activeGlobalRelays()
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingGeoSet = account.selectedGeohashesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingCommunities = account.selectedCommunitiesFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingKeySet = account.liveHomeFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveHomeFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveHomeFollowLists.value?.geotags ?: emptySet()
|
||||
val followingCommunities = account.liveHomeFollowLists.value?.communities ?: emptySet()
|
||||
|
||||
val oneMinuteInTheFuture = TimeUtils.now() + (1 * 60) // one minute in the future.
|
||||
val oneHr = 60 * 60
|
||||
@@ -57,7 +59,7 @@ class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
val isGlobalRelay = it.relays.any { gRelays.contains(it) }
|
||||
(noteEvent is TextNoteEvent || noteEvent is ClassifiedsEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent || noteEvent is LongTextNoteEvent || noteEvent is PollNoteEvent || noteEvent is HighlightEvent || noteEvent is AudioTrackEvent || noteEvent is AudioHeaderEvent) &&
|
||||
(!ignoreAddressables || noteEvent.kind() < 10000) &&
|
||||
((isGlobal && isGlobalRelay) || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeoSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
|
||||
((isGlobal && isGlobalRelay) || it.author?.pubkeyHex in followingKeySet || noteEvent.isTaggedHashes(followingTagSet) || noteEvent.isTaggedGeoHashes(followingGeohashSet) || noteEvent.isTaggedAddressableNotes(followingCommunities)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
(isHiddenList || it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true) &&
|
||||
((it.event?.createdAt() ?: 0) < oneMinuteInTheFuture) &&
|
||||
|
||||
@@ -14,17 +14,19 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
|
||||
class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultNotificationFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultNotificationFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultNotificationFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -36,10 +38,10 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val isGlobal = account.defaultNotificationFollowList == GLOBAL_FOLLOWS
|
||||
val isGlobal = account.defaultNotificationFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = showHiddenKey()
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultNotificationFollowList) ?: emptySet()
|
||||
val followingKeySet = account.liveNotificationFollowLists.value?.users ?: emptySet()
|
||||
|
||||
val loggedInUser = account.userProfile()
|
||||
val loggedInUserHex = loggedInUser.pubkeyHex
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -16,14 +15,14 @@ class ThreadFeedFilter(val account: Account, val noteId: String) : FeedFilter<No
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val cachedSignatures: MutableMap<Note, Note.LevelSignature> = mutableMapOf()
|
||||
val followingSet = account.selectedUsersFollowList(KIND3_FOLLOWS) ?: emptySet()
|
||||
val followingKeySet = account.liveKind3Follows.value.users
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId)
|
||||
val eventsInHex = eventsToWatch.map { it.idHex }.toSet()
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
val order = compareByDescending<Note> {
|
||||
it.replyLevelSignature(eventsInHex, cachedSignatures, account.userProfile(), followingSet, now).signature
|
||||
it.replyLevelSignature(eventsInHex, cachedSignatures, account.userProfile(), followingKeySet, now).signature
|
||||
}
|
||||
|
||||
return eventsToWatch.sortedWith(order)
|
||||
|
||||
@@ -6,16 +6,18 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultStoriesFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
return account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -30,12 +32,13 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = TimeUtils.now()
|
||||
val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
|
||||
val isHiddenList = account.defaultStoriesFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
val isGlobal = account.defaultStoriesFollowList.value == GLOBAL_FOLLOWS
|
||||
val isHiddenList = account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
val followingGeohashSet = account.selectedGeohashesFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
val followingKeySet = account.liveStoriesFollowLists.value?.users ?: emptySet()
|
||||
val followingTagSet = account.liveStoriesFollowLists.value?.hashtags ?: emptySet()
|
||||
val followingGeohashSet = account.liveStoriesFollowLists.value?.geotags ?: emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
|
||||
@@ -30,14 +30,14 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size0dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@@ -86,9 +86,18 @@ fun keyboardAsState(): State<Keyboard> {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBottomBar(accountViewModel: AccountViewModel, navEntryState: State<NavBackStackEntry?>, nav: (Route, Boolean) -> Unit) {
|
||||
fun IfKeyboardClosed(
|
||||
inner: @Composable () -> Unit
|
||||
) {
|
||||
val isKeyboardOpen by keyboardAsState()
|
||||
if (isKeyboardOpen == Keyboard.Closed) {
|
||||
inner()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBottomBar(accountViewModel: AccountViewModel, navEntryState: State<NavBackStackEntry?>, nav: (Route, Boolean) -> Unit) {
|
||||
IfKeyboardClosed {
|
||||
RenderBottomMenu(accountViewModel, navEntryState, nav)
|
||||
}
|
||||
}
|
||||
@@ -124,30 +133,30 @@ private fun RowScope.HasNewItemsIcon(
|
||||
}
|
||||
}
|
||||
|
||||
val size = remember {
|
||||
if ("Home" == route.base) 25.dp else 23.dp
|
||||
}
|
||||
val iconSize = remember {
|
||||
if ("Home" == route.base) 24.dp else 20.dp
|
||||
}
|
||||
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
val hasNewItems = accountViewModel.notificationDots.hasNewItems[route]?.collectAsStateWithLifecycle()
|
||||
|
||||
NotifiableIcon(
|
||||
route.icon,
|
||||
size,
|
||||
iconSize,
|
||||
selected,
|
||||
hasNewItems
|
||||
)
|
||||
ObserveNewItems(route, accountViewModel) { hasNewItems ->
|
||||
NotifiableIcon(
|
||||
route.icon,
|
||||
route.notifSize,
|
||||
route.iconSize,
|
||||
selected,
|
||||
hasNewItems
|
||||
)
|
||||
}
|
||||
},
|
||||
selected = selected,
|
||||
onClick = { nav(route, selected) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveNewItems(route: Route, accountViewModel: AccountViewModel, inner: @Composable (hasNewItems: State<Boolean>?) -> Unit) {
|
||||
val hasNewItems = accountViewModel.notificationDots.hasNewItems[route]?.collectAsStateWithLifecycle()
|
||||
|
||||
inner(hasNewItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotifiableIcon(
|
||||
icon: Int,
|
||||
@@ -177,9 +186,7 @@ private fun NotificationDotIcon(modifier: Modifier) {
|
||||
Box(modifier.size(Size10dp)) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(Size10dp)
|
||||
.clip(shape = CircleShape)
|
||||
Size10Modifier.clip(shape = CircleShape)
|
||||
}.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
@@ -187,7 +194,7 @@ private fun NotificationDotIcon(modifier: Modifier) {
|
||||
"",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
fontSize = Font12SP,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.wrapContentHeight()
|
||||
|
||||
@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -177,6 +178,7 @@ private fun RenderTopRouteBar(
|
||||
Route.Community.base -> CommunityTopBar(id, accountViewModel, nav, navPopBack)
|
||||
Route.Hashtag.base -> HashTagTopBar(id, accountViewModel, navPopBack)
|
||||
Route.Geohash.base -> GeoHashTopBar(id, accountViewModel, navPopBack)
|
||||
Route.Note.base -> ThreadTopBar(id, accountViewModel, navPopBack)
|
||||
else -> MainTopBar(drawerState, accountViewModel, nav)
|
||||
}
|
||||
} else {
|
||||
@@ -186,6 +188,20 @@ private fun RenderTopRouteBar(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThreadTopBar(
|
||||
id: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
navPopBack: () -> Unit
|
||||
) {
|
||||
FlexibleTopBarWithBackButton(
|
||||
title = {
|
||||
Text(stringResource(id = R.string.thread_title))
|
||||
},
|
||||
popBack = navPopBack
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GeoHashTopBar(
|
||||
tag: String,
|
||||
@@ -373,7 +389,7 @@ fun NoTopBar() {
|
||||
@Composable
|
||||
fun StoriesTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
|
||||
val list by accountViewModel.storiesListLiveData.observeAsState(GLOBAL_FOLLOWS)
|
||||
val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithRoutes(
|
||||
followListsModel = followLists,
|
||||
@@ -387,7 +403,7 @@ fun StoriesTopBar(followLists: FollowListViewModel, drawerState: DrawerState, ac
|
||||
@Composable
|
||||
fun HomeTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
|
||||
val list by accountViewModel.homeListLiveData.observeAsState(KIND3_FOLLOWS)
|
||||
val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithRoutes(
|
||||
followListsModel = followLists,
|
||||
@@ -405,7 +421,7 @@ fun HomeTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accou
|
||||
@Composable
|
||||
fun NotificationTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
|
||||
val list by accountViewModel.notificationListLiveData.observeAsState(GLOBAL_FOLLOWS)
|
||||
val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithoutRoutes(
|
||||
followListsModel = followLists,
|
||||
@@ -419,7 +435,7 @@ fun NotificationTopBar(followLists: FollowListViewModel, drawerState: DrawerStat
|
||||
@Composable
|
||||
fun DiscoveryTopBar(followLists: FollowListViewModel, drawerState: DrawerState, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
GenericMainTopBar(drawerState, accountViewModel, nav) { accountViewModel ->
|
||||
val list by accountViewModel.discoveryListLiveData.observeAsState(GLOBAL_FOLLOWS)
|
||||
val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithoutRoutes(
|
||||
followListsModel = followLists,
|
||||
@@ -589,6 +605,11 @@ data class CodeName(val code: String, val name: Name, val type: CodeNameType)
|
||||
class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
val kind3Follow = CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows), CodeNameType.HARDCODED)
|
||||
val globalFollow = CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED)
|
||||
val muteListFollow = CodeName(
|
||||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex),
|
||||
ResourceName(R.string.follow_list_mute_list),
|
||||
CodeNameType.HARDCODED
|
||||
)
|
||||
|
||||
private var _kind3GlobalPeopleRoutes = MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
|
||||
val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.asStateFlow()
|
||||
@@ -634,13 +655,13 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
|
||||
val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() }
|
||||
|
||||
val kind3GlobalPeopleRouteList = listOf(listOf(kind3Follow, globalFollow), newFollowLists, routeList).flatten().toImmutableList()
|
||||
val kind3GlobalPeopleRouteList = listOf(listOf(kind3Follow, globalFollow), newFollowLists, routeList, listOf(muteListFollow)).flatten().toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(_kind3GlobalPeopleRoutes.value, kind3GlobalPeopleRouteList)) {
|
||||
_kind3GlobalPeopleRoutes.emit(kind3GlobalPeopleRouteList)
|
||||
}
|
||||
|
||||
val kind3GlobalPeopleList = listOf(listOf(kind3Follow, globalFollow), newFollowLists).flatten().toImmutableList()
|
||||
val kind3GlobalPeopleList = listOf(listOf(kind3Follow, globalFollow), newFollowLists, listOf(muteListFollow)).flatten().toImmutableList()
|
||||
|
||||
if (!equalImmutableLists(_kind3GlobalPeople.value, kind3GlobalPeopleList)) {
|
||||
_kind3GlobalPeople.emit(kind3GlobalPeopleList)
|
||||
@@ -656,7 +677,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
checkNotInMainThread()
|
||||
if (newNotes.any {
|
||||
it.event?.pubKey() == account.userProfile().pubkeyHex && (it.event is PeopleListEvent || it.event is ContactListEvent)
|
||||
it.event?.pubKey() == account.userProfile().pubkeyHex && (it.event is PeopleListEvent || it.event is MuteListEvent || it.event is ContactListEvent)
|
||||
}
|
||||
) {
|
||||
refresh()
|
||||
@@ -693,7 +714,7 @@ fun SimpleTextSpinner(
|
||||
id = R.string.select_an_option
|
||||
)
|
||||
|
||||
var currentText by remember(placeholderCode) {
|
||||
var currentText by remember(placeholderCode, options) {
|
||||
mutableStateOf(
|
||||
options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption
|
||||
)
|
||||
@@ -813,7 +834,7 @@ fun TopBarWithBackButton(caption: String, popBack: () -> Unit) {
|
||||
},
|
||||
actions = {}
|
||||
)
|
||||
Divider(thickness = 0.25.dp)
|
||||
Divider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,7 +856,7 @@ fun FlexibleTopBarWithBackButton(
|
||||
actions = {}
|
||||
)
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
Divider(thickness = 0.25.dp)
|
||||
Divider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -62,12 +61,8 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
@@ -77,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadStatuses
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
@@ -114,7 +110,7 @@ fun DrawerContent(
|
||||
nav
|
||||
)
|
||||
Divider(
|
||||
thickness = 0.25.dp,
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp)
|
||||
)
|
||||
ListContent(
|
||||
@@ -563,9 +559,7 @@ fun ListContent(
|
||||
conectOrbotDialogOpen = false
|
||||
disconnectTorDialog = false
|
||||
checked = true
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
enableTor(accountViewModel.account, true, proxyPort)
|
||||
}
|
||||
accountViewModel.enableTor(true, proxyPort)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(
|
||||
@@ -593,13 +587,7 @@ fun ListContent(
|
||||
onClick = {
|
||||
disconnectTorDialog = false
|
||||
checked = false
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
enableTor(
|
||||
accountViewModel.account,
|
||||
false,
|
||||
proxyPort
|
||||
)
|
||||
}
|
||||
accountViewModel.enableTor(false, proxyPort)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.yes))
|
||||
@@ -618,18 +606,6 @@ fun ListContent(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun enableTor(
|
||||
account: Account,
|
||||
checked: Boolean,
|
||||
portNumber: MutableState<String>
|
||||
) {
|
||||
account.proxyPort = portNumber.value.toInt()
|
||||
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
|
||||
LocalPreferences.saveToEncryptedStorage(account)
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(accountViewModel: AccountViewModel) {
|
||||
val connectedRelaysText by RelayPool.statusFlow.collectAsStateWithLifecycle(RelayPoolStatus(0, 0))
|
||||
@@ -761,7 +737,7 @@ fun BottomContent(user: User, drawerState: DrawerState, loadProfilePicture: Bool
|
||||
Column(modifier = Modifier) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 15.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDestination
|
||||
@@ -21,6 +22,10 @@ import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveNowFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size23dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.quartz.events.ChatroomKeyable
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -30,16 +35,18 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
@Immutable
|
||||
sealed class Route(
|
||||
val route: String,
|
||||
val base: String = route.substringBefore("?"),
|
||||
val icon: Int,
|
||||
val notifSize: Dp = Size23dp,
|
||||
val iconSize: Dp = Size20dp,
|
||||
val hasNewItems: (Account, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ -> false },
|
||||
val arguments: ImmutableList<NamedNavArgument> = persistentListOf()
|
||||
) {
|
||||
val base: String
|
||||
get() = route.substringBefore("?")
|
||||
|
||||
object Home : Route(
|
||||
route = "Home?nip47={nip47}",
|
||||
icon = R.drawable.ic_home,
|
||||
notifSize = Size25dp,
|
||||
iconSize = Size24dp,
|
||||
arguments = listOf(
|
||||
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
|
||||
).toImmutableList(),
|
||||
@@ -186,7 +193,8 @@ open class LatestItem {
|
||||
fun updateNewestItem(newNotes: Set<Note>, account: Account, filter: AdditiveFeedFilter<Note>): Note? {
|
||||
val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
|
||||
|
||||
if (newestItem == null) {
|
||||
// Block list got updated
|
||||
if (newestItem == null || !account.isAcceptable(newestItem)) {
|
||||
newestItemPerAccount = newestItemPerAccount + Pair(
|
||||
account.userProfile().pubkeyHex,
|
||||
filterMore(filter.feed(), account).firstOrNull { it.createdAt() != null }
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -161,7 +162,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
@@ -22,6 +21,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@@ -50,7 +51,7 @@ fun BlankNote(modifier: Modifier = Modifier, showDivider: Boolean = false, idHex
|
||||
if (!showDivider) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -110,7 +111,7 @@ fun HiddenNote(
|
||||
.buttonColors(
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
contentPadding = ButtonPadding
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_anyway), color = Color.White)
|
||||
}
|
||||
@@ -118,7 +119,7 @@ fun HiddenNote(
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
@@ -54,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
|
||||
@@ -418,16 +418,6 @@ fun RenderLiveActivityThumb(
|
||||
)
|
||||
)
|
||||
|
||||
var isOnline by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = card.media) {
|
||||
accountViewModel.checkIsOnline(card.media) { newIsOnline ->
|
||||
if (isOnline != newIsOnline) {
|
||||
isOnline = newIsOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
@@ -453,15 +443,20 @@ fun RenderLiveActivityThumb(
|
||||
}
|
||||
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
Crossfade(targetState = card.status) {
|
||||
Crossfade(targetState = card.status, label = "RenderLiveActivityThumb") {
|
||||
when (it) {
|
||||
STATUS_LIVE -> {
|
||||
if (card.media.isNullOrBlank()) {
|
||||
LiveFlag()
|
||||
} else if (isOnline) {
|
||||
val url = card.media
|
||||
if (url.isNullOrBlank()) {
|
||||
LiveFlag()
|
||||
} else {
|
||||
OfflineFlag()
|
||||
CheckIfUrlIsOnline(url, accountViewModel) { isOnline ->
|
||||
if (isOnline) {
|
||||
LiveFlag()
|
||||
} else {
|
||||
OfflineFlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
STATUS_ENDED -> {
|
||||
@@ -630,11 +625,11 @@ fun LoadModerators(
|
||||
}
|
||||
}
|
||||
|
||||
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
|
||||
val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users
|
||||
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
|
||||
|
||||
val newParticipantUsers = if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
|
||||
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
|
||||
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
|
||||
|
||||
(hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
|
||||
@@ -680,11 +675,12 @@ private fun LoadParticipants(
|
||||
} ?: emptyList<User>()
|
||||
)
|
||||
|
||||
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
|
||||
val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users
|
||||
|
||||
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hostsAuthor)
|
||||
|
||||
val newParticipantUsers = if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
|
||||
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
|
||||
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hostsAuthor)
|
||||
|
||||
(hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
|
||||
@@ -730,11 +726,11 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
|
||||
|
||||
LaunchedEffect(key1 = channelUpdates) {
|
||||
launch(Dispatchers.IO) {
|
||||
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
|
||||
val followingKeySet = accountViewModel.account.liveDiscoveryFollowLists.value?.users
|
||||
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).toImmutableList()
|
||||
|
||||
val newParticipantUsers = if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
|
||||
val allFollows = accountViewModel.account.userProfile().cachedFollowingKeySet()
|
||||
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).toList()
|
||||
|
||||
(followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
|
||||
@@ -819,7 +815,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Gallery(users: List<User>, accountViewModel: AccountViewModel) {
|
||||
fun Gallery(users: ImmutableList<User>, accountViewModel: AccountViewModel) {
|
||||
FlowRow(verticalArrangement = Arrangement.Center) {
|
||||
users.take(6).forEach {
|
||||
ClickableUserPicture(it, Size35dp, accountViewModel)
|
||||
|
||||
@@ -262,11 +262,6 @@ private fun UserRoomCompose(
|
||||
note.createdAt()
|
||||
}
|
||||
}
|
||||
val content by remember(note) {
|
||||
mutableStateOf(
|
||||
accountViewModel.decrypt(note)
|
||||
)
|
||||
}
|
||||
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
if (hasNewMessages.value != newHasNewMessages) {
|
||||
@@ -274,22 +269,24 @@ private fun UserRoomCompose(
|
||||
}
|
||||
}
|
||||
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
NonClickableUserPictures(
|
||||
users = room.users,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size55dp
|
||||
)
|
||||
},
|
||||
channelTitle = {
|
||||
RoomNameDisplay(room, it, accountViewModel)
|
||||
},
|
||||
channelLastTime = createAt,
|
||||
channelLastContent = content,
|
||||
hasNewMessages = hasNewMessages,
|
||||
onClick = { nav(route) }
|
||||
)
|
||||
LoadDecryptedContentOrNull(note, accountViewModel) { content ->
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
NonClickableUserPictures(
|
||||
users = room.users,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size55dp
|
||||
)
|
||||
},
|
||||
channelTitle = {
|
||||
RoomNameDisplay(room, it, accountViewModel)
|
||||
},
|
||||
channelLastTime = createAt,
|
||||
channelLastContent = content,
|
||||
hasNewMessages = hasNewMessages,
|
||||
onClick = { nav(route) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -58,8 +58,11 @@ import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
@@ -305,8 +308,8 @@ fun NormalChatNote(
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = modif2.onSizeChanged {
|
||||
if (availableBubbleSize.value != it.width) {
|
||||
availableBubbleSize.value = it.width
|
||||
if (availableBubbleSize.intValue != it.width) {
|
||||
availableBubbleSize.intValue = it.width
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -359,8 +362,8 @@ private fun RenderBubble(
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
|
||||
.onSizeChanged {
|
||||
if (bubbleSize.value != it.width) {
|
||||
bubbleSize.value = it.width
|
||||
if (bubbleSize.intValue != it.width) {
|
||||
bubbleSize.intValue = it.width
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,15 +433,30 @@ private fun MessageBubbleLines(
|
||||
|
||||
ConstrainedStatusRow(
|
||||
bubbleSize = bubbleSize,
|
||||
availableBubbleSize = availableBubbleSize
|
||||
) {
|
||||
StatusRow(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onWantsToReply = onWantsToReply
|
||||
)
|
||||
}
|
||||
availableBubbleSize = availableBubbleSize,
|
||||
firstColumn = {
|
||||
IncognitoBadge(baseNote)
|
||||
ChatTimeAgo(baseNote)
|
||||
RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
},
|
||||
secondColumn = {
|
||||
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
ReplyReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
showCounter = false,
|
||||
iconSize = Size15dp
|
||||
) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -524,58 +542,38 @@ private fun NoteRow(
|
||||
private fun ConstrainedStatusRow(
|
||||
bubbleSize: MutableState<Int>,
|
||||
availableBubbleSize: MutableState<Int>,
|
||||
content: @Composable () -> Unit
|
||||
firstColumn: @Composable () -> Unit,
|
||||
secondColumn: @Composable () -> Unit
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = with(LocalDensity.current) {
|
||||
Modifier
|
||||
.height(26.dp)
|
||||
.padding(top = 5.dp)
|
||||
.padding(top = Size5dp)
|
||||
.height(Size20dp)
|
||||
.widthIn(
|
||||
bubbleSize.value.toDp(),
|
||||
availableBubbleSize.value.toDp()
|
||||
)
|
||||
}
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
|
||||
IncognitoBadge(baseNote)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ChatTimeAgo(baseNote)
|
||||
RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
|
||||
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
ReplyReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
showCounter = false,
|
||||
iconSize = Size15dp
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat
|
||||
) {
|
||||
onWantsToReply(baseNote)
|
||||
firstColumn()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = ReactionRowHeightChat) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = ReactionRowHeightChat
|
||||
) {
|
||||
secondColumn()
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -591,6 +589,7 @@ fun IncognitoBadge(baseNote: Note) {
|
||||
.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
} else if (baseNote.event is PrivateDmEvent) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.incognito_off),
|
||||
@@ -600,6 +599,7 @@ fun IncognitoBadge(baseNote: Note) {
|
||||
.size(14.dp),
|
||||
tint = MaterialTheme.colorScheme.placeholderText
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,17 +630,28 @@ private fun RenderRegularTextNote(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
|
||||
val modifier = remember { Modifier.padding(top = 5.dp) }
|
||||
|
||||
if (eventContent != null) {
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
LoadDecryptedContentOrNull(note = note, accountViewModel = accountViewModel) { eventContent ->
|
||||
if (eventContent != null) {
|
||||
SensitivityWarning(
|
||||
note = note,
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent!!,
|
||||
canPreview = canPreview,
|
||||
modifier = modifier,
|
||||
tags = tags,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent!!,
|
||||
canPreview = canPreview,
|
||||
content = stringResource(id = R.string.could_not_decrypt_the_message),
|
||||
canPreview = true,
|
||||
modifier = modifier,
|
||||
tags = tags,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
@@ -648,16 +659,6 @@ private fun RenderRegularTextNote(
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
} else {
|
||||
TranslatableRichTextViewer(
|
||||
content = stringResource(id = R.string.could_not_decrypt_the_message),
|
||||
canPreview = true,
|
||||
modifier = modifier,
|
||||
tags = tags,
|
||||
backgroundColor = backgroundBubbleColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,7 +720,7 @@ private fun DrawAuthorInfo(
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
modifier = Modifier.padding(top = Size10dp)
|
||||
) {
|
||||
DisplayAndWatchNoteAuthor(baseNote, loadProfilePicture, nav)
|
||||
}
|
||||
|
||||
@@ -205,11 +205,6 @@ fun CommentIcon(iconSize: Dp, tint: Color) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ViewCountIcon(iconSize: Dp, tint: Color = Color.Unspecified) {
|
||||
ViewCountIcon(remember { Modifier.size(iconSize) }, tint)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ViewCountIcon(modifier: Modifier, tint: Color = Color.Unspecified) {
|
||||
Icon(
|
||||
|
||||
@@ -111,12 +111,8 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
|
||||
|
||||
val columnModifier = remember(backgroundColor.value) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor.value)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
@@ -125,7 +121,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
|
||||
},
|
||||
onLongClick = enablePopup
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = columnModifier) {
|
||||
@@ -311,7 +311,7 @@ fun MapZaps(
|
||||
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
LaunchedEffect(key1 = zaps) {
|
||||
accountViewModel.decryptAmountMessageInGroup(zaps) {
|
||||
zapEvents = it
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.BottomEnd
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -70,6 +71,7 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
@@ -113,6 +115,8 @@ import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CrossfadeCheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.JoinCommunityButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LeaveCommunityButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
@@ -186,6 +190,7 @@ import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_ENDED
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_PLANNED
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
@@ -308,7 +313,7 @@ fun CheckHiddenNoteCompose(
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState(accountViewModel.isNoteHidden(note))
|
||||
|
||||
Crossfade(targetState = isHidden) {
|
||||
Crossfade(targetState = isHidden, label = "CheckHiddenNoteCompose") {
|
||||
if (!it) {
|
||||
LoadedNoteCompose(
|
||||
note = note,
|
||||
@@ -348,17 +353,13 @@ fun LoadedNoteCompose(
|
||||
)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchForReports(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
state = newState
|
||||
}
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = state) {
|
||||
Crossfade(targetState = state, label = "LoadedNoteCompose") {
|
||||
RenderReportState(
|
||||
it,
|
||||
note,
|
||||
@@ -393,7 +394,7 @@ fun RenderReportState(
|
||||
) {
|
||||
var showReportedNote by remember { mutableStateOf(false) }
|
||||
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote) { showHiddenNote ->
|
||||
Crossfade(targetState = !state.isAcceptable && !showReportedNote, label = "RenderReportState") { showHiddenNote ->
|
||||
if (showHiddenNote) {
|
||||
HiddenNote(
|
||||
state.relevantReports,
|
||||
@@ -581,7 +582,7 @@ fun CommunityHeader(
|
||||
|
||||
if (showBottomDiviser) {
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -624,7 +625,7 @@ fun LongCommunityHeader(
|
||||
Column(
|
||||
Modifier.weight(1f)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
val defaultBackground = MaterialTheme.colorScheme.background
|
||||
val background = remember {
|
||||
mutableStateOf(defaultBackground)
|
||||
@@ -654,7 +655,7 @@ fun LongCommunityHeader(
|
||||
|
||||
Row(
|
||||
lineModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.owner),
|
||||
@@ -691,7 +692,7 @@ fun LongCommunityHeader(
|
||||
lineModifier.clickable {
|
||||
nav("User/${it.second.pubkeyHex}")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
it.first.role?.let { it1 ->
|
||||
Text(
|
||||
@@ -710,7 +711,7 @@ fun LongCommunityHeader(
|
||||
|
||||
Row(
|
||||
lineModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.created_at),
|
||||
@@ -733,7 +734,7 @@ fun ShortCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountVie
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
noteEvent.image()?.let {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = baseNote.idHex,
|
||||
@@ -752,7 +753,7 @@ fun ShortCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountVie
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Text(
|
||||
text = remember(noteState) { noteEvent.dTag() },
|
||||
maxLines = 1,
|
||||
@@ -765,7 +766,7 @@ fun ShortCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountVie
|
||||
modifier = Modifier
|
||||
.height(Size35dp)
|
||||
.padding(start = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
ShortCommunityActionOptions(baseNote, accountViewModel, nav)
|
||||
}
|
||||
@@ -1205,7 +1206,8 @@ private fun RenderNoteRow(
|
||||
RenderClassifieds(
|
||||
noteEvent,
|
||||
baseNote,
|
||||
accountViewModel
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1313,6 +1315,52 @@ fun authorRouteFor(note: Note): String {
|
||||
return "User/${note.author?.pubkeyHex}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadDecryptedContent(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (String) -> Unit
|
||||
) {
|
||||
var decryptedContent by remember(note.event) {
|
||||
mutableStateOf(
|
||||
accountViewModel.cachedDecrypt(note)
|
||||
)
|
||||
}
|
||||
|
||||
decryptedContent?.let {
|
||||
inner(it)
|
||||
} ?: run {
|
||||
LaunchedEffect(key1 = decryptedContent) {
|
||||
accountViewModel.decrypt(note) {
|
||||
decryptedContent = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadDecryptedContentOrNull(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (String?) -> Unit
|
||||
) {
|
||||
var decryptedContent by remember(note.event) {
|
||||
mutableStateOf(
|
||||
accountViewModel.cachedDecrypt(note)
|
||||
)
|
||||
}
|
||||
|
||||
if (decryptedContent == null) {
|
||||
LaunchedEffect(key1 = decryptedContent) {
|
||||
accountViewModel.decrypt(note) {
|
||||
decryptedContent = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner(decryptedContent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderTextEvent(
|
||||
note: Note,
|
||||
@@ -1322,18 +1370,19 @@ fun RenderTextEvent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val eventContent = remember(note.event) {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
val body = accountViewModel.decrypt(note)
|
||||
LoadDecryptedContent(note, accountViewModel) { body ->
|
||||
val eventContent by remember(note.event) {
|
||||
derivedStateOf {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
|
||||
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) {
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventContent != null) {
|
||||
val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) }
|
||||
|
||||
if (makeItShort && isAuthorTheLoggedUser) {
|
||||
@@ -1538,7 +1587,7 @@ fun RenderAppDefinition(
|
||||
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = remember { (note.event?.tags() ?: emptyList()).toImmutableListOfLists() },
|
||||
tags = remember { (note.event?.tags() ?: emptyArray()).toImmutableListOfLists() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 25.sp
|
||||
)
|
||||
@@ -1547,7 +1596,7 @@ fun RenderAppDefinition(
|
||||
|
||||
val website = remember(it) { it.website }
|
||||
if (!website.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
|
||||
ClickableText(
|
||||
@@ -1631,17 +1680,16 @@ private fun RenderPrivateMessage(
|
||||
|
||||
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
|
||||
if (withMe) {
|
||||
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
|
||||
val hashtags = remember(note.event?.id()) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
|
||||
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
|
||||
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
|
||||
LoadDecryptedContent(note, accountViewModel) { eventContent ->
|
||||
val hashtags = remember(note.event?.id()) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
|
||||
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
|
||||
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
|
||||
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
if (eventContent != null) {
|
||||
if (makeItShort && isAuthorTheLoggedUser) {
|
||||
Text(
|
||||
text = eventContent!!,
|
||||
text = eventContent,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@@ -1652,7 +1700,7 @@ private fun RenderPrivateMessage(
|
||||
accountViewModel = accountViewModel
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = eventContent!!,
|
||||
content = eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
modifier = modifier,
|
||||
tags = tags,
|
||||
@@ -1662,7 +1710,7 @@ private fun RenderPrivateMessage(
|
||||
)
|
||||
}
|
||||
|
||||
DisplayUncitedHashtags(hashtags, eventContent!!, nav)
|
||||
DisplayUncitedHashtags(hashtags, eventContent, nav)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1768,7 +1816,7 @@ fun DisplayRelaySet(
|
||||
|
||||
if (relays.size > 3 && !expanded) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -1872,7 +1920,7 @@ fun DisplayPeopleList(
|
||||
|
||||
if (members.size > 3 && !expanded) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -1916,7 +1964,7 @@ private fun RenderBadgeAward(
|
||||
.clickable {
|
||||
nav("User/${user.pubkeyHex}")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
ClickableUserPicture(
|
||||
baseUser = user,
|
||||
@@ -2184,7 +2232,7 @@ public fun RenderEmojiPack(
|
||||
|
||||
if (allEmojis.size > 60 && !expanded) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -2220,19 +2268,14 @@ private fun EmojiListOptions(
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState()
|
||||
|
||||
Crossfade(targetState = hasAddedThis) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Crossfade(targetState = hasAddedThis, label = "EmojiListOptions") {
|
||||
if (it != true) {
|
||||
AddButton() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote)
|
||||
}
|
||||
accountViewModel.addEmojiPack(usersEmojiList, emojiPackNote)
|
||||
}
|
||||
} else {
|
||||
RemoveButton {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote)
|
||||
}
|
||||
accountViewModel.removeEmojiPack(usersEmojiList, emojiPackNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2334,7 +2377,7 @@ fun RenderPinListEvent(
|
||||
|
||||
if (pins.size > 3 && !expanded) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -2549,21 +2592,27 @@ fun SecondUserInfoRow(
|
||||
val noteEvent = remember { note.event } ?: return
|
||||
val noteAuthor = remember { note.author } ?: return
|
||||
|
||||
Row(verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = UserNameMaxRowHeight
|
||||
) {
|
||||
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) }, accountViewModel, nav)
|
||||
|
||||
val geo = remember { noteEvent.getGeoHash() }
|
||||
if (geo != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayLocation(geo, nav)
|
||||
}
|
||||
|
||||
val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } }
|
||||
if (baseReward != null) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayReward(baseReward, note, accountViewModel, nav)
|
||||
}
|
||||
|
||||
val pow = remember { noteEvent.getPoWRank() }
|
||||
if (pow > 20) {
|
||||
Spacer(StdHorzSpacer)
|
||||
DisplayPoW(pow)
|
||||
}
|
||||
}
|
||||
@@ -2861,21 +2910,16 @@ private fun RepostNoteAuthorPicture(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
Box(modifier = Size55Modifier) {
|
||||
Box(Size35Modifier.align(Alignment.TopStart)) {
|
||||
GenericRepostSection(
|
||||
baseAuthorPicture = {
|
||||
NoteAuthorPicture(
|
||||
baseNote = baseNote,
|
||||
nav = nav,
|
||||
accountViewModel = accountViewModel,
|
||||
size = Size34dp
|
||||
)
|
||||
}
|
||||
|
||||
Box(Size18Modifier.align(Alignment.BottomStart).padding(1.dp)) {
|
||||
RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
|
||||
Box(Size35Modifier.align(Alignment.BottomEnd)) {
|
||||
},
|
||||
repostAuthorPicture = {
|
||||
NoteAuthorPicture(
|
||||
baseNote = baseRepost,
|
||||
nav = nav,
|
||||
@@ -2883,6 +2927,45 @@ private fun RepostNoteAuthorPicture(
|
||||
size = Size34dp
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun GenericRepostSectionPreview() {
|
||||
GenericRepostSection(
|
||||
baseAuthorPicture = {
|
||||
Text("ab")
|
||||
},
|
||||
repostAuthorPicture = {
|
||||
Text("cd")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenericRepostSection(
|
||||
baseAuthorPicture: @Composable () -> Unit,
|
||||
repostAuthorPicture: @Composable () -> Unit
|
||||
) {
|
||||
Box(modifier = Size55Modifier) {
|
||||
Box(remember { Size35Modifier.align(Alignment.TopStart) }) {
|
||||
baseAuthorPicture()
|
||||
}
|
||||
|
||||
Box(
|
||||
remember {
|
||||
Size18Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(1.dp)
|
||||
}
|
||||
) {
|
||||
RepostedIcon(modifier = Size18Modifier, MaterialTheme.colorScheme.placeholderText)
|
||||
}
|
||||
|
||||
Box(remember { Size35Modifier.align(Alignment.BottomEnd) }, contentAlignment = BottomEnd) {
|
||||
repostAuthorPicture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2919,6 +3002,7 @@ fun DisplayHighlight(
|
||||
DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DisplayQuoteAuthor(
|
||||
authorHex: String,
|
||||
@@ -2938,7 +3022,7 @@ private fun DisplayQuoteAuthor(
|
||||
}
|
||||
|
||||
MeasureSpaceWidth {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(it), verticalAlignment = Alignment.CenterVertically) {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(it), verticalArrangement = Arrangement.Center) {
|
||||
userBase?.let { userBase ->
|
||||
LoadAndDisplayUser(userBase, nav)
|
||||
}
|
||||
@@ -3181,7 +3265,7 @@ fun DisplayReward(
|
||||
|
||||
Column() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.clickable { popupExpanded = true }
|
||||
) {
|
||||
ClickableText(
|
||||
@@ -3488,7 +3572,7 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Row() {
|
||||
subject?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) {
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)) {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -3502,7 +3586,7 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A
|
||||
|
||||
participantUsers.forEach {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
|
||||
.clickable {
|
||||
@@ -3525,7 +3609,7 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A
|
||||
|
||||
media?.let { media ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
cover?.let { cover ->
|
||||
LoadThumbAndThenVideoView(
|
||||
@@ -3559,24 +3643,13 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
|
||||
|
||||
val defaultBackground = MaterialTheme.colorScheme.background
|
||||
val background = remember { mutableStateOf(defaultBackground) }
|
||||
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
val eventContent = remember(note.event) {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
||||
val body = accountViewModel.decrypt(note)
|
||||
|
||||
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
val tags = remember(noteEvent) { noteEvent.tags()?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
|
||||
Row(modifier = Modifier.padding(top = 5.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
media?.let { media ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
@@ -3608,7 +3681,7 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = CenterVertically) {
|
||||
val hashtags = remember(noteEvent) { noteEvent.hashtags().toImmutableList() }
|
||||
DisplayUncitedHashtags(hashtags, content ?: "", nav)
|
||||
}
|
||||
@@ -3642,16 +3715,6 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
val status = remember(eventUpdates) { noteEvent.status() }
|
||||
val starts = remember(eventUpdates) { noteEvent.starts() }
|
||||
|
||||
var isOnline by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = media) {
|
||||
accountViewModel.checkIsOnline(media) { newIsOnline ->
|
||||
if (isOnline != newIsOnline) {
|
||||
isOnline = newIsOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -3670,11 +3733,13 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
|
||||
Crossfade(targetState = status) {
|
||||
Crossfade(targetState = status, label = "RenderLiveActivityEventInner") {
|
||||
when (it) {
|
||||
STATUS_LIVE -> {
|
||||
if (isOnline) {
|
||||
LiveFlag()
|
||||
media?.let {
|
||||
CrossfadeCheckIfUrlIsOnline(it, accountViewModel) {
|
||||
LiveFlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
STATUS_PLANNED -> {
|
||||
@@ -3700,7 +3765,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
|
||||
participantUsers.forEach {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(top = 5.dp, start = 10.dp, end = 10.dp)
|
||||
.clickable {
|
||||
@@ -3723,37 +3788,39 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
|
||||
|
||||
media?.let { media ->
|
||||
if (status == STATUS_LIVE) {
|
||||
if (isOnline) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
title = subject,
|
||||
artworkUri = cover,
|
||||
authorName = baseNote.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = "nostr:${baseNote.toNEvent()}"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.height(100.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_is_offline),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
CheckIfUrlIsOnline(media, accountViewModel) { isOnline ->
|
||||
if (isOnline) {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = media,
|
||||
title = subject,
|
||||
artworkUri = cover,
|
||||
authorName = baseNote.author?.toBestDisplayName(),
|
||||
roundedCorner = true,
|
||||
accountViewModel = accountViewModel,
|
||||
nostrUriCallback = "nostr:${baseNote.toNEvent()}"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.height(100.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.live_stream_is_offline),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (status == "ended") {
|
||||
} else if (status == STATUS_ENDED) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.height(100.dp)
|
||||
@@ -3830,7 +3897,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountViewModel: AccountViewModel) {
|
||||
private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val image = remember(noteEvent) { noteEvent.image() }
|
||||
val title = remember(noteEvent) { noteEvent.title() }
|
||||
val summary = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
|
||||
@@ -3861,24 +3928,29 @@ private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountVi
|
||||
} ?: CreateImageHeader(note, accountViewModel)
|
||||
}
|
||||
|
||||
Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp), verticalAlignment = CenterVertically) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
price?.let {
|
||||
val priceTag = remember(noteEvent) {
|
||||
val newAmount = price.amount.toBigDecimalOrNull()?.let {
|
||||
showAmount(it)
|
||||
} ?: price.amount
|
||||
|
||||
if (price.frequency != null && price.currency != null) {
|
||||
"${price.amount} ${price.currency}/${price.frequency}"
|
||||
"$newAmount ${price.currency}/${price.frequency}"
|
||||
} else if (price.currency != null) {
|
||||
"${price.amount} ${price.currency}"
|
||||
"$newAmount ${price.currency}"
|
||||
} else {
|
||||
price.amount
|
||||
newAmount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3890,7 +3962,6 @@ private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountVi
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.clip(SmallBorder)
|
||||
.background(Color.Black)
|
||||
.padding(start = 5.dp)
|
||||
}
|
||||
)
|
||||
@@ -3898,7 +3969,7 @@ private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountVi
|
||||
}
|
||||
|
||||
if (summary != null || location != null) {
|
||||
Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp), verticalAlignment = CenterVertically) {
|
||||
summary?.let {
|
||||
Text(
|
||||
text = it,
|
||||
@@ -3911,16 +3982,46 @@ private fun RenderClassifieds(noteEvent: ClassifiedsEvent, note: Note, accountVi
|
||||
)
|
||||
}
|
||||
|
||||
location?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 5.dp)
|
||||
)
|
||||
/*
|
||||
Column {
|
||||
location?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = {
|
||||
note.author?.let {
|
||||
accountViewModel.createChatRoomFor(it) {
|
||||
nav("Room/$it")
|
||||
}
|
||||
}
|
||||
},
|
||||
contentPadding = ZeroPadding,
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_dm),
|
||||
stringResource(R.string.send_a_direct_message),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -99,7 +100,7 @@ val externalLinkForNote = { note: Note ->
|
||||
if (note.event is FileHeaderEvent) {
|
||||
"https://filestr.vercel.app/e/${note.toNEvent()}"
|
||||
} else {
|
||||
"https://snort.social/e/${note.toNEvent()}"
|
||||
"https://njump.me/${note.toNEvent()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,8 +144,21 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
|
||||
}
|
||||
|
||||
if (showSelectTextDialog.value) {
|
||||
accountViewModel.decrypt(note)?.let {
|
||||
SelectTextDialog(it) { showSelectTextDialog.value = false }
|
||||
val decryptedNote = remember {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountViewModel.decrypt(note) {
|
||||
decryptedNote.value = it
|
||||
}
|
||||
}
|
||||
|
||||
decryptedNote.value?.let {
|
||||
SelectTextDialog(it) {
|
||||
showSelectTextDialog.value = false
|
||||
decryptedNote.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,15 +230,12 @@ private fun RenderMainPopup(
|
||||
icon = Icons.Default.ContentCopy,
|
||||
label = stringResource(R.string.quick_action_copy_text)
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(
|
||||
AnnotatedString(
|
||||
accountViewModel.decrypt(note) ?: ""
|
||||
)
|
||||
)
|
||||
accountViewModel.decrypt(note) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
showToast(R.string.copied_note_text_to_clipboard)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
}
|
||||
VerticalDivider(primaryLight)
|
||||
NoteQuickActionItem(
|
||||
@@ -278,10 +289,8 @@ private fun RenderMainPopup(
|
||||
stringResource(R.string.quick_action_delete)
|
||||
) {
|
||||
if (accountViewModel.hideDeleteRequestDialog) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
}
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
} else {
|
||||
showDeleteAlertDialog.value = true
|
||||
}
|
||||
@@ -380,24 +389,18 @@ fun NoteQuickActionItem(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
fun DeleteAlertDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
QuickActionAlertDialog(
|
||||
title = stringResource(R.string.quick_action_request_deletion_alert_title),
|
||||
textContent = stringResource(R.string.quick_action_request_deletion_alert_body),
|
||||
buttonIcon = Icons.Default.Delete,
|
||||
buttonText = stringResource(R.string.quick_action_delete_dialog_btn),
|
||||
onClickDoOnce = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.delete(note)
|
||||
}
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
},
|
||||
onClickDontShowAgain = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.dontShowDeleteRequestDialog()
|
||||
}
|
||||
accountViewModel.delete(note)
|
||||
accountViewModel.dontShowDeleteRequestDialog()
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = onDismiss
|
||||
|
||||
@@ -335,7 +335,7 @@ fun ZapVote(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false, radius = 24.dp),
|
||||
onClick = {
|
||||
if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_send_zaps
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.events.CLOSED_AT
|
||||
import com.vitorpamplona.quartz.events.CONSENSUS_THRESHOLD
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.VALUE_MAXIMUM
|
||||
import com.vitorpamplona.quartz.events.VALUE_MINIMUM
|
||||
@@ -68,10 +69,13 @@ class PollNoteViewModel : ViewModel() {
|
||||
fun refreshTallies() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
wasZappedByLoggedInAccount = false
|
||||
account?.calculateIfNoteWasZappedByAccount(pollNote) {
|
||||
wasZappedByLoggedInAccount = true
|
||||
}
|
||||
|
||||
val newOptions = pollOptions?.keys?.map {
|
||||
val zappedInOption = zappedPollOptionAmount(it)
|
||||
val newOptions = pollOptions?.keys?.map { option ->
|
||||
val zappedInOption = zappedPollOptionAmount(option)
|
||||
|
||||
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
@@ -79,11 +83,11 @@ class PollNoteViewModel : ViewModel() {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false
|
||||
val cachedZappedByLoggedIn = account?.userProfile()?.let { it1 -> cachedIsPollOptionZappedBy(option, it1) } ?: false
|
||||
|
||||
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
|
||||
|
||||
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
|
||||
PollOption(option, pollOptions?.get(option) ?: "", zappedInOption, myTally, consensus, cachedZappedByLoggedIn)
|
||||
}
|
||||
|
||||
_tallies.emit(
|
||||
@@ -166,11 +170,15 @@ class PollNoteViewModel : ViewModel() {
|
||||
return false
|
||||
}
|
||||
|
||||
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
|
||||
fun isPollOptionZappedBy(option: Int, user: User, onWasZappedByAuthor: () -> Unit) {
|
||||
pollNote?.isZappedBy(option, user, account!!, onWasZappedByAuthor)
|
||||
}
|
||||
|
||||
fun cachedIsPollOptionZappedBy(option: Int, user: User): Boolean {
|
||||
return pollNote!!.zaps
|
||||
.any {
|
||||
val zapEvent = it.value?.event as? LnZapEvent
|
||||
val privateZapAuthor = account?.decryptZapContentAuthor(it.key)
|
||||
val privateZapAuthor = (it.key.event as? LnZapRequestEvent)?.cachedPrivateZap()
|
||||
zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
@@ -13,6 +12,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.with
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -106,16 +107,17 @@ import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderTextColorFilter
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.DecimalFormat
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@Composable
|
||||
fun ReactionsRow(
|
||||
@@ -149,91 +151,81 @@ private fun InnerReactionRow(
|
||||
wantsToSeeReactions: MutableState<Boolean>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
GenericInnerReactionRow(
|
||||
showReactionDetail = showReactionDetail,
|
||||
one = {
|
||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) {
|
||||
RenderShowIndividualReactionsButton(wantsToSeeReactions)
|
||||
}
|
||||
},
|
||||
two = {
|
||||
ReplyReactionWithDialog(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
},
|
||||
three = {
|
||||
BoostWithDialog(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
},
|
||||
four = {
|
||||
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
},
|
||||
five = {
|
||||
ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
},
|
||||
six = {
|
||||
ViewCountReaction(
|
||||
note = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
viewCountColorFilter = MaterialTheme.colorScheme.placeholderTextColorFilter
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenericInnerReactionRow(
|
||||
showReactionDetail: Boolean,
|
||||
one: @Composable () -> Unit,
|
||||
two: @Composable () -> Unit,
|
||||
three: @Composable () -> Unit,
|
||||
four: @Composable () -> Unit,
|
||||
five: @Composable () -> Unit,
|
||||
six: @Composable () -> Unit
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically, modifier = ReactionRowHeight) {
|
||||
val fullWeight = remember { Modifier.weight(1f) }
|
||||
|
||||
if (showReactionDetail) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = ReactionRowExpandButton
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = remember { ReactionRowExpandButton.then(fullWeight) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) {
|
||||
RenderShowIndividualReactionsButton(wantsToSeeReactions)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reactions Button: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
one()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ReplyReactionWithDialog(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Reply: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
Row(verticalAlignment = CenterVertically, modifier = fullWeight) {
|
||||
two()
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
BoostWithDialog(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Boost: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
Row(verticalAlignment = CenterVertically, modifier = fullWeight) {
|
||||
three()
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Likes: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
Row(verticalAlignment = CenterVertically, modifier = fullWeight) {
|
||||
four()
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
Row(verticalAlignment = CenterVertically, modifier = fullWeight) {
|
||||
five()
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ViewCountReaction(
|
||||
note = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
viewCountColorFilter = MaterialTheme.colorScheme.placeholderTextColorFilter
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Views: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
Row(verticalAlignment = CenterVertically, modifier = fullWeight) {
|
||||
six()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,9 +282,11 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (details) 24.dp else 4.dp),
|
||||
modifier = remember(details) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (details) 24.dp else 4.dp)
|
||||
},
|
||||
color = color,
|
||||
progress = zapraiserStatus.progress
|
||||
)
|
||||
@@ -372,7 +366,7 @@ private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableStat
|
||||
},
|
||||
modifier = Size20Modifier
|
||||
) {
|
||||
Crossfade(targetState = wantsToSeeReactions.value) {
|
||||
Crossfade(targetState = wantsToSeeReactions.value, label = "RenderShowIndividualReactionsButton") {
|
||||
if (it) {
|
||||
ExpandLessIcon(modifier = Size22Modifier)
|
||||
} else {
|
||||
@@ -547,14 +541,10 @@ fun ReplyReaction(
|
||||
if (accountViewModel.isWriteable()) {
|
||||
onPress()
|
||||
} else {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
onPress()
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_reply
|
||||
)
|
||||
}
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_reply
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -573,17 +563,12 @@ fun ReplyCounter(baseNote: Note, textColor: Color) {
|
||||
SlidingAnimationCount(repliesState, textColor)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlidingAnimationCount(baseCount: MutableState<Int>, textColor: Color) {
|
||||
SlidingAnimationCount(baseCount.value, textColor)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
private fun SlidingAnimationCount(baseCount: Int, textColor: Color) {
|
||||
AnimatedContent<Int>(
|
||||
targetState = baseCount,
|
||||
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec
|
||||
transitionSpec = AnimatedContentTransitionScope<Int>::transitionSpec,
|
||||
label = "SlidingAnimationCount"
|
||||
) { count ->
|
||||
TextCount(count, textColor)
|
||||
}
|
||||
@@ -595,11 +580,16 @@ private fun <S> AnimatedContentTransitionScope<S>.transitionSpec(): ContentTrans
|
||||
}
|
||||
|
||||
@ExperimentalAnimationApi
|
||||
val slideAnimation: ContentTransform = slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + fadeIn(
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
) with slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + fadeOut(
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
)
|
||||
val slideAnimation: ContentTransform =
|
||||
(
|
||||
slideInVertically(animationSpec = tween(durationMillis = 100)) { height -> height } + fadeIn(
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
)
|
||||
).togetherWith(
|
||||
slideOutVertically(animationSpec = tween(durationMillis = 100)) { height -> -height } + fadeOut(
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun TextCount(count: Int, textColor: Color) {
|
||||
@@ -613,11 +603,11 @@ private fun TextCount(count: Int, textColor: Color) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
private fun SlidingAnimationAmount(amount: MutableState<String>, textColor: Color) {
|
||||
AnimatedContent(
|
||||
targetState = amount.value,
|
||||
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec
|
||||
transitionSpec = AnimatedContentTransitionScope<String>::transitionSpec,
|
||||
label = "SlidingAnimationAmount"
|
||||
) { count ->
|
||||
Text(
|
||||
text = count,
|
||||
@@ -645,29 +635,14 @@ fun BoostReaction(
|
||||
IconButton(
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.hasBoosted(baseNote)) {
|
||||
accountViewModel.deleteBoostsTo(baseNote)
|
||||
} else {
|
||||
wantsToBoost = true
|
||||
}
|
||||
} else {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
if (accountViewModel.hasBoosted(baseNote)) {
|
||||
accountViewModel.deleteBoostsTo(baseNote)
|
||||
} else {
|
||||
wantsToBoost = true
|
||||
}
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_boost_posts
|
||||
)
|
||||
}
|
||||
accountViewModel.tryBoost(baseNote) {
|
||||
wantsToBoost = true
|
||||
}
|
||||
}
|
||||
) {
|
||||
BoostIcon(baseNote, iconSize, grayTint, accountViewModel)
|
||||
ObserveBoostIcon(baseNote, accountViewModel) { hasBoosted ->
|
||||
RepostedIcon(iconButtonModifier, if (hasBoosted) Color.Unspecified else grayTint)
|
||||
}
|
||||
|
||||
if (wantsToBoost) {
|
||||
BoostTypeChoicePopup(
|
||||
@@ -692,18 +667,16 @@ fun BoostReaction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountViewModel: AccountViewModel) {
|
||||
val iconTint by remember(baseNote) {
|
||||
fun ObserveBoostIcon(baseNote: Note, accountViewModel: AccountViewModel, inner: @Composable (Boolean) -> Unit) {
|
||||
val hasBoosted by remember(baseNote) {
|
||||
baseNote.live().boosts.map {
|
||||
if (it.note.isBoostedBy(accountViewModel.userProfile())) Color.Unspecified else grayTint
|
||||
it.note.isBoostedBy(accountViewModel.userProfile())
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState(grayTint)
|
||||
}.observeAsState(
|
||||
baseNote.isBoostedBy(accountViewModel.userProfile())
|
||||
)
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
RepostedIcon(iconModifier, iconTint)
|
||||
inner(hasBoosted)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -758,10 +731,20 @@ fun LikeReaction(
|
||||
}
|
||||
)
|
||||
) {
|
||||
LikeIcon(baseNote, iconFontSize, heartSize, grayTint, accountViewModel)
|
||||
ObserveLikeIcon(baseNote, accountViewModel) { reactionType ->
|
||||
Crossfade(targetState = reactionType.value, label = "LikeIcon") {
|
||||
if (it != null) {
|
||||
RenderReactionType(it, heartSize, iconFontSize)
|
||||
} else {
|
||||
LikeIcon(heartSize, grayTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LikeText(baseNote, grayTint)
|
||||
ObserveLikeText(baseNote) { reactionCount ->
|
||||
SlidingAnimationCount(reactionCount, grayTint)
|
||||
}
|
||||
|
||||
if (wantsToChangeReactionSymbol) {
|
||||
UpdateReactionTypeDialog(
|
||||
@@ -788,44 +771,26 @@ fun LikeReaction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LikeIcon(
|
||||
fun ObserveLikeIcon(
|
||||
baseNote: Note,
|
||||
iconFontSize: TextUnit = Font14SP,
|
||||
iconSize: Dp = Size20dp,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (MutableState<String?>) -> Unit
|
||||
) {
|
||||
val reactionType = remember(baseNote) {
|
||||
mutableStateOf<String?>(null)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
|
||||
WatchReactionTypeForNote(baseNote, accountViewModel) { newReactionType ->
|
||||
if (reactionType.value != newReactionType) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
LaunchedEffect(key1 = reactionsState) {
|
||||
accountViewModel.loadReactionTo(reactionsState?.note) { newReactionType ->
|
||||
if (reactionType.value != newReactionType) {
|
||||
reactionType.value = newReactionType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = reactionType) {
|
||||
val value = it.value
|
||||
if (value != null) {
|
||||
RenderReactionType(value, iconSize, iconFontSize)
|
||||
} else {
|
||||
LikeIcon(iconSize, grayTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountViewModel, onNewReactionType: (String?) -> Unit) {
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = reactionsState) {
|
||||
accountViewModel.loadReactionTo(reactionsState?.note, onNewReactionType)
|
||||
}
|
||||
inner(reactionType)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -834,13 +799,16 @@ private fun RenderReactionType(
|
||||
iconSize: Dp = Size20dp,
|
||||
iconFontSize: TextUnit
|
||||
) {
|
||||
if (reactionType.startsWith(":")) {
|
||||
val noStartColon = reactionType.removePrefix(":")
|
||||
val url = noStartColon.substringAfter(":")
|
||||
val isCustom = remember(reactionType) {
|
||||
reactionType.startsWith(":")
|
||||
}
|
||||
|
||||
val renderable = listOf(
|
||||
ImageUrlType(url)
|
||||
).toImmutableList()
|
||||
if (isCustom) {
|
||||
val renderable = remember(reactionType) {
|
||||
listOf(
|
||||
ImageUrlType(reactionType.removePrefix(":").substringAfter(":"))
|
||||
).toImmutableList()
|
||||
}
|
||||
|
||||
InLineIconRenderer(
|
||||
renderable,
|
||||
@@ -858,10 +826,10 @@ private fun RenderReactionType(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LikeText(baseNote: Note, grayTint: Color) {
|
||||
fun ObserveLikeText(baseNote: Note, inner: @Composable (Int) -> Unit) {
|
||||
val reactionCount by baseNote.live().reactionCount.observeAsState(0)
|
||||
|
||||
SlidingAnimationCount(reactionCount, grayTint)
|
||||
inner(reactionCount)
|
||||
}
|
||||
|
||||
private fun likeClick(
|
||||
@@ -876,14 +844,10 @@ private fun likeClick(
|
||||
R.string.no_reaction_type_setup_long_press_to_change
|
||||
)
|
||||
} else if (!accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
onWantsToSignReaction()
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_like_posts
|
||||
)
|
||||
}
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_like_posts
|
||||
)
|
||||
} else if (accountViewModel.account.reactionChoices.size == 1) {
|
||||
accountViewModel.reactToOrDelete(baseNote)
|
||||
} else if (accountViewModel.account.reactionChoices.size > 1) {
|
||||
@@ -914,7 +878,7 @@ fun ZapReaction(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
var zappingProgress by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
@@ -937,7 +901,7 @@ fun ZapReaction(
|
||||
onMultipleChoices = {
|
||||
wantsToZap = true
|
||||
},
|
||||
onError = { title, message ->
|
||||
onError = { _, message ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = message
|
||||
@@ -968,7 +932,7 @@ fun ZapReaction(
|
||||
wantsToZap = false
|
||||
wantsToChangeZapAmount = true
|
||||
},
|
||||
onError = { title, message ->
|
||||
onError = { _, message ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = message
|
||||
@@ -1028,7 +992,7 @@ fun ZapReaction(
|
||||
if (wantsToSetCustomZap) {
|
||||
ZapCustomDialog(
|
||||
onClose = { wantsToSetCustomZap = false },
|
||||
onError = { title, message ->
|
||||
onError = { _, message ->
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
showErrorMessageDialog = message
|
||||
@@ -1053,22 +1017,31 @@ fun ZapReaction(
|
||||
CircularProgressIndicator(
|
||||
progress = animateFloatAsState(
|
||||
targetValue = zappingProgress,
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec
|
||||
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
|
||||
label = "ZapIconIndicator"
|
||||
).value,
|
||||
modifier = remember { Modifier.size(animationSize) },
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
ZapIcon(
|
||||
ObserveZapIcon(
|
||||
baseNote,
|
||||
iconSize,
|
||||
grayTint,
|
||||
accountViewModel
|
||||
)
|
||||
) { wasZappedByLoggedInUser ->
|
||||
Crossfade(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon") {
|
||||
if (it) {
|
||||
ZappedIcon(iconSize)
|
||||
} else {
|
||||
ZapIcon(iconSize, grayTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ZapAmountText(baseNote, grayTint, accountViewModel)
|
||||
ObserveZapAmountText(baseNote, accountViewModel) { zapAmountTxt ->
|
||||
SlidingAnimationAmount(zapAmountTxt, grayTint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun zapClick(
|
||||
@@ -1085,7 +1058,7 @@ private fun zapClick(
|
||||
context.getString(R.string.error_dialog_zap_error),
|
||||
context.getString(R.string.no_zap_amount_setup_long_press_to_change)
|
||||
)
|
||||
} else if (!accountViewModel.isWriteable() && !accountViewModel.loggedInWithExternalSigner()) {
|
||||
} else if (!accountViewModel.isWriteable()) {
|
||||
accountViewModel.toast(
|
||||
context.getString(R.string.error_dialog_zap_error),
|
||||
context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps)
|
||||
@@ -1110,98 +1083,70 @@ private fun zapClick(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapIcon(
|
||||
private fun ObserveZapIcon(
|
||||
baseNote: Note,
|
||||
iconSize: Dp,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (MutableState<Boolean>) -> Unit
|
||||
) {
|
||||
val wasZappedByLoggedInUser = remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (!wasZappedByLoggedInUser.value) {
|
||||
WatchZapsForNote(baseNote, accountViewModel) { newWasZapped ->
|
||||
if (wasZappedByLoggedInUser.value != newWasZapped) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped ->
|
||||
if (wasZappedByLoggedInUser.value != newWasZapped) {
|
||||
wasZappedByLoggedInUser.value = newWasZapped
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = wasZappedByLoggedInUser) {
|
||||
if (it.value) {
|
||||
ZappedIcon(iconSize)
|
||||
} else {
|
||||
ZapIcon(iconSize, grayTint)
|
||||
}
|
||||
}
|
||||
inner(wasZappedByLoggedInUser)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel, onWasZapped: (Boolean) -> Unit) {
|
||||
private fun ObserveZapAmountText(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (MutableState<String>) -> Unit
|
||||
) {
|
||||
val zapAmountTxt = remember(baseNote) {
|
||||
mutableStateOf(showAmount(baseNote.zapsAmount))
|
||||
}
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, onWasZapped)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapAmountText(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val zapAmountTxt = remember(baseNote) { mutableStateOf("") }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
WatchZapAmountsForNote(baseNote, accountViewModel) { newZapAmount ->
|
||||
if (zapAmountTxt.value != newZapAmount) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
accountViewModel.calculateZapAmount(baseNote) { newZapAmount ->
|
||||
if (zapAmountTxt.value != newZapAmount) {
|
||||
zapAmountTxt.value = newZapAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SlidingAnimationAmount(zapAmountTxt, grayTint)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, onZapAmount: (String) -> Unit) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
accountViewModel.calculateZapAmount(baseNote, onZapAmount)
|
||||
}
|
||||
inner(zapAmountTxt)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ViewCountReaction(
|
||||
note: Note,
|
||||
grayTint: Color,
|
||||
barChartSize: Dp = Size19dp,
|
||||
numberSize: Dp = Size24dp,
|
||||
barChartModifier: Modifier = Modifier.size(Size19dp),
|
||||
numberSizeModifier: Modifier = Modifier.height(Size24dp),
|
||||
viewCountColorFilter: ColorFilter
|
||||
) {
|
||||
ViewCountIcon(barChartSize, grayTint)
|
||||
DrawViewCount(note, numberSize, viewCountColorFilter)
|
||||
ViewCountIcon(barChartModifier, grayTint)
|
||||
DrawViewCount(note, numberSizeModifier, viewCountColorFilter)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawViewCount(
|
||||
note: Note,
|
||||
numberSize: Dp = Size24dp,
|
||||
iconModifier: Modifier = Modifier,
|
||||
viewCountColorFilter: ColorFilter
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.height(numberSize)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = remember(note) {
|
||||
ImageRequest.Builder(context)
|
||||
@@ -1277,7 +1222,7 @@ fun ReactionChoicePopup(
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val toRemove = remember {
|
||||
baseNote.reactedBy(account.userProfile()).toSet()
|
||||
baseNote.reactedBy(account.userProfile()).toImmutableSet()
|
||||
}
|
||||
|
||||
val iconSizePx = with(LocalDensity.current) {
|
||||
@@ -1312,7 +1257,7 @@ private fun ActionableReactionButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
toRemove: Set<String>
|
||||
toRemove: ImmutableSet<String>
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
@@ -1482,13 +1427,14 @@ fun showCount(count: Int?): String {
|
||||
return when {
|
||||
count >= 1000000000 -> "${(count / 1000000000f).roundToInt()}G"
|
||||
count >= 1000000 -> "${(count / 1000000f).roundToInt()}M"
|
||||
count >= 1000 -> "${(count / 1000f).roundToInt()}k"
|
||||
count >= 10000 -> "${(count / 1000f).roundToInt()}k"
|
||||
else -> "$count"
|
||||
}
|
||||
}
|
||||
|
||||
val OneGiga = BigDecimal(1000000000)
|
||||
val OneMega = BigDecimal(1000000)
|
||||
val TenKilo = BigDecimal(10000)
|
||||
val OneKilo = BigDecimal(1000)
|
||||
|
||||
var dfG: DecimalFormat = DecimalFormat("#.0G")
|
||||
@@ -1501,9 +1447,9 @@ fun showAmount(amount: BigDecimal?): String {
|
||||
if (amount.abs() < BigDecimal(0.01)) return ""
|
||||
|
||||
return when {
|
||||
amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(1, RoundingMode.HALF_UP))
|
||||
amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(1, RoundingMode.HALF_UP))
|
||||
amount >= OneKilo -> dfK.format(amount.div(OneKilo).setScale(1, RoundingMode.HALF_UP))
|
||||
amount >= OneGiga -> dfG.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= OneMega -> dfM.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP))
|
||||
amount >= TenKilo -> dfK.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP))
|
||||
else -> dfN.format(amount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -27,6 +26,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.RelayInfo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -86,7 +87,7 @@ fun RelayCompose(
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -120,7 +121,7 @@ fun AddRelayButton(onClick: () -> Unit) {
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
contentPadding = ButtonPadding
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.add), color = Color.White)
|
||||
}
|
||||
@@ -135,7 +136,7 @@ fun RemoveRelayButton(onClick: () -> Unit) {
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
contentPadding = ButtonPadding
|
||||
) {
|
||||
Text(text = stringResource(R.string.remove), color = Color.White)
|
||||
}
|
||||
|
||||
@@ -32,21 +32,22 @@ import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
public fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
fun RelayBadges(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val relayList by baseNote.live().relayInfo.observeAsState(persistentListOf())
|
||||
val shortRelayList by remember {
|
||||
derivedStateOf {
|
||||
relayList.take(3).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(DoubleVertSpacer)
|
||||
|
||||
if (expanded) {
|
||||
VerticalRelayPanelWithFlow(relayList, accountViewModel, nav)
|
||||
} else {
|
||||
val shortRelayList by remember {
|
||||
derivedStateOf {
|
||||
relayList.take(3).toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
VerticalRelayPanelWithFlow(shortRelayList, accountViewModel, nav)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
@@ -21,8 +22,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -63,7 +64,7 @@ public fun RelayBadgesHorizontal(baseNote: Note, accountViewModel: AccountViewMo
|
||||
fun RenderRelayList(baseNote: Note, expanded: MutableState<Boolean>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteRelays by baseNote.live().relayInfo.observeAsState()
|
||||
|
||||
FlowRow(StdStartPadding) {
|
||||
FlowRow(StdStartPadding, verticalArrangement = Arrangement.Center) {
|
||||
val relaysToDisplay = remember(noteRelays, expanded.value) {
|
||||
if (expanded.value) noteRelays else noteRelays?.take(3)?.toImmutableList()
|
||||
}
|
||||
@@ -120,7 +121,6 @@ fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav:
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val ripple = rememberRipple(bounded = false, radius = Size15dp)
|
||||
@@ -162,7 +162,8 @@ fun RenderRelay(relay: RelayBriefInfo, accountViewModel: AccountViewModel, nav:
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = clickableModifier
|
||||
modifier = clickableModifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
RenderRelayIcon(relay.favIcon, automaticallyShowProfilePicture)
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiUrl
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -321,8 +322,10 @@ private fun EmojiSelector(accountViewModel: AccountViewModel, nav: (String) -> U
|
||||
) { emptyNote ->
|
||||
emptyNote?.let { usersEmojiList ->
|
||||
val collections by usersEmojiList.live().metadata.map {
|
||||
(it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()
|
||||
}.distinctUntilChanged().observeAsState((usersEmojiList.event as? EmojiPackSelectionEvent)?.taggedAddresses())
|
||||
(it.note.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList()
|
||||
}.distinctUntilChanged().observeAsState(
|
||||
(usersEmojiList.event as? EmojiPackSelectionEvent)?.taggedAddresses()?.toImmutableList()
|
||||
)
|
||||
|
||||
collections?.let {
|
||||
EmojiCollectionGallery(it, accountViewModel, nav, onClick)
|
||||
@@ -332,7 +335,7 @@ private fun EmojiSelector(accountViewModel: AccountViewModel, nav: (String) -> U
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: AccountViewModel, nav: (String) -> Unit, onClick: ((EmojiUrl) -> Unit)? = null) {
|
||||
fun EmojiCollectionGallery(emojiCollections: ImmutableList<ATag>, accountViewModel: AccountViewModel, nav: (String) -> Unit, onClick: ((EmojiUrl) -> Unit)? = null) {
|
||||
val color = MaterialTheme.colorScheme.background
|
||||
val bgColor = remember { mutableStateOf(color) }
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
@@ -376,7 +377,7 @@ fun UpdateZapAmountDialog(
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
|
||||
@@ -50,7 +51,7 @@ fun UserCompose(
|
||||
|
||||
if (showDiviser) {
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,11 @@ import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -80,7 +78,7 @@ fun NoteAuthorPicture(
|
||||
) {
|
||||
val author by baseNote.live().authorChanges.observeAsState(baseNote.author)
|
||||
|
||||
Crossfade(targetState = author) {
|
||||
Crossfade(targetState = author, label = "NoteAuthorPicture") {
|
||||
if (it == null) {
|
||||
DisplayBlankAuthor(size, modifier)
|
||||
} else {
|
||||
@@ -287,31 +285,39 @@ fun BaseUserPicture(
|
||||
innerModifier: Modifier = Modifier,
|
||||
outerModifier: Modifier = remember { Modifier.size(size) }
|
||||
) {
|
||||
val myIconSize by remember(size) {
|
||||
derivedStateOf {
|
||||
size.div(3.5f)
|
||||
}
|
||||
}
|
||||
|
||||
Box(outerModifier, contentAlignment = Alignment.TopEnd) {
|
||||
InnerBaseUserPicture(baseUser, size, accountViewModel, innerModifier)
|
||||
LoadUserProfilePicture(baseUser) { userProfilePicture ->
|
||||
InnerUserPicture(
|
||||
userHex = baseUser.pubkeyHex,
|
||||
userPicture = userProfilePicture,
|
||||
size = size,
|
||||
modifier = innerModifier,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
ObserveAndDisplayFollowingMark(baseUser.pubkeyHex, myIconSize, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InnerBaseUserPicture(
|
||||
fun LoadUserProfilePicture(
|
||||
baseUser: User,
|
||||
size: Dp,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier
|
||||
innerContent: @Composable (String?) -> Unit
|
||||
) {
|
||||
val userProfile by baseUser.live().profilePictureChanges.observeAsState(baseUser.profilePicture())
|
||||
|
||||
PictureAndFollowingMark(
|
||||
userHex = baseUser.pubkeyHex,
|
||||
userPicture = userProfile,
|
||||
size = size,
|
||||
modifier = modifier,
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
innerContent(userProfile)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PictureAndFollowingMark(
|
||||
fun InnerUserPicture(
|
||||
userHex: String,
|
||||
userPicture: String?,
|
||||
size: Dp,
|
||||
@@ -338,12 +344,6 @@ fun PictureAndFollowingMark(
|
||||
contentScale = ContentScale.Crop,
|
||||
loadProfilePicture = automaticallyShowProfilePicture
|
||||
)
|
||||
val myIconSize by remember(size) {
|
||||
derivedStateOf {
|
||||
size.div(3.5f)
|
||||
}
|
||||
}
|
||||
ObserveAndDisplayFollowingMark(userHex, myIconSize, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -351,12 +351,10 @@ fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewMod
|
||||
WatchUserFollows(userHex, accountViewModel) { newFollowingState ->
|
||||
AnimatedVisibility(
|
||||
visible = newFollowingState,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() }
|
||||
) {
|
||||
Box(contentAlignment = Alignment.TopEnd) {
|
||||
FollowingIcon(iconSize)
|
||||
}
|
||||
FollowingIcon(iconSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -438,7 +436,9 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
},
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: ""))
|
||||
accountViewModel.decrypt(note) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
}
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
@@ -501,21 +501,8 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
Text(stringResource(R.string.remove_from_private_bookmarks))
|
||||
},
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.removePrivateBookmark(note, bookmarks?.decryptedContent ?: "")
|
||||
} else {
|
||||
accountViewModel.removePrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
accountViewModel.removePrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -524,21 +511,8 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
Text(stringResource(R.string.add_to_private_bookmarks))
|
||||
},
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.addPrivateBookmark(note, bookmarks?.decryptedContent ?: "")
|
||||
} else {
|
||||
accountViewModel.addPrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
accountViewModel.addPrivateBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -548,24 +522,8 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
Text(stringResource(R.string.remove_from_public_bookmarks))
|
||||
},
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.removePublicBookmark(
|
||||
note,
|
||||
bookmarks?.decryptedContent ?: ""
|
||||
)
|
||||
} else {
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
accountViewModel.removePublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -574,24 +532,8 @@ fun NoteDropDownMenu(note: Note, popupExpanded: MutableState<Boolean>, accountVi
|
||||
Text(stringResource(R.string.add_to_public_bookmarks))
|
||||
},
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
val bookmarks = accountViewModel.userProfile().latestBookmarkList
|
||||
ExternalSignerUtils.decrypt(
|
||||
bookmarks?.content ?: "",
|
||||
accountViewModel.account.keyPair.pubKey.toHexKey(),
|
||||
bookmarks?.id ?: ""
|
||||
)
|
||||
bookmarks?.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[bookmarks?.id ?: ""] ?: ""
|
||||
accountViewModel.addPublicBookmark(
|
||||
note,
|
||||
bookmarks?.decryptedContent ?: ""
|
||||
)
|
||||
} else {
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
accountViewModel.addPublicBookmark(note)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -654,19 +596,21 @@ fun WatchBookmarksFollowsAndAccount(note: Note, accountViewModel: AccountViewMod
|
||||
|
||||
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newState = DropDownParams(
|
||||
isFollowingAuthor = accountViewModel.isFollowing(note.author),
|
||||
isPrivateBookmarkNote = accountViewModel.isInPrivateBookmarks(note),
|
||||
isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
|
||||
isLoggedUser = accountViewModel.isLoggedUser(note.author),
|
||||
isSensitive = note.event?.isSensitive() ?: false,
|
||||
showSensitiveContent = showSensitiveContent
|
||||
)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
onNew(
|
||||
newState
|
||||
accountViewModel.isInPrivateBookmarks(note) {
|
||||
val newState = DropDownParams(
|
||||
isFollowingAuthor = accountViewModel.isFollowing(note.author),
|
||||
isPrivateBookmarkNote = it,
|
||||
isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note),
|
||||
isLoggedUser = accountViewModel.isLoggedUser(note.author),
|
||||
isSensitive = note.event?.isSensitive() ?: false,
|
||||
showSensitiveContent = showSensitiveContent
|
||||
)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
onNew(
|
||||
newState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,10 +336,14 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
|
||||
val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel()
|
||||
val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel()
|
||||
|
||||
this.shouldShowDecimalsInAxis = shouldShowDecimals(chartEntryModelProducer2.minY, chartEntryModelProducer2.maxY)
|
||||
chartEntryModelProducer1?.let { chart1 ->
|
||||
chartEntryModelProducer2?.let { chart2 ->
|
||||
this.shouldShowDecimalsInAxis = shouldShowDecimals(chart2.minY, chart2.maxY)
|
||||
|
||||
this._axisLabels.emit(listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) })
|
||||
this._chartModel.emit(chartEntryModelProducer1.plus(chartEntryModelProducer2))
|
||||
this._axisLabels.emit(listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) })
|
||||
this._chartModel.emit(chart1.plus(chart2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// determine if the min max are so close that they render to the same number.
|
||||
|
||||
@@ -34,7 +34,7 @@ import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true, textColor: Color = Color.Unspecified) {
|
||||
val authorState by baseNote.live().authorChanges.observeAsState(baseNote.author)
|
||||
|
||||
Crossfade(targetState = authorState, modifier = weight) {
|
||||
Crossfade(targetState = authorState, modifier = weight, label = "NoteUsernameDisplay") {
|
||||
it?.let {
|
||||
UsernameDisplay(it, weight, showPlayButton, textColor = textColor)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton:
|
||||
|
||||
val userMetadata by baseUser.live().userMetadataInfo.observeAsState(baseUser.info)
|
||||
|
||||
Crossfade(targetState = userMetadata, modifier = weight) {
|
||||
Crossfade(targetState = userMetadata, modifier = weight, label = "UsernameDisplay") {
|
||||
if (it != null) {
|
||||
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight, textColor)
|
||||
} else {
|
||||
|
||||
@@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -417,14 +418,12 @@ fun payViaIntent(invoice: String, context: Context, onError: (String) -> Unit) {
|
||||
fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
onPost()
|
||||
},
|
||||
onClick = onPost,
|
||||
shape = ButtonBorder,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray
|
||||
),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
contentPadding = ZeroPadding
|
||||
) {
|
||||
if (isActive) {
|
||||
Text(text = stringResource(R.string.pay), color = Color.White)
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.WatchIsHiddenUser
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -76,7 +77,7 @@ fun ZapNoteCompose(baseReqResponse: ZapReqResponse, accountViewModel: AccountVie
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -197,14 +198,10 @@ fun ShowFollowingOrUnfollowingButton(
|
||||
if (isFollowing) {
|
||||
UnfollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
accountViewModel.unfollow(baseAuthor)
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_unfollow
|
||||
)
|
||||
}
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_unfollow
|
||||
)
|
||||
} else {
|
||||
accountViewModel.unfollow(baseAuthor)
|
||||
}
|
||||
@@ -212,14 +209,10 @@ fun ShowFollowingOrUnfollowingButton(
|
||||
} else {
|
||||
FollowButton {
|
||||
if (!accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.loggedInWithExternalSigner()) {
|
||||
accountViewModel.follow(baseAuthor)
|
||||
} else {
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_follow
|
||||
)
|
||||
}
|
||||
accountViewModel.toast(
|
||||
R.string.read_only_user,
|
||||
R.string.login_with_a_private_key_to_be_able_to_follow
|
||||
)
|
||||
} else {
|
||||
accountViewModel.follow(baseAuthor)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.app.Activity
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -9,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -18,11 +23,16 @@ import androidx.lifecycle.ViewModelStoreOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MainScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
@@ -31,40 +41,38 @@ fun AccountScreen(
|
||||
) {
|
||||
val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle()
|
||||
|
||||
Column() {
|
||||
Crossfade(
|
||||
targetState = accountState,
|
||||
animationSpec = tween(durationMillis = 100),
|
||||
label = "AccountState"
|
||||
) { state ->
|
||||
when (state) {
|
||||
is AccountState.Loading -> {
|
||||
LoadingAccounts()
|
||||
Crossfade(
|
||||
targetState = accountState,
|
||||
animationSpec = tween(durationMillis = 100),
|
||||
label = "AccountState"
|
||||
) { state ->
|
||||
when (state) {
|
||||
is AccountState.Loading -> {
|
||||
LoadingAccounts()
|
||||
}
|
||||
is AccountState.LoggedOff -> {
|
||||
LoginPage(accountStateViewModel, isFirstLogin = true)
|
||||
}
|
||||
is AccountState.LoggedIn -> {
|
||||
CompositionLocalProvider(
|
||||
LocalViewModelStoreOwner provides state.currentViewModelStore
|
||||
) {
|
||||
LoggedInPage(
|
||||
state.account,
|
||||
accountStateViewModel,
|
||||
sharedPreferencesViewModel
|
||||
)
|
||||
}
|
||||
is AccountState.LoggedOff -> {
|
||||
LoginPage(accountStateViewModel, isFirstLogin = true)
|
||||
}
|
||||
is AccountState.LoggedIn -> {
|
||||
CompositionLocalProvider(
|
||||
LocalViewModelStoreOwner provides state.currentViewModelStore
|
||||
) {
|
||||
LoggedInPage(
|
||||
state.account,
|
||||
accountStateViewModel,
|
||||
sharedPreferencesViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
CompositionLocalProvider(
|
||||
LocalViewModelStoreOwner provides state.currentViewModelStore
|
||||
) {
|
||||
LoggedInPage(
|
||||
state.account,
|
||||
accountStateViewModel,
|
||||
sharedPreferencesViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
CompositionLocalProvider(
|
||||
LocalViewModelStoreOwner provides state.currentViewModelStore
|
||||
) {
|
||||
LoggedInPage(
|
||||
state.account,
|
||||
accountStateViewModel,
|
||||
sharedPreferencesViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,13 +86,58 @@ fun LoggedInPage(
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel
|
||||
) {
|
||||
val accountViewModel: AccountViewModel = viewModel(
|
||||
key = "AccountStateViewModel",
|
||||
key = "AccountViewModel",
|
||||
factory = AccountViewModel.Factory(
|
||||
account,
|
||||
sharedPreferencesViewModel.sharedPrefs
|
||||
)
|
||||
)
|
||||
|
||||
val activity = getActivity() as MainActivity
|
||||
// Find a better way to associate these two.
|
||||
accountViewModel.serviceManager = activity.serviceManager
|
||||
|
||||
if (accountViewModel.account.signer is NostrSignerExternal) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult(),
|
||||
onResult = { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) {
|
||||
accountViewModel.toast(
|
||||
R.string.sign_request_rejected,
|
||||
R.string.sign_request_rejected_description
|
||||
)
|
||||
} else {
|
||||
result.data?.let {
|
||||
accountViewModel.runOnIO {
|
||||
accountViewModel.account.signer.launcher.newResult(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
DisposableEffect(accountViewModel, accountViewModel.account, launcher, activity) {
|
||||
accountViewModel.account.signer.launcher.registerLauncher(
|
||||
launcher = {
|
||||
try {
|
||||
activity.prepareToLaunchSigner()
|
||||
launcher.launch(it)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Signer", "Error opening Signer app", e)
|
||||
accountViewModel.toast(
|
||||
R.string.error_opening_external_signer,
|
||||
R.string.error_opening_external_signer_description
|
||||
)
|
||||
}
|
||||
},
|
||||
contentResolver = { Amethyst.instance.contentResolver }
|
||||
)
|
||||
onDispose {
|
||||
accountViewModel.account.signer.launcher.clearLauncher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainScreen(accountViewModel, accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
@@ -28,12 +33,14 @@ val EMAIL_PATTERN = Pattern.compile(".+@.+\\.[a-z]+")
|
||||
|
||||
@Stable
|
||||
class AccountStateViewModel() : ViewModel() {
|
||||
var serviceManager: ServiceManager? = null
|
||||
|
||||
private val _accountContent = MutableStateFlow<AccountState>(AccountState.Loading)
|
||||
val accountContent = _accountContent.asStateFlow()
|
||||
|
||||
fun tryLoginExistingAccountAsync() {
|
||||
// pulls account from storage.
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
tryLoginExistingAccount()
|
||||
}
|
||||
}
|
||||
@@ -48,9 +55,19 @@ class AccountStateViewModel() : ViewModel() {
|
||||
|
||||
private suspend fun requestLoginUI() {
|
||||
_accountContent.update { AccountState.LoggedOff }
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
serviceManager?.pauseForGood()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loginAndStartUI(key: String, useProxy: Boolean, proxyPort: Int, loginWithExternalSigner: Boolean = false) = withContext(Dispatchers.IO) {
|
||||
suspend fun loginAndStartUI(
|
||||
key: String,
|
||||
useProxy: Boolean,
|
||||
proxyPort: Int,
|
||||
loginWithExternalSigner: Boolean = false,
|
||||
packageName: String = ""
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
@@ -61,16 +78,22 @@ class AccountStateViewModel() : ViewModel() {
|
||||
|
||||
val account =
|
||||
if (loginWithExternalSigner) {
|
||||
Account(KeyPair(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort, loginWithExternalSigner = true)
|
||||
val keyPair = KeyPair(pubKey = pubKeyParsed)
|
||||
val localPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }
|
||||
Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toNpub(), localPackageName)))
|
||||
} else if (key.startsWith("nsec")) {
|
||||
Account(KeyPair(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort)
|
||||
val keyPair = KeyPair(privKey = key.bechToBytes())
|
||||
Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair))
|
||||
} else if (pubKeyParsed != null) {
|
||||
Account(KeyPair(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort)
|
||||
val keyPair = KeyPair(pubKey = pubKeyParsed)
|
||||
Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair))
|
||||
} else if (EMAIL_PATTERN.matcher(key).matches()) {
|
||||
val keyPair = KeyPair()
|
||||
// Evaluate NIP-5
|
||||
Account(KeyPair(), proxy = proxy, proxyPort = proxyPort)
|
||||
Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair))
|
||||
} else {
|
||||
Account(KeyPair(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort)
|
||||
val keyPair = KeyPair(Hex.decode(key))
|
||||
Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair))
|
||||
}
|
||||
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
@@ -78,15 +101,15 @@ class AccountStateViewModel() : ViewModel() {
|
||||
startUI(account)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
suspend fun startUI(account: Account) = withContext(Dispatchers.Main) {
|
||||
if (account.keyPair.privKey != null) {
|
||||
if (account.isWriteable()) {
|
||||
_accountContent.update { AccountState.LoggedIn(account) }
|
||||
} else {
|
||||
_accountContent.update { AccountState.LoggedInViewOnly(account) }
|
||||
}
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.restartIfDifferentAccount(account)
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
serviceManager?.restartIfDifferentAccount(account)
|
||||
}
|
||||
|
||||
account.saveable.observeForever(saveListener)
|
||||
@@ -122,11 +145,12 @@ class AccountStateViewModel() : ViewModel() {
|
||||
useProxy: Boolean,
|
||||
proxyPort: Int,
|
||||
loginWithExternalSigner: Boolean = false,
|
||||
packageName: String = "",
|
||||
onError: () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner)
|
||||
loginAndStartUI(key, useProxy, proxyPort, loginWithExternalSigner, packageName)
|
||||
} catch (e: Exception) {
|
||||
Log.e("Login", "Could not sign in", e)
|
||||
onError()
|
||||
@@ -135,9 +159,13 @@ class AccountStateViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
fun newKey(useProxy: Boolean, proxyPort: Int) {
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
val account = Account(KeyPair(), proxy = proxy, proxyPort = proxyPort)
|
||||
val keyPair = KeyPair()
|
||||
val account = Account(keyPair, proxy = proxy, proxyPort = proxyPort, signer = NostrSignerInternal(keyPair))
|
||||
|
||||
account.follow(account.userProfile())
|
||||
|
||||
// saves to local preferences
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
startUI(account)
|
||||
@@ -145,7 +173,7 @@ class AccountStateViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
fun switchUser(accountInfo: AccountInfo) {
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
prepareLogoutOrSwitch()
|
||||
LocalPreferences.switchToAccount(accountInfo)
|
||||
tryLoginExistingAccount()
|
||||
@@ -153,7 +181,7 @@ class AccountStateViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
fun logOff(accountInfo: AccountInfo) {
|
||||
viewModelScope.launch {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
prepareLogoutOrSwitch()
|
||||
LocalPreferences.updatePrefsForLogout(accountInfo)
|
||||
tryLoginExistingAccount()
|
||||
|
||||
@@ -64,7 +64,7 @@ class MultiSetCard(
|
||||
)
|
||||
|
||||
val likeEventsByType = likeEvents.groupBy {
|
||||
it.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(it.event?.tags() ?: emptyList())) ?: "+"
|
||||
it.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(it.event?.tags() ?: emptyArray())) ?: "+"
|
||||
}.mapValues {
|
||||
it.value.toImmutableList()
|
||||
}.toImmutableMap()
|
||||
|
||||
@@ -4,8 +4,6 @@ import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -26,7 +24,6 @@ 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.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
|
||||
@@ -34,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
|
||||
@Composable
|
||||
fun RefresheableCardView(
|
||||
@@ -56,15 +54,13 @@ fun RefresheableCardView(
|
||||
)
|
||||
|
||||
val modifier = if (enablePullRefresh) {
|
||||
Modifier.pullRefresh(pullRefreshState)
|
||||
Modifier.fillMaxSize().pullRefresh(pullRefreshState)
|
||||
} else {
|
||||
Modifier
|
||||
Modifier.fillMaxSize()
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
Column {
|
||||
SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
|
||||
}
|
||||
SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
|
||||
|
||||
if (enablePullRefresh) {
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
@@ -117,7 +113,7 @@ fun RenderCardFeed(
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
Crossfade(
|
||||
modifier = remember { Modifier.fillMaxSize() },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
targetState = feedState,
|
||||
animationSpec = tween(durationMillis = 100)
|
||||
) { state ->
|
||||
@@ -158,13 +154,8 @@ private fun FeedLoaded(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = remember { Modifier.fillMaxSize() },
|
||||
contentPadding = remember {
|
||||
PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = FeedPadding,
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -17,11 +16,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
|
||||
@@ -91,10 +90,7 @@ fun ChatroomFeedLoaded(
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
contentPadding = FeedPadding,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
reverseLayout = true,
|
||||
state = listState
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -14,10 +13,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomHeaderCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -89,10 +88,7 @@ private fun FeedLoaded(
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
contentPadding = FeedPadding,
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user