mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
0e51731f69 | ||
|
|
2cc9bb8b72 | ||
|
|
553bebb6cf | ||
|
|
e91e5c366d | ||
|
|
3222f88999 | ||
|
|
9d32718c13 | ||
|
|
9664e2f042 | ||
|
|
979c04c303 | ||
|
|
725baa8776 | ||
|
|
64c9505620 | ||
|
|
55db7221ad | ||
|
|
75dc55858c | ||
|
|
c606212043 | ||
|
|
2ccf7ec2f0 | ||
|
|
2baad54660 | ||
|
|
46cca5fd49 | ||
|
|
bb32477671 | ||
|
|
e89384ee61 | ||
|
|
b213137e7a | ||
|
|
8fab00300a | ||
|
|
92a39c4305 | ||
|
|
116bb19bc6 | ||
|
|
ce54d47f46 | ||
|
|
6a8c756f7a | ||
|
|
64921ab240 | ||
|
|
ab01113d0e | ||
|
|
26752084ae | ||
|
|
84bc9745be | ||
|
|
b535fde0ff | ||
|
|
424cbfcf1a | ||
|
|
56af44b00b | ||
|
|
3b93198219 | ||
|
|
98bb30fa59 | ||
|
|
3ffb43539f | ||
|
|
e3e989de53 | ||
|
|
86e34d4681 | ||
|
|
a0b79d1d3b | ||
|
|
75ec92aff4 | ||
|
|
ab38a369da | ||
|
|
460a99d95d | ||
|
|
a66e14f3eb | ||
|
|
dd1ade2f99 | ||
|
|
177c98efb7 | ||
|
|
8830ad3c27 | ||
|
|
903384d02a | ||
|
|
4a8c020c3d | ||
|
|
4aaf25a384 | ||
|
|
a2f0c7f548 | ||
|
|
75bd97d0ae | ||
|
|
f072b98fd7 | ||
|
|
d3d2845f10 | ||
|
|
6831349f2f | ||
|
|
5b0fc7982b | ||
|
|
10a0dc7f8a | ||
|
|
f92e13270d | ||
|
|
9dfb4d1a1b | ||
|
|
91bfb60613 | ||
|
|
191254d920 | ||
|
|
6dc82815b6 | ||
|
|
eb6e81f81e | ||
|
|
19b3bca86a | ||
|
|
80df2eefed | ||
|
|
884a124c7e | ||
|
|
a284c1b9c6 | ||
|
|
ccf428eff6 | ||
|
|
96faac5aad | ||
|
|
7060aa3ed0 | ||
|
|
45a749e4bf | ||
|
|
3321448dd2 | ||
|
|
893b88246e | ||
|
|
8065b942bd | ||
|
|
a1a2118b35 | ||
|
|
17e59c5ae8 | ||
|
|
39d84e33bb | ||
|
|
87e8948d9a | ||
|
|
8317a06b6f | ||
|
|
1ab93adc22 | ||
|
|
af100fa0b4 | ||
|
|
66443d029b | ||
|
|
6fe713107e | ||
|
|
800c0b0131 | ||
|
|
e1a3975a07 | ||
|
|
33bf305ab5 | ||
|
|
14ff6f03bf | ||
|
|
68e91e405a | ||
|
|
7b22b96968 | ||
|
|
26107ff5e9 | ||
|
|
18931fb743 | ||
|
|
f68f32f942 | ||
|
|
2892fa6fca | ||
|
|
27286c7ffe | ||
|
|
d9b0fbe928 | ||
|
|
b15f203bc1 | ||
|
|
5d266cca20 | ||
|
|
904b070306 | ||
|
|
37d4efd7ba | ||
|
|
ece8998ba6 | ||
|
|
3f5000f278 | ||
|
|
68f4752c66 | ||
|
|
904a50be1f | ||
|
|
35f6ecad59 | ||
|
|
2a328ca120 | ||
|
|
1e83dbfbe3 | ||
|
|
e703c08634 | ||
|
|
f8a574375a | ||
|
|
cfcafe5b2c | ||
|
|
3ea7b8194d | ||
|
|
1a7ae33fa8 | ||
|
|
2bbe126c8d | ||
|
|
f2e6efe2b7 | ||
|
|
404e6cd862 | ||
|
|
4ab04477f8 | ||
|
|
611e8f1a6d | ||
|
|
c8fd810bd3 | ||
|
|
c619a9000c | ||
|
|
3d70614797 | ||
|
|
c29b4b8e5f | ||
|
|
4286b64b41 | ||
|
|
e8c50cbf54 | ||
|
|
407807d871 | ||
|
|
202af650de | ||
|
|
e1e42ed500 | ||
|
|
1767cc74a9 | ||
|
|
4028018605 | ||
|
|
33f8b6d6d8 | ||
|
|
c738436ddc | ||
|
|
467174ebf8 | ||
|
|
f9fed8a04f | ||
|
|
00194c2f0d | ||
|
|
37be1e8c46 | ||
|
|
357f8f6f94 | ||
|
|
ba4a594a41 | ||
|
|
928a87363d | ||
|
|
1708c48b0c | ||
|
|
17adbf3603 | ||
|
|
57ff5c63b0 | ||
|
|
4f4cab6a6c | ||
|
|
e848b58a60 | ||
|
|
5c8f0ad5ce | ||
|
|
173245d900 | ||
|
|
dd2e590475 | ||
|
|
5bc4aab8d8 | ||
|
|
94308a6098 | ||
|
|
c7861dfac4 | ||
|
|
071df0b6d3 | ||
|
|
ac0fbc7435 | ||
|
|
275051ed30 | ||
|
|
3c4cf03684 | ||
|
|
2f1c221a7c | ||
|
|
6748abbf70 | ||
|
|
e04a35afed | ||
|
|
f574a1dae5 | ||
|
|
f67ff43eb3 | ||
|
|
55a4b1937f | ||
|
|
b8b41f840a | ||
|
|
9f5a71020d | ||
|
|
e8c9e73985 | ||
|
|
e0dba87286 | ||
|
|
3c308788d8 | ||
|
|
3d28715713 | ||
|
|
61d2030934 | ||
|
|
56c67b9ec6 | ||
|
|
77ed529c26 | ||
|
|
7eddf4a12a | ||
|
|
ff69a4168b | ||
|
|
cec204b7ae | ||
|
|
188ef3762d | ||
|
|
64c9acb4b2 | ||
|
|
c25018bb64 | ||
|
|
c1a5b855df | ||
|
|
0da031fae4 | ||
|
|
72dff060d2 | ||
|
|
d6f4ffafa1 | ||
|
|
4b36217699 | ||
|
|
9a109e6fc1 |
@@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.5.1"
|
||||
id 'org.jlleitschuh.gradle.ktlint' version "11.6.1"
|
||||
id 'com.google.gms.google-services'
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 34
|
||||
versionCode 311
|
||||
versionName "0.79.2"
|
||||
versionCode 326
|
||||
versionName "0.80.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -81,15 +81,20 @@ android {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(path: ':quartz')
|
||||
implementation "androidx.core:core-ktx:$core_ktx_version"
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation 'androidx.activity:activity-compose:1.8.0'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
|
||||
@@ -102,9 +107,15 @@ dependencies {
|
||||
// Observe Live data as State
|
||||
implementation "androidx.compose.runtime:runtime-livedata:$compose_ui_version"
|
||||
|
||||
// Material 3 Design
|
||||
implementation "androidx.compose.material3:material3:${material3_version}"
|
||||
implementation "androidx.compose.material:material-icons-extended:$compose_ui_version"
|
||||
|
||||
// Adaptive Layout / Two Pane
|
||||
implementation "androidx.compose.material3:material3-window-size-class:${material3_version}"
|
||||
implementation "com.google.accompanist:accompanist-adaptive:0.32.0"
|
||||
|
||||
|
||||
// Lifecycle
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
|
||||
@@ -121,7 +132,7 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// HTML Parsing for Link Preview
|
||||
implementation 'org.jsoup:jsoup:1.16.1'
|
||||
implementation 'org.jsoup:jsoup:1.16.2'
|
||||
|
||||
// Encrypted Key Storage
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
|
||||
@@ -171,9 +182,12 @@ dependencies {
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.3.1')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.4.1')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
//PushNotifications(FDroid)
|
||||
fdroidImplementation 'com.github.UnifiedPush:android-connector:2.2.0'
|
||||
|
||||
// Charts
|
||||
implementation "com.patrykandpatrick.vico:core:${vico_version}"
|
||||
implementation "com.patrykandpatrick.vico:compose:${vico_version}"
|
||||
@@ -200,10 +214,6 @@ dependencies {
|
||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_ui_version"
|
||||
}
|
||||
|
||||
ktlint {
|
||||
disabledRules.set(["no-wildcard-imports"])
|
||||
}
|
||||
|
||||
// https://gitlab.com/fdroid/wiki/-/wikis/HOWTO:-diff-&-fix-APKs-for-Reproducible-Builds#differing-assetsdexoptbaselineprofm-easy-to-fix
|
||||
// NB: Android Studio can't find the imports; this does not affect the
|
||||
// actual build since Gradle can find them just fine.
|
||||
|
||||
23
app/src/fdroid/AndroidManifest.xml
Normal file
23
app/src/fdroid/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application
|
||||
android:name=".Amethyst">
|
||||
|
||||
<receiver
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".service.notifications.PushMessageReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.MESSAGE"/>
|
||||
<action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
|
||||
<action android:name="org.unifiedpush.android.connector.REGISTRATION_REFUSED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.service.lang
|
||||
|
||||
object LanguageTranslatorService {
|
||||
fun clear() {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
interface PushDistributorActions {
|
||||
fun getSavedDistributor(): String
|
||||
fun getInstalledDistributors(): List<String>
|
||||
fun saveDistributor(distributor: String)
|
||||
fun removeSavedDistributor()
|
||||
}
|
||||
object PushDistributorHandler : PushDistributorActions {
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val unifiedPush: UnifiedPush = UnifiedPush
|
||||
|
||||
private var endpointInternal = ""
|
||||
val endpoint = endpointInternal
|
||||
|
||||
fun getSavedEndpoint() = endpoint
|
||||
fun setEndpoint(newEndpoint: String) {
|
||||
endpointInternal = newEndpoint
|
||||
Log.d("PushHandler", "New endpoint saved : $endpointInternal")
|
||||
}
|
||||
|
||||
fun removeEndpoint() {
|
||||
endpointInternal = ""
|
||||
}
|
||||
|
||||
override fun getSavedDistributor(): String {
|
||||
return unifiedPush.getDistributor(appContext)
|
||||
}
|
||||
|
||||
fun savedDistributorExists(): Boolean = getSavedDistributor().isNotEmpty()
|
||||
|
||||
override fun getInstalledDistributors(): List<String> {
|
||||
return unifiedPush.getDistributors(appContext)
|
||||
}
|
||||
|
||||
fun formattedDistributorNames(): List<String> {
|
||||
val distributorsArray = getInstalledDistributors().toTypedArray()
|
||||
val distributorsNameArray = distributorsArray.map {
|
||||
try {
|
||||
val ai = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appContext.packageManager.getApplicationInfo(
|
||||
it,
|
||||
PackageManager.ApplicationInfoFlags.of(
|
||||
PackageManager.GET_META_DATA.toLong()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
appContext.packageManager.getApplicationInfo(it, 0)
|
||||
}
|
||||
appContext.packageManager.getApplicationLabel(ai)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
it
|
||||
} as String
|
||||
}.toTypedArray()
|
||||
return distributorsNameArray.toList()
|
||||
}
|
||||
|
||||
override fun saveDistributor(distributor: String) {
|
||||
unifiedPush.saveDistributor(appContext, distributor)
|
||||
unifiedPush.registerApp(appContext)
|
||||
}
|
||||
|
||||
override fun removeSavedDistributor() {
|
||||
unifiedPush.safeRemoveDistributor(appContext)
|
||||
}
|
||||
fun forceRemoveDistributor(context: Context) {
|
||||
unifiedPush.forceRemoveDistributor(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.unifiedpush.android.connector.MessagingReceiver
|
||||
|
||||
class PushMessageReceiver : MessagingReceiver() {
|
||||
private val TAG = "Amethyst-OSSPushReceiver"
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val eventCache = LruCache<String, String>(100)
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
override fun onMessage(context: Context, message: ByteArray, instance: String) {
|
||||
val messageStr = String(message)
|
||||
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
|
||||
scope.launch {
|
||||
try {
|
||||
parseMessage(messageStr)?.let {
|
||||
receiveIfNew(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Message could not be parsed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun parseMessage(message: String): GiftWrapEvent? {
|
||||
(Event.fromJson(message) as? GiftWrapEvent)?.let {
|
||||
return it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun receiveIfNew(event: GiftWrapEvent) {
|
||||
if (eventCache.get(event.id) == null) {
|
||||
eventCache.put(event.id, event.id)
|
||||
EventNotificationConsumer(appContext).consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance")
|
||||
val sanitizedEndpoint = endpoint.dropLast(5)
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
|
||||
notificationManager().getOrCreateZapChannel(appContext)
|
||||
notificationManager().getOrCreateDMChannel(appContext)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val intentData = intent.dataString
|
||||
val intentAction = intent.action.toString()
|
||||
Log.d(TAG, "Intent Data:- $intentData Intent Action: $intentAction")
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(context: Context, instance: String) {
|
||||
Log.d(TAG, "Registration failed for Instance: $instance")
|
||||
scope.cancel()
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
}
|
||||
|
||||
override fun onUnregistered(context: Context, instance: String) {
|
||||
val removedEndpoint = pushHandler.endpoint
|
||||
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
|
||||
Log.d(TAG, "App is unregistered. ")
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
pushHandler.removeEndpoint()
|
||||
}
|
||||
|
||||
fun notificationManager(): NotificationManager {
|
||||
return ContextCompat.getSystemService(appContext, NotificationManager::class.java) as NotificationManager
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
|
||||
object PushNotificationUtils {
|
||||
var hasInit: Boolean = true
|
||||
var hasInit: Boolean = false
|
||||
private val pushHandler = PushDistributorHandler
|
||||
suspend fun init(accounts: List<AccountInfo>) {
|
||||
if (hasInit || pushHandler.savedDistributorExists()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pushHandler.savedDistributorExists()) {
|
||||
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("Amethyst-OSSPushUtils", "Failed to get endpoint.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.ui.RichTextStyle
|
||||
import com.halilibo.richtext.ui.material3.Material3RichText
|
||||
import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckifItNeedsToRequestNotificationPermission
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val notificationPermissionState = CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
|
||||
|
||||
if (notificationPermissionState.status.isGranted) {
|
||||
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
|
||||
val context = LocalContext.current
|
||||
var distributorPresent by remember {
|
||||
mutableStateOf(PushDistributorHandler.savedDistributorExists())
|
||||
}
|
||||
if (!distributorPresent) {
|
||||
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
|
||||
if (readableListWithExplainer.size > 1) {
|
||||
SpinnerSelectionDialog(
|
||||
title = stringResource(id = R.string.select_push_server),
|
||||
options = readableListWithExplainer,
|
||||
onSelect = { index ->
|
||||
if (list[index] == "None") {
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
sharedPreferencesViewModel.dontAskForNotificationPermissions()
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
} else {
|
||||
val fullDistributorName = list[index]
|
||||
PushDistributorHandler.saveDistributor(fullDistributorName)
|
||||
}
|
||||
distributorPresent = true
|
||||
Log.d("Amethyst", "NotificationScreen: Distributor registered.")
|
||||
},
|
||||
onDismiss = {
|
||||
distributorPresent = true
|
||||
Log.d("Amethyst", "NotificationScreen: Distributor dialog dismissed.")
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
distributorPresent = true
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.push_server_install_app))
|
||||
},
|
||||
text = {
|
||||
Material3RichText(
|
||||
style = RichTextStyle().resolveDefaults()
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.push_server_install_app_description)
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
distributorPresent = true
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
}) {
|
||||
Text(stringResource(R.string.quick_action_dont_show_again_button))
|
||||
}
|
||||
Button(onClick = {
|
||||
distributorPresent = true
|
||||
}, contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.error_dialog_button_ok))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadDistributors(
|
||||
onInner: @Composable (String, ImmutableList<String>, ImmutableList<TitleExplainer>) -> Unit
|
||||
) {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor().ifBlank { null } ?: "None"
|
||||
|
||||
val list = remember {
|
||||
PushDistributorHandler.getInstalledDistributors().plus("None").toImmutableList()
|
||||
}
|
||||
|
||||
val readableListWithExplainer =
|
||||
PushDistributorHandler.formattedDistributorNames()
|
||||
.mapIndexed { index, name ->
|
||||
TitleExplainer(
|
||||
name,
|
||||
stringResource(id = R.string.push_server_uses_app_explainer, list[index])
|
||||
)
|
||||
}
|
||||
.plus(
|
||||
TitleExplainer(
|
||||
stringResource(id = R.string.push_server_none),
|
||||
stringResource(id = R.string.push_server_none_explainer)
|
||||
)
|
||||
)
|
||||
.toImmutableList()
|
||||
|
||||
onInner(
|
||||
currentDistributor,
|
||||
list,
|
||||
readableListWithExplainer
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LoadDistributors() { currentDistributor, list, readableListWithExplainer ->
|
||||
SettingsRow(
|
||||
R.string.push_server_title,
|
||||
R.string.push_server_explainer,
|
||||
selectedItens = readableListWithExplainer,
|
||||
selectedIndex = list.indexOf(currentDistributor)
|
||||
) { index ->
|
||||
if (list[index] == "None") {
|
||||
sharedPreferencesViewModel.dontAskForNotificationPermissions()
|
||||
sharedPreferencesViewModel.dontShowPushNotificationSelector()
|
||||
PushDistributorHandler.forceRemoveDistributor(context)
|
||||
} else {
|
||||
PushDistributorHandler.saveDistributor(list[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@
|
||||
tools:replace="screenOrientation" />
|
||||
|
||||
<service
|
||||
android:name=".PlaybackService"
|
||||
android:name=".service.playback.PlaybackService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="true"
|
||||
android:exported="true">
|
||||
|
||||
@@ -4,15 +4,25 @@ import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.os.StrictMode.VmPolicy
|
||||
import android.util.Log
|
||||
import coil.ImageLoader
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class Amethyst : Application() {
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
val videoCache: VideoCache by lazy {
|
||||
val newCache = VideoCache()
|
||||
newCache.initFileCache(instance)
|
||||
newCache
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
|
||||
VideoCache.initFileCache(instance)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy.Builder()
|
||||
@@ -22,17 +32,25 @@ class Amethyst : Application() {
|
||||
)
|
||||
StrictMode.setVmPolicy(
|
||||
VmPolicy.Builder()
|
||||
.detectActivityLeaks()
|
||||
.detectFileUriExposure()
|
||||
.detectContentUriWithoutPermission()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.detectAll()
|
||||
.penaltyLog()
|
||||
// .penaltyDeath()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
// initializes the video cache in a thread
|
||||
videoCache
|
||||
}
|
||||
Log.d("Rendering Metrics", "VideoCache initialized in $elapsed")
|
||||
}
|
||||
}
|
||||
|
||||
fun imageLoaderBuilder(): ImageLoader.Builder {
|
||||
return ImageLoader.Builder(applicationContext).diskCache {
|
||||
SingletonDiskCache.get(applicationContext)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.security.crypto.MasterKey
|
||||
object EncryptedStorage {
|
||||
private const val PREFERENCES_NAME = "secret_keeper"
|
||||
|
||||
// returns the preferences for each account or a global file if null.
|
||||
fun prefsFileName(npub: String? = null): String {
|
||||
return if (npub == null) PREFERENCES_NAME else "${PREFERENCES_NAME}_$npub"
|
||||
}
|
||||
|
||||
@@ -17,15 +17,20 @@ import com.vitorpamplona.amethyst.model.Nip47URI
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.model.parseBooleanType
|
||||
import com.vitorpamplona.amethyst.model.parseConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
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.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
@@ -81,21 +86,25 @@ 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 ALL_ACCOUNT_INFO = "all_saved_accounts_info"
|
||||
const val SHARED_SETTINGS = "shared_settings"
|
||||
}
|
||||
|
||||
object LocalPreferences {
|
||||
private const val comma = ","
|
||||
|
||||
private var _currentAccount: String? = null
|
||||
private var _savedAccounts: List<AccountInfo>? = null
|
||||
|
||||
fun currentAccount(): String? {
|
||||
suspend fun currentAccount(): String? {
|
||||
if (_currentAccount == null) {
|
||||
_currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
return _currentAccount
|
||||
}
|
||||
|
||||
private fun updateCurrentAccount(npub: String) {
|
||||
private suspend fun updateCurrentAccount(npub: String) {
|
||||
if (_currentAccount != npub) {
|
||||
_currentAccount = npub
|
||||
|
||||
@@ -105,22 +114,39 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
private var _savedAccounts: List<String>? = null
|
||||
|
||||
private fun savedAccounts(): List<String> {
|
||||
private fun savedAccounts(): List<AccountInfo> {
|
||||
if (_savedAccounts == null) {
|
||||
_savedAccounts = encryptedPreferences()
|
||||
.getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts = getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
Event.mapper.readValue<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
if (newSystemOfAccounts != null && newSystemOfAccounts.isNotEmpty()) {
|
||||
_savedAccounts = newSystemOfAccounts
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(comma) ?: listOf()
|
||||
|
||||
val migrated = oldAccounts.map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
|
||||
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
_savedAccounts = migrated
|
||||
}
|
||||
}
|
||||
}
|
||||
return _savedAccounts!!
|
||||
}
|
||||
|
||||
private fun updateSavedAccounts(accounts: List<String>) {
|
||||
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) = withContext(Dispatchers.IO) {
|
||||
if (_savedAccounts != accounts) {
|
||||
_savedAccounts = accounts
|
||||
|
||||
encryptedPreferences().edit().apply {
|
||||
putString(PrefKeys.SAVED_ACCOUNTS, accounts.joinToString(comma).ifBlank { null })
|
||||
putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(accounts))
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
@@ -128,38 +154,40 @@ object LocalPreferences {
|
||||
private val prefsDirPath: String
|
||||
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
|
||||
|
||||
private fun addAccount(npub: String) {
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (npub !in accounts) {
|
||||
accounts.add(npub)
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
private suspend fun addAccount(accInfo: AccountInfo) {
|
||||
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
private fun setCurrentAccount(account: Account) {
|
||||
private suspend fun setCurrentAccount(account: Account) = withContext(Dispatchers.IO) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
val accInfo = AccountInfo(
|
||||
npub,
|
||||
account.isWriteable(),
|
||||
account.loginWithExternalSigner
|
||||
)
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(npub)
|
||||
addAccount(accInfo)
|
||||
}
|
||||
|
||||
fun switchToAccount(npub: String) {
|
||||
updateCurrentAccount(npub)
|
||||
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
|
||||
updateCurrentAccount(accountInfo.npub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the account from the app level shared preferences
|
||||
*/
|
||||
private fun removeAccount(npub: String) {
|
||||
val accounts = savedAccounts().toMutableList()
|
||||
if (accounts.remove(npub)) {
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
private suspend fun removeAccount(accountInfo: AccountInfo) {
|
||||
val accounts = savedAccounts().filter { it.npub != accountInfo.npub }
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the npub-specific shared preference file
|
||||
*/
|
||||
private fun deleteUserPreferenceFile(npub: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
@@ -169,6 +197,8 @@ object LocalPreferences {
|
||||
}
|
||||
|
||||
private fun encryptedPreferences(npub: String? = null): SharedPreferences {
|
||||
checkNotInMainThread()
|
||||
|
||||
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
|
||||
val preferenceFile = if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
|
||||
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
|
||||
@@ -186,43 +216,39 @@ object LocalPreferences {
|
||||
* condition and the file will probably not be deleted
|
||||
*/
|
||||
@SuppressLint("ApplySharedPref")
|
||||
fun updatePrefsForLogout(npub: String) {
|
||||
val userPrefs = encryptedPreferences(npub)
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) = withContext(Dispatchers.IO) {
|
||||
val userPrefs = encryptedPreferences(accountInfo.npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
removeAccount(npub)
|
||||
deleteUserPreferenceFile(npub)
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
|
||||
if (savedAccounts().isEmpty()) {
|
||||
val appPrefs = encryptedPreferences()
|
||||
appPrefs.edit().clear().apply()
|
||||
} else if (currentAccount() == npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0))
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
} else if (currentAccount() == accountInfo.npub) {
|
||||
updateCurrentAccount(savedAccounts().elementAt(0).npub)
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrefsForLogin(account: Account) {
|
||||
suspend fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
saveToEncryptedStorage(account)
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> {
|
||||
return savedAccounts().map { npub ->
|
||||
AccountInfo(
|
||||
npub,
|
||||
hasPrivKey(npub),
|
||||
getLoggedInWithExternalSigner(npub)
|
||||
)
|
||||
}
|
||||
return savedAccounts()
|
||||
}
|
||||
|
||||
fun allLocalAccountNPubs(): Set<String> {
|
||||
return savedAccounts().toSet()
|
||||
}
|
||||
suspend fun saveToEncryptedStorage(account: Account) = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
prefs.edit().apply {
|
||||
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
|
||||
if (account.loginWithExternalSigner) {
|
||||
remove(PrefKeys.NOSTR_PRIVKEY)
|
||||
} 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)
|
||||
@@ -255,74 +281,107 @@ object LocalPreferences {
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent!!)
|
||||
}
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.loginWithExternalSigner)
|
||||
}.apply()
|
||||
|
||||
val globalPrefs = encryptedPreferences()
|
||||
globalPrefs.edit().apply {
|
||||
if (account.settings.automaticallyShowImages.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, account.settings.automaticallyShowImages.prefCode!!)
|
||||
}
|
||||
|
||||
if (account.settings.automaticallyStartPlayback.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_START_PLAYBACK)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, account.settings.automaticallyStartPlayback.prefCode!!)
|
||||
}
|
||||
if (account.settings.automaticallyShowUrlPreview.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview.prefCode!!)
|
||||
}
|
||||
if (account.settings.automaticallyHideNavigationBars.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, account.settings.automaticallyHideNavigationBars.prefCode!!)
|
||||
}
|
||||
|
||||
if (account.settings.automaticallyShowProfilePictures.prefCode == null) {
|
||||
remove(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)
|
||||
} else {
|
||||
putBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, account.settings.automaticallyShowProfilePictures.prefCode!!)
|
||||
}
|
||||
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun updateTheme(theme: Int) {
|
||||
encryptedPreferences().edit().apply {
|
||||
putInt(PrefKeys.THEME, theme)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun getTheme(): Int {
|
||||
return encryptedPreferences().getInt(PrefKeys.THEME, 0)
|
||||
}
|
||||
|
||||
fun getPreferredLanguage(): String {
|
||||
return encryptedPreferences().getString(PrefKeys.PREFERRED_LANGUAGE, "") ?: ""
|
||||
}
|
||||
|
||||
private fun getLoggedInWithExternalSigner(npub: String): Boolean {
|
||||
return encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
}
|
||||
|
||||
private fun hasPrivKey(npub: String): Boolean {
|
||||
return (encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
val acc = loadFromEncryptedStorage(currentAccount())
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? {
|
||||
val acc = loadCurrentAccountFromEncryptedStorage(currentAccount())
|
||||
acc?.registerObservers()
|
||||
return acc
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(npub: String?): Account? {
|
||||
encryptedPreferences(npub).apply {
|
||||
val pubKey = getString(PrefKeys.NOSTR_PUBKEY, null) ?: return null
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
suspend fun migrateOldSharedSettings(): Settings? {
|
||||
val prefs = encryptedPreferences()
|
||||
loadOldSharedSettings(prefs)?.let {
|
||||
saveSharedSettings(it, prefs)
|
||||
return it
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun saveSharedSettings(sharedSettings: Settings, prefs: SharedPreferences = encryptedPreferences()) {
|
||||
with(prefs.edit()) {
|
||||
putString(PrefKeys.SHARED_SETTINGS, Event.mapper.writeValueAsString(sharedSettings))
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
with(prefs) {
|
||||
return try {
|
||||
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let {
|
||||
Event.mapper.readValue<Settings>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w("LocalPreferences", "Unable to decode shared preferences: ${getString(PrefKeys.SHARED_SETTINGS, null)}", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Turned into a single JSON object")
|
||||
suspend fun loadOldSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
|
||||
with(prefs) {
|
||||
if (!contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
val automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
|
||||
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
|
||||
} else {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePictures = if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
val themeType = if (contains(PrefKeys.THEME)) {
|
||||
parseThemeType(getInt(PrefKeys.THEME, ThemeType.SYSTEM.screenCode))
|
||||
} else {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
|
||||
return Settings(
|
||||
themeType,
|
||||
getString(PrefKeys.PREFERRED_LANGUAGE, null)?.ifBlank { null },
|
||||
automaticallyShowImages,
|
||||
automaticallyStartPlayback,
|
||||
automaticallyShowUrlPreview,
|
||||
automaticallyHideNavigationBars,
|
||||
automaticallyShowProfilePictures,
|
||||
false,
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String?): Account? = withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
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()
|
||||
@@ -394,7 +453,6 @@ object LocalPreferences {
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
val loginWithExternalSigner = getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)
|
||||
|
||||
val showSensitiveContent = if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
getBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, false)
|
||||
@@ -414,40 +472,7 @@ object LocalPreferences {
|
||||
mapOf()
|
||||
}
|
||||
|
||||
val settings = Settings()
|
||||
encryptedPreferences().apply {
|
||||
settings.automaticallyShowImages = if (contains(PrefKeys.AUTOMATICALLY_SHOW_IMAGES)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_IMAGES, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
settings.automaticallyStartPlayback = if (contains(PrefKeys.AUTOMATICALLY_START_PLAYBACK)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_START_PLAYBACK, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
settings.automaticallyShowUrlPreview = if (contains(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
settings.automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
|
||||
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
|
||||
} else {
|
||||
BooleanType.ALWAYS
|
||||
}
|
||||
|
||||
settings.automaticallyShowProfilePictures = if (contains(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE)) {
|
||||
parseConnectivityType(getBoolean(PrefKeys.AUTOMATICALLY_SHOW_PROFILE_PICTURE, false))
|
||||
} else {
|
||||
ConnectivityType.ALWAYS
|
||||
}
|
||||
|
||||
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
|
||||
}
|
||||
|
||||
val a = Account(
|
||||
return@with Account(
|
||||
keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels = followingChannels,
|
||||
followingCommunities = followingCommunities,
|
||||
@@ -475,11 +500,8 @@ object LocalPreferences {
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
settings = settings,
|
||||
loginWithExternalSigner = loginWithExternalSigner
|
||||
)
|
||||
|
||||
return a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
@@ -44,14 +43,15 @@ object 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
|
||||
|
||||
fun start(account: Account, context: Context) {
|
||||
private fun start(account: Account) {
|
||||
this.account = account
|
||||
ExternalSignerUtils.account = account
|
||||
start(context)
|
||||
start()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun start(context: Context) {
|
||||
fun start() {
|
||||
Log.d("ServiceManager", "Pre Starting Relay Services $isStarted $account")
|
||||
if (isStarted && account != null) {
|
||||
return
|
||||
}
|
||||
@@ -63,7 +63,7 @@ object ServiceManager {
|
||||
HttpClient.start(account)
|
||||
OptOutFromFilters.start(account?.warnAboutPostsWithReports ?: true, account?.filterSpamFromStrangers ?: true)
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(context).components {
|
||||
Amethyst.instance.imageLoaderBuilder().components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
@@ -71,7 +71,6 @@ object ServiceManager {
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
}.logger(DebugLogger())
|
||||
.diskCache { SingletonDiskCache.get(context.applicationContext) }
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
@@ -138,10 +137,14 @@ object ServiceManager {
|
||||
fun trimMemory() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()
|
||||
val accounts = LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
decodePublicKeyAsHexOrNull(it.npub)
|
||||
}.toSet()
|
||||
|
||||
account?.let {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
NostrChatroomDataSource.clearEOSEs(it)
|
||||
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(accounts)
|
||||
LocalCache.pruneRepliesAndReactions(accounts)
|
||||
@@ -150,10 +153,17 @@ object ServiceManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun restartIfDifferentAccount(account: Account, context: Context) {
|
||||
fun restartIfDifferentAccount(account: Account) {
|
||||
if (this.account != account) {
|
||||
pause()
|
||||
start(account, context)
|
||||
start(account)
|
||||
}
|
||||
}
|
||||
|
||||
fun forceRestartIfItShould() {
|
||||
if (shouldPauseService) {
|
||||
pause()
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,47 @@ import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.hexToByteArray
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.Contact
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiUrl
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GeneralListEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.Gossip
|
||||
import com.vitorpamplona.quartz.events.HTTPAuthorizationEvent
|
||||
import com.vitorpamplona.quartz.events.IdentityClaim
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.NIP24Factory
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.Response
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.WrappedEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
@@ -33,6 +73,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
@@ -90,7 +131,6 @@ class Account(
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
var lastReadPerRoute: Map<String, Long> = mapOf<String, Long>(),
|
||||
var settings: Settings = Settings(),
|
||||
var loginWithExternalSigner: Boolean = false
|
||||
) {
|
||||
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
|
||||
@@ -168,46 +208,6 @@ class Account(
|
||||
|
||||
var userProfileCache: User? = null
|
||||
|
||||
fun updateAutomaticallyStartPlayback(
|
||||
automaticallyStartPlayback: ConnectivityType
|
||||
) {
|
||||
settings.automaticallyStartPlayback = automaticallyStartPlayback
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateAutomaticallyShowUrlPreview(
|
||||
automaticallyShowUrlPreview: ConnectivityType
|
||||
) {
|
||||
settings.automaticallyShowUrlPreview = automaticallyShowUrlPreview
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateAutomaticallyShowProfilePicture(
|
||||
automaticallyShowProfilePicture: ConnectivityType
|
||||
) {
|
||||
settings.automaticallyShowProfilePictures = automaticallyShowProfilePicture
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateAutomaticallyHideHavBars(
|
||||
automaticallyHideHavBars: BooleanType
|
||||
) {
|
||||
settings.automaticallyHideNavigationBars = automaticallyHideHavBars
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateAutomaticallyShowImages(
|
||||
automaticallyShowImages: ConnectivityType
|
||||
) {
|
||||
settings.automaticallyShowImages = automaticallyShowImages
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
@@ -366,7 +366,7 @@ class Account(
|
||||
)
|
||||
}
|
||||
|
||||
broadcastPrivately(giftWraps)
|
||||
broadcastPrivately(NIP24Factory.Result(senderReaction, giftWraps))
|
||||
} else {
|
||||
val giftWraps = NIP24Factory().createReactionWithinGroup(
|
||||
emojiUrl = emojiUrl,
|
||||
@@ -419,7 +419,7 @@ class Account(
|
||||
recipientPubKey = it
|
||||
)
|
||||
|
||||
broadcastPrivately(listOf(giftWraps))
|
||||
broadcastPrivately(NIP24Factory.Result(senderReaction, listOf(giftWraps)))
|
||||
}
|
||||
} else {
|
||||
val giftWraps = NIP24Factory().createReactionWithinGroup(
|
||||
@@ -1532,40 +1532,51 @@ class Account(
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
var localMessage = message
|
||||
if (loginWithExternalSigner) {
|
||||
ExternalSignerUtils.encrypt(localMessage, toUser, "encrypt")
|
||||
ExternalSignerUtils.encrypt(message, toUser, "encrypt")
|
||||
val eventContent = ExternalSignerUtils.content["encrypt"] ?: ""
|
||||
if (eventContent.isBlank()) return
|
||||
localMessage = eventContent
|
||||
ExternalSignerUtils.content.remove("encrypt")
|
||||
}
|
||||
val unsignedEvent = PrivateDmEvent.create(
|
||||
recipientPubKey = toUser.hexToByteArray(),
|
||||
publishedRecipientPubKey = toUser.hexToByteArray(),
|
||||
msg = eventContent,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
keyPair = keyPair,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
|
||||
var signedEvent = PrivateDmEvent.create(
|
||||
recipientPubKey = toUser.hexToByteArray(),
|
||||
publishedRecipientPubKey = toUser.hexToByteArray(),
|
||||
msg = localMessage,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
keyPair = keyPair,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
|
||||
if (loginWithExternalSigner) {
|
||||
ExternalSignerUtils.openSigner(signedEvent)
|
||||
val eventContent = ExternalSignerUtils.content[signedEvent.id] ?: ""
|
||||
if (eventContent.isBlank()) {
|
||||
ExternalSignerUtils.openSigner(unsignedEvent)
|
||||
val signature = ExternalSignerUtils.content[unsignedEvent.id] ?: ""
|
||||
if (signature.isBlank()) {
|
||||
return
|
||||
}
|
||||
signedEvent = PrivateDmEvent.create(signedEvent, eventContent)
|
||||
}
|
||||
val signedEvent = PrivateDmEvent.create(unsignedEvent, signature)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
} else {
|
||||
val signedEvent = PrivateDmEvent.create(
|
||||
recipientPubKey = toUser.hexToByteArray(),
|
||||
publishedRecipientPubKey = toUser.hexToByteArray(),
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
markAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = zapRaiserAmount,
|
||||
geohash = geohash,
|
||||
keyPair = keyPair,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNIP24PrivateMessage(
|
||||
@@ -1622,7 +1633,7 @@ class Account(
|
||||
event = sealedEvent,
|
||||
recipientPubKey = it
|
||||
)
|
||||
broadcastPrivately(listOf(giftWraps))
|
||||
broadcastPrivately(NIP24Factory.Result(chatMessageEvent, listOf(giftWraps)))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1643,42 +1654,54 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun broadcastPrivately(signedEvents: List<GiftWrapEvent>) {
|
||||
signedEvents.forEach {
|
||||
Client.send(it)
|
||||
fun broadcastPrivately(signedEvents: NIP24Factory.Result) {
|
||||
val mine = signedEvents.wraps.filter {
|
||||
(it.recipientPubKey() == keyPair.pubKey.toHexKey())
|
||||
}
|
||||
|
||||
mine.forEach {
|
||||
// Only keep in cache the GiftWrap for the account.
|
||||
if (it.recipientPubKey() == keyPair.pubKey.toHexKey()) {
|
||||
if (loginWithExternalSigner) {
|
||||
ExternalSignerUtils.decrypt(it.content, it.pubKey, it.id, SignerType.NIP44_DECRYPT)
|
||||
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[it.id] ?: ""
|
||||
if (decryptedContent.isEmpty()) return
|
||||
it.cachedGift(keyPair.pubKey, decryptedContent)?.let { cached ->
|
||||
if (cached is SealedGossipEvent) {
|
||||
ExternalSignerUtils.decrypt(cached.content, cached.pubKey, cached.id, SignerType.NIP44_DECRYPT)
|
||||
val localDecryptedContent = ExternalSignerUtils.cachedDecryptedContent[cached.id] ?: ""
|
||||
if (localDecryptedContent.isEmpty()) return
|
||||
cached.cachedGossip(keyPair.pubKey, localDecryptedContent)?.let { gossip ->
|
||||
LocalCache.justConsume(gossip, null)
|
||||
}
|
||||
} else {
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
it.cachedGift(keyPair.privKey!!)?.let {
|
||||
if (it is SealedGossipEvent) {
|
||||
it.cachedGossip(keyPair.privKey!!)?.let {
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
LocalCache.justConsume(it, null)
|
||||
if (loginWithExternalSigner) {
|
||||
ExternalSignerUtils.decrypt(it.content, it.pubKey, it.id, SignerType.NIP44_DECRYPT)
|
||||
val decryptedContent = ExternalSignerUtils.cachedDecryptedContent[it.id] ?: ""
|
||||
if (decryptedContent.isEmpty()) return
|
||||
it.cachedGift(keyPair.pubKey, decryptedContent)?.let { cached ->
|
||||
if (cached is SealedGossipEvent) {
|
||||
ExternalSignerUtils.decrypt(cached.content, cached.pubKey, cached.id, SignerType.NIP44_DECRYPT)
|
||||
val localDecryptedContent = ExternalSignerUtils.cachedDecryptedContent[cached.id] ?: ""
|
||||
if (localDecryptedContent.isEmpty()) return
|
||||
cached.cachedGossip(keyPair.pubKey, localDecryptedContent)?.let { gossip ->
|
||||
LocalCache.justConsume(gossip, null)
|
||||
}
|
||||
} else {
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
it.cachedGift(keyPair.privKey!!)?.let {
|
||||
if (it is SealedGossipEvent) {
|
||||
it.cachedGossip(keyPair.privKey!!)?.let {
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
} else {
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
|
||||
LocalCache.consume(it, null)
|
||||
}
|
||||
|
||||
val id = mine.firstOrNull()?.id
|
||||
val mineNote = if (id == null) null else LocalCache.getNoteIfExists(id)
|
||||
|
||||
signedEvents.wraps.forEach {
|
||||
// Creates an alias
|
||||
if (mineNote != null && it.recipientPubKey() != keyPair.pubKey.toHexKey()) {
|
||||
LocalCache.getOrAddAliasNote(it.id, mineNote)
|
||||
}
|
||||
|
||||
Client.send(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2119,9 +2142,13 @@ class Account(
|
||||
}
|
||||
|
||||
fun createAuthEvent(relay: Relay, challenge: String): RelayAuthEvent? {
|
||||
return createAuthEvent(relay.url, challenge)
|
||||
}
|
||||
|
||||
fun createAuthEvent(relayUrl: String, challenge: String): RelayAuthEvent? {
|
||||
if (!isWriteable() && !loginWithExternalSigner) return null
|
||||
|
||||
var event = RelayAuthEvent.create(relay.url, challenge, keyPair.pubKey.toHexKey(), keyPair.privKey)
|
||||
var event = RelayAuthEvent.create(relayUrl, challenge, keyPair.pubKey.toHexKey(), keyPair.privKey)
|
||||
if (loginWithExternalSigner) {
|
||||
ExternalSignerUtils.openSigner(event)
|
||||
val eventContent = ExternalSignerUtils.content[event.id] ?: ""
|
||||
@@ -3127,7 +3154,7 @@ class Account(
|
||||
return lastReadPerRoute[route] ?: 0
|
||||
}
|
||||
|
||||
fun registerObservers() {
|
||||
suspend fun registerObservers() = withContext(Dispatchers.Main) {
|
||||
// Observes relays to restart connections
|
||||
userProfile().live().relays.observeForever {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String, primary: Color): HashtagIcon? {
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc", "timechain", "bitcoiner", "bitcoiners" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches", "thenostr" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
|
||||
@@ -3,8 +3,6 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.ExternalSignerUtils
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -13,15 +11,70 @@ import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.HexValidator
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarRSVPEvent
|
||||
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
|
||||
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.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RecommendRelayEvent
|
||||
import com.vitorpamplona.quartz.events.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
@@ -94,7 +147,15 @@ object LocalCache {
|
||||
val noteEvent = note.event
|
||||
return if (noteEvent is AddressableEvent) {
|
||||
// upgrade to the latest
|
||||
checkGetOrCreateAddressableNote(noteEvent.address().toTag())
|
||||
val newNote = checkGetOrCreateAddressableNote(noteEvent.address().toTag())
|
||||
|
||||
if (newNote != null && noteEvent is Event && newNote.event == null) {
|
||||
val author = note.author ?: getOrCreateUser(noteEvent.pubKey)
|
||||
newNote.loadEvent(noteEvent as Event, author, emptyList())
|
||||
note.moveAllReferencesTo(newNote)
|
||||
}
|
||||
|
||||
newNote
|
||||
} else {
|
||||
note
|
||||
}
|
||||
@@ -102,6 +163,18 @@ object LocalCache {
|
||||
return null
|
||||
}
|
||||
|
||||
fun getOrAddAliasNote(idHex: String, note: Note): Note {
|
||||
checkNotInMainThread()
|
||||
|
||||
return notes.get(idHex) ?: run {
|
||||
require(isValidHex(idHex)) {
|
||||
"$idHex is not a valid hex"
|
||||
}
|
||||
|
||||
notes.putIfAbsent(idHex, note) ?: note
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -210,14 +283,6 @@ object LocalCache {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
|
||||
if (event.dTag() == "bookmark") {
|
||||
val loggedInUser = LocalPreferences.currentAccount()
|
||||
val hexKey = loggedInUser?.bechToBytes()
|
||||
if (hexKey != null) {
|
||||
val pubKey = Hex.encode(hexKey)
|
||||
if (pubKey == event.pubKey) {
|
||||
ExternalSignerUtils.content.remove(event.id)
|
||||
}
|
||||
}
|
||||
user.updateBookmark(event)
|
||||
}
|
||||
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
|
||||
@@ -1453,7 +1518,7 @@ object LocalCache {
|
||||
try {
|
||||
event.checkSignature()
|
||||
} catch (e: Exception) {
|
||||
Log.w("Event failed retest ${event.kind}", e.message ?: "")
|
||||
Log.w("Event failed retest ${event.kind}", (e.message ?: "") + event.toJson())
|
||||
}
|
||||
false
|
||||
} else {
|
||||
|
||||
@@ -20,7 +20,25 @@ import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toNote
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.AddressableEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
|
||||
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.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -399,9 +417,16 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addRelaySync(url: String) {
|
||||
if (url !in relays) {
|
||||
relays = relays + url
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
if (relay.url !in relays) {
|
||||
relays = relays + relay.url
|
||||
addRelaySync(relay.url)
|
||||
liveSet?.innerRelays?.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -752,6 +777,10 @@ class NoteLiveSet(u: Note) {
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
val content = innerMetadata.map {
|
||||
it.note.event?.content() ?: ""
|
||||
}
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return metadata.hasObservers() ||
|
||||
reactions.hasObservers() ||
|
||||
@@ -780,6 +809,7 @@ class NoteLiveSet(u: Note) {
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(500, Dispatchers.IO)
|
||||
@@ -808,6 +838,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()
|
||||
|
||||
@@ -4,16 +4,36 @@ import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Stable
|
||||
class Settings(
|
||||
var preferredLanguage: String? = null,
|
||||
var automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
var automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
var automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
|
||||
var automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS
|
||||
data class Settings(
|
||||
val theme: ThemeType = ThemeType.SYSTEM,
|
||||
val preferredLanguage: String? = null,
|
||||
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS,
|
||||
val automaticallyShowProfilePictures: ConnectivityType = ConnectivityType.ALWAYS,
|
||||
val dontShowPushNotificationSelector: Boolean = false,
|
||||
val dontAskForNotificationPermissions: Boolean = false
|
||||
)
|
||||
|
||||
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
|
||||
enum class ThemeType(val screenCode: Int, val resourceId: Int) {
|
||||
SYSTEM(0, R.string.system),
|
||||
LIGHT(1, R.string.light),
|
||||
DARK(2, R.string.dark)
|
||||
}
|
||||
|
||||
fun parseThemeType(code: Int?): ThemeType {
|
||||
return when (code) {
|
||||
ThemeType.SYSTEM.screenCode -> ThemeType.SYSTEM
|
||||
ThemeType.LIGHT.screenCode -> ThemeType.LIGHT
|
||||
ThemeType.DARK.screenCode -> ThemeType.DARK
|
||||
else -> {
|
||||
ThemeType.SYSTEM
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val resourceId: Int) {
|
||||
ALWAYS(null, 0, R.string.connectivity_type_always),
|
||||
WIFI_ONLY(true, 1, R.string.connectivity_type_wifi_only),
|
||||
NEVER(false, 2, R.string.connectivity_type_never)
|
||||
|
||||
@@ -156,40 +156,39 @@ object ExternalSignerUtils {
|
||||
checkNotInMainThread()
|
||||
|
||||
val result = getDataFromResolver(SignerType.SIGN_EVENT, arrayOf(event.toJson(), event.pubKey()), columnName)
|
||||
if (result !== null) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
ServiceManager.shouldPauseService = false
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.SIGN_EVENT,
|
||||
activityResultLauncher,
|
||||
"",
|
||||
event.id()
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptBlockList(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
blockListResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
blockListResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
fun getDataFromResolver(signerType: SignerType, data: Array<out String>, columnName: String = "signature"): String? {
|
||||
@@ -206,15 +205,16 @@ object ExternalSignerUtils {
|
||||
null,
|
||||
null
|
||||
).use {
|
||||
if (it !== 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)
|
||||
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
|
||||
@@ -222,90 +222,90 @@ object ExternalSignerUtils {
|
||||
|
||||
fun decrypt(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
// do nothing
|
||||
}
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptDM(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
fun decryptBookmark(encryptedContent: String, pubKey: HexKey, id: String, signerType: SignerType = SignerType.NIP04_DECRYPT) {
|
||||
val result = getDataFromResolver(signerType, arrayOf(encryptedContent, pubKey))
|
||||
if (result !== null) {
|
||||
if (result == null) {
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
content.put(id, result)
|
||||
cachedDecryptedContent[id] = result
|
||||
return
|
||||
}
|
||||
openSigner(
|
||||
encryptedContent,
|
||||
signerType,
|
||||
decryptResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
if (result == null) {
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
decryptedContent,
|
||||
signerType,
|
||||
activityResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
} else {
|
||||
content.put(id, result)
|
||||
return
|
||||
}
|
||||
|
||||
isActivityRunning = true
|
||||
openSigner(
|
||||
decryptedContent,
|
||||
signerType,
|
||||
activityResultLauncher,
|
||||
pubKey,
|
||||
id
|
||||
)
|
||||
while (isActivityRunning) {
|
||||
Thread.sleep(100)
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapEvent(event: LnZapRequestEvent) {
|
||||
val result = getDataFromResolver(SignerType.DECRYPT_ZAP_EVENT, arrayOf(event.toJson(), event.pubKey))
|
||||
if (result !== null) {
|
||||
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
|
||||
return
|
||||
}
|
||||
openSigner(
|
||||
event.toJson(),
|
||||
SignerType.DECRYPT_ZAP_EVENT,
|
||||
decryptResultLauncher,
|
||||
event.pubKey,
|
||||
event.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
@@ -147,6 +148,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), account.defaultNotificationFollowList, relayUrl, time)
|
||||
} else {
|
||||
hasLoadedTheBasics[account.userProfile()] = true
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +209,30 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
}
|
||||
}
|
||||
|
||||
override fun markAsSeenOnRelay(eventId: String, relay: Relay) {
|
||||
checkNotInMainThread()
|
||||
|
||||
super.markAsSeenOnRelay(eventId, relay)
|
||||
|
||||
val note = LocalCache.getNoteIfExists(eventId) ?: return
|
||||
val privKey = account.keyPair.privKey ?: return
|
||||
|
||||
val noteEvent = note.event ?: return
|
||||
markInnerAsSeenOnRelay(noteEvent, privKey, relay)
|
||||
}
|
||||
|
||||
private fun markInnerAsSeenOnRelay(noteEvent: EventInterface, privKey: ByteArray, relay: Relay) {
|
||||
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
|
||||
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
val gift = noteEvent.cachedGift(privKey) ?: return
|
||||
markInnerAsSeenOnRelay(gift, privKey, relay)
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
val rumor = noteEvent.cachedGossip(privKey) ?: return
|
||||
markInnerAsSeenOnRelay(rumor, privKey, relay)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
return if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
// gets everthing about the user logged in
|
||||
|
||||
@@ -56,6 +56,10 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
fun clearEOSEs(account: Account) {
|
||||
latestEOSEs.removeDataFor(account.userProfile())
|
||||
}
|
||||
|
||||
val inandoutChannel = requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(account.userProfile(), withRoom.hashCode().toString(), relayUrl, time)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.Error
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@@ -51,7 +50,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, subscriptionId: String?) {
|
||||
override fun onRelayStateChange(type: Relay.StateType, relay: Relay, subscriptionId: String?) {
|
||||
// Log.d("RELAY", "Relay ${relay.url} ${when (type) {
|
||||
// Relay.Type.CONNECT -> "connected."
|
||||
// Relay.Type.DISCONNECT -> "disconnected."
|
||||
@@ -59,7 +58,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// Relay.Type.EOSE -> "sent all events it had stored."
|
||||
// }}")
|
||||
|
||||
if (type == Relay.Type.EOSE && subscriptionId != null && subscriptionId in subscriptions.keys) {
|
||||
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.
|
||||
|
||||
@@ -7,7 +7,24 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.events.ClassifiedsEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.NNSEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
|
||||
private var searchString: String? = null
|
||||
|
||||
@@ -6,7 +6,15 @@ import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<Note>()
|
||||
|
||||
@@ -4,7 +4,23 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.AppRecommendationEvent
|
||||
import com.vitorpamplona.quartz.events.AudioHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.events.HighlightEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.PinListEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.service.connectivitystatus
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
||||
object ConnectivityStatus {
|
||||
private val onMobileData = mutableStateOf(false)
|
||||
val isOnMobileData: MutableState<Boolean> = onMobileData
|
||||
|
||||
fun updateConnectivityStatus(isOnMobileData: Boolean, isOnWifi: Boolean) {
|
||||
if (onMobileData.value != isOnMobileData) {
|
||||
onMobileData.value = isOnMobileData
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,10 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
// PushNotification Wraps don't include a receiver.
|
||||
// Test with all logged in accounts
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
if (acc != null && (acc.keyPair.privKey != null || acc.loginWithExternalSigner)) {
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
if (it.hasPrivKey || it.loggedInWithExternalSigner) {
|
||||
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,9 +73,6 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
} else if (key != null) {
|
||||
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
|
||||
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
|
||||
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
|
||||
|
||||
LocalCache.justConsume(notificationEvent, null)
|
||||
|
||||
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
|
||||
|
||||
@@ -18,17 +18,20 @@ class RegisterAccounts(
|
||||
) {
|
||||
|
||||
// creates proof that it controls all accounts
|
||||
private fun signEventsToProveControlOfAccounts(
|
||||
private suspend fun signEventsToProveControlOfAccounts(
|
||||
accounts: List<AccountInfo>,
|
||||
notificationToken: String
|
||||
): List<RelayAuthEvent> {
|
||||
return accounts.mapNotNull {
|
||||
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
|
||||
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
|
||||
if (acc != null && (acc.isWriteable() || acc.loginWithExternalSigner)) {
|
||||
if (acc.loginWithExternalSigner) {
|
||||
ExternalSignerUtils.account = acc
|
||||
}
|
||||
val relayToUse = acc.activeRelays()?.firstOrNull { it.read }
|
||||
|
||||
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)
|
||||
} else {
|
||||
@@ -62,7 +65,12 @@ class RegisterAccounts(
|
||||
it.isSuccessful
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
Log.e("FirebaseMsgService", "Unable to register with push server", e)
|
||||
val tag = if (BuildConfig.FLAVOR == "play") {
|
||||
"FirebaseMsgService"
|
||||
} else {
|
||||
"UnifiedPushService"
|
||||
}
|
||||
Log.e(tag, "Unable to register with push server", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
package com.vitorpamplona.amethyst.service.playback
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
@@ -84,6 +84,7 @@ class MultiPlayerPlaybackManager(
|
||||
playingMap.put(id, mediaSession)
|
||||
} else {
|
||||
player.setWakeMode(C.WAKE_MODE_NONE)
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
cache.put(id, mediaSession)
|
||||
playingMap.remove(id, mediaSession)
|
||||
}
|
||||
@@ -92,7 +93,10 @@ class MultiPlayerPlaybackManager(
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
when (playbackState) {
|
||||
STATE_IDLE -> {
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
// only saves if it wqs playing
|
||||
if (abs(player.currentPosition) > 1) {
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
STATE_READY -> {
|
||||
cachedPositions.get(uri)?.let { lastPosition ->
|
||||
@@ -102,7 +106,10 @@ class MultiPlayerPlaybackManager(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
// only saves if it wqs playing
|
||||
if (abs(player.currentPosition) > 1) {
|
||||
cachedPositions.add(uri, player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
package com.vitorpamplona.amethyst.service.playback
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
package com.vitorpamplona.amethyst.service.playback
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -8,6 +8,7 @@ import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
@UnstableApi // Extend MediaSessionService
|
||||
@@ -23,7 +24,9 @@ class PlaybackService : MediaSessionService() {
|
||||
}
|
||||
|
||||
fun newProgressiveDataSource(): MediaSource.Factory {
|
||||
return ProgressiveMediaSource.Factory(VideoCache.get(Amethyst.instance, HttpClient.getHttpClient()))
|
||||
return ProgressiveMediaSource.Factory(
|
||||
(applicationContext as Amethyst).videoCache.get(HttpClient.getHttpClient())
|
||||
)
|
||||
}
|
||||
|
||||
fun lazyHlsDS(): MultiPlayerPlaybackManager {
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
package com.vitorpamplona.amethyst.service.playback
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
@@ -10,7 +10,8 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
@UnstableApi object VideoCache {
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class VideoCache {
|
||||
|
||||
var exoPlayerCacheSize: Long = 150 * 1024 * 1024 // 90MB
|
||||
|
||||
@@ -42,11 +43,7 @@ import java.io.File
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(context: Context, client: OkHttpClient): CacheDataSource.Factory {
|
||||
if (!this::simpleCache.isInitialized) {
|
||||
initFileCache(context)
|
||||
}
|
||||
|
||||
fun get(client: OkHttpClient): CacheDataSource.Factory {
|
||||
// Renews the factory because OkHttpMight have changed.
|
||||
renewCacheFactory(client)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
package com.vitorpamplona.amethyst.service.playback
|
||||
|
||||
import android.util.LruCache
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Locale
|
||||
|
||||
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
@@ -6,7 +6,6 @@ import kotlinx.coroutines.withContext
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
import java.lang.IllegalArgumentException
|
||||
|
||||
private const val ELEMENT_TAG_META = "meta"
|
||||
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
|
||||
@@ -151,7 +151,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
|
||||
override fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: 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) {
|
||||
@@ -207,7 +207,7 @@ object Client : RelayPool.Listener {
|
||||
/**
|
||||
* Connected to or disconnected from a relay
|
||||
*/
|
||||
open fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) = Unit
|
||||
open fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?) = Unit
|
||||
|
||||
/**
|
||||
* When an relay saves or rejects a new event.
|
||||
|
||||
@@ -43,4 +43,8 @@ class EOSEAccount(var users: Map<User, EOSEFollowList> = emptyMap()) {
|
||||
followList.addOrUpdate(listCode, relayUrl, time)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDataFor(user: User) {
|
||||
users = users.minus(user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.EventInterface
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
@@ -60,6 +61,8 @@ class Relay(
|
||||
|
||||
var afterEOSE = false
|
||||
|
||||
val authResponse = mutableMapOf<HexKey, Boolean>()
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -77,9 +80,7 @@ class Relay(
|
||||
checkNotInMainThread()
|
||||
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
renewFilters()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ class Relay(
|
||||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
onConnected(this@Relay)
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.CONNECT, null) }
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
@@ -151,7 +152,7 @@ class Relay(
|
||||
listeners.forEach {
|
||||
it.onRelayStateChange(
|
||||
this@Relay,
|
||||
Type.DISCONNECTING,
|
||||
StateType.DISCONNECTING,
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -162,7 +163,7 @@ class Relay(
|
||||
|
||||
markConnectionAsClosed()
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, StateType.DISCONNECT, null) }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
@@ -210,22 +211,34 @@ class Relay(
|
||||
listeners.forEach {
|
||||
it.onEvent(this@Relay, channel, event)
|
||||
if (afterEOSE) {
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
afterEOSE = true
|
||||
// Log.w("Relay", "Relay onEOSE $url, $channel")
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
it.onRelayStateChange(this@Relay, StateType.EOSE, channel)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
Log.w("Relay", "Relay on OK $url, ${msgArray[1].asText()}, ${msgArray[2].asBoolean()}, ${msgArray[3].asText()}")
|
||||
it.onSendResponse(this@Relay, msgArray[1].asText(), msgArray[2].asBoolean(), msgArray[3].asText())
|
||||
val eventId = msgArray[1].asText()
|
||||
val success = msgArray[2].asBoolean()
|
||||
val message = msgArray[3].asText()
|
||||
|
||||
if (authResponse.containsKey(eventId)) {
|
||||
val wasAlreadyAuthenticated = authResponse.get(eventId)
|
||||
authResponse.put(eventId, success)
|
||||
if (wasAlreadyAuthenticated != true && success) {
|
||||
renewFilters()
|
||||
}
|
||||
}
|
||||
|
||||
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}")
|
||||
@@ -243,7 +256,8 @@ class Relay(
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
// httpClient.dispatcher.executorService.shutdown()
|
||||
checkNotInMainThread()
|
||||
|
||||
closingTimeInSeconds = TimeUtils.now()
|
||||
socket?.close(1000, "Normal close")
|
||||
socket = null
|
||||
@@ -290,10 +304,18 @@ class Relay(
|
||||
}
|
||||
}
|
||||
|
||||
fun renewFilters() {
|
||||
// Force update all filters after AUTH.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
authResponse.put(signedEvent.id, false)
|
||||
// specific protocol for this event.
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
@@ -339,7 +361,7 @@ class Relay(
|
||||
activeTypes == other.activeTypes
|
||||
}
|
||||
|
||||
enum class Type {
|
||||
enum class StateType {
|
||||
// Websocket connected
|
||||
CONNECT,
|
||||
|
||||
@@ -370,6 +392,6 @@ class Relay(
|
||||
*
|
||||
* @param type is 0 for disconnect and 1 for connect
|
||||
*/
|
||||
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
|
||||
fun onRelayStateChange(relay: Relay, type: StateType, channel: String?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ object RelayPool : Relay.Listener {
|
||||
|
||||
fun onError(error: Error, subscriptionId: String, relay: Relay)
|
||||
|
||||
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
|
||||
fun onRelayStateChange(type: Relay.StateType, relay: Relay, channel: String?)
|
||||
|
||||
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
|
||||
|
||||
@@ -123,9 +123,9 @@ object RelayPool : Relay.Listener {
|
||||
updateStatus()
|
||||
}
|
||||
|
||||
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
|
||||
override fun onRelayStateChange(relay: Relay, type: Relay.StateType, channel: String?) {
|
||||
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
|
||||
if (type != Relay.Type.EOSE) {
|
||||
if (type != Relay.StateType.EOSE) {
|
||||
updateStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,28 +49,30 @@ class TextToSpeechEngine private constructor() {
|
||||
fun initTTS(context: Context, message: String) {
|
||||
tts = TextToSpeech(context) {
|
||||
if (it == TextToSpeech.SUCCESS) {
|
||||
tts!!.language = defLanguage
|
||||
tts!!.setPitch(defaultPitch)
|
||||
tts!!.setSpeechRate(defaultSpeed)
|
||||
tts!!.setListener(
|
||||
onStart = {
|
||||
onStartListener?.invoke()
|
||||
},
|
||||
onError = { e ->
|
||||
e?.let { error ->
|
||||
onErrorListener?.invoke(error)
|
||||
tts?.let {
|
||||
it.language = defLanguage
|
||||
it.setPitch(defaultPitch)
|
||||
it.setSpeechRate(defaultSpeed)
|
||||
it.setListener(
|
||||
onStart = {
|
||||
onStartListener?.invoke()
|
||||
},
|
||||
onError = { e ->
|
||||
e?.let { error ->
|
||||
onErrorListener?.invoke(error)
|
||||
}
|
||||
},
|
||||
onRange = { start, end ->
|
||||
if (this@TextToSpeechEngine.message != null) {
|
||||
onHighlightListener?.invoke(start, end)
|
||||
}
|
||||
},
|
||||
onDone = {
|
||||
onStartListener?.invoke()
|
||||
}
|
||||
},
|
||||
onRange = { start, end ->
|
||||
if (this@TextToSpeechEngine.message != null) {
|
||||
onHighlightListener?.invoke(start, end)
|
||||
}
|
||||
},
|
||||
onDone = {
|
||||
onStartListener?.invoke()
|
||||
}
|
||||
)
|
||||
speak(message)
|
||||
)
|
||||
speak(message)
|
||||
}
|
||||
} else {
|
||||
onErrorListener?.invoke(getErrorText(it))
|
||||
}
|
||||
@@ -78,7 +80,7 @@ class TextToSpeechEngine private constructor() {
|
||||
}
|
||||
|
||||
private fun speak(message: String): TextToSpeechEngine {
|
||||
tts!!.speak(
|
||||
tts?.speak(
|
||||
message,
|
||||
TextToSpeech.QUEUE_FLUSH,
|
||||
null,
|
||||
|
||||
@@ -21,7 +21,7 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
|
||||
private var message: String? = null
|
||||
|
||||
private var tts: TextToSpeechEngine? = null
|
||||
private var ttsEngine: TextToSpeechEngine? = null
|
||||
|
||||
private var onStart: (() -> Unit)? = null
|
||||
|
||||
@@ -44,19 +44,19 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
}
|
||||
|
||||
private fun initTTS() = context.get()?.run {
|
||||
tts = TextToSpeechEngine.getInstance()
|
||||
ttsEngine = TextToSpeechEngine.getInstance()
|
||||
.setOnCompletionListener { onDoneListener?.invoke() }
|
||||
.setOnErrorListener { onErrorListener?.invoke(it) }
|
||||
.setOnStartListener { onStart?.invoke() }
|
||||
}
|
||||
|
||||
fun speak(message: String): TextToSpeechHelper {
|
||||
if (tts == null) {
|
||||
if (ttsEngine == null) {
|
||||
initTTS()
|
||||
}
|
||||
this.message = message
|
||||
|
||||
tts?.initTTS(
|
||||
ttsEngine?.initTTS(
|
||||
appContext,
|
||||
message
|
||||
)
|
||||
@@ -70,8 +70,8 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
*/
|
||||
fun highlight(): TextToSpeechHelper {
|
||||
if (message == null) throw Exception("Message can't be null for highlighting !! Call speak() first")
|
||||
tts?.setHighlightedMessage(message!!)
|
||||
tts?.setOnHighlightListener { i, i2 ->
|
||||
ttsEngine?.setHighlightedMessage(message!!)
|
||||
ttsEngine?.setOnHighlightListener { i, i2 ->
|
||||
onHighlightListener?.invoke(Pair(i, i2))
|
||||
}
|
||||
return this
|
||||
@@ -86,8 +86,8 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
fun destroy(
|
||||
action: (() -> Unit) = {}
|
||||
) {
|
||||
tts?.destroy()
|
||||
tts = null
|
||||
ttsEngine?.destroy()
|
||||
ttsEngine = null
|
||||
action.invoke()
|
||||
INSTANCE = null
|
||||
}
|
||||
@@ -118,7 +118,7 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
}
|
||||
|
||||
fun setLanguage(locale: Locale): TextToSpeechHelper {
|
||||
tts?.setLanguage(locale)
|
||||
ttsEngine?.setLanguage(locale)
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -126,12 +126,12 @@ class TextToSpeechHelper private constructor(private val context: WeakReference<
|
||||
pitch: Float = DEF_SPEECH_AND_PITCH,
|
||||
speed: Float = DEF_SPEECH_AND_PITCH
|
||||
): TextToSpeechHelper {
|
||||
tts?.setPitchAndSpeed(pitch, speed)
|
||||
ttsEngine?.setPitchAndSpeed(pitch, speed)
|
||||
return this
|
||||
}
|
||||
|
||||
fun resetPitchAndSpeed(): TextToSpeechHelper {
|
||||
tts?.resetPitchAndSpeed()
|
||||
ttsEngine?.resetPitchAndSpeed()
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
@@ -14,17 +13,20 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.os.LocaleListCompat
|
||||
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.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
|
||||
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
|
||||
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
|
||||
import com.vitorpamplona.amethyst.ui.components.keepPlayingMutex
|
||||
@@ -33,7 +35,7 @@ import com.vitorpamplona.amethyst.ui.navigation.debugState
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.encoders.Nip19
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
@@ -50,46 +52,46 @@ import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val isOnMobileDataState = mutableStateOf(false)
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ExternalSignerUtils.start(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val language = LocalPreferences.getPreferredLanguage()
|
||||
if (language.isNotBlank()) {
|
||||
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)
|
||||
AppCompatDelegate.setApplicationLocales(appLocale)
|
||||
}
|
||||
|
||||
setContent {
|
||||
val themeViewModel: ThemeViewModel = viewModel()
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
|
||||
themeViewModel.onChange(LocalPreferences.getTheme())
|
||||
AmethystTheme(themeViewModel) {
|
||||
val displayFeatures = calculateDisplayFeatures(this)
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
}
|
||||
|
||||
LaunchedEffect(isOnMobileDataState) {
|
||||
sharedPreferencesViewModel.updateConnectivityStatusState(isOnMobileDataState)
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(this@MainActivity)
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, themeViewModel)
|
||||
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.build()
|
||||
|
||||
val connectivityManager =
|
||||
getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.requestNetwork(networkRequest, networkCallback)
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@@ -100,26 +102,38 @@ class MainActivity : AppCompatActivity() {
|
||||
DefaultMutedSetting.value = true
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.start(this@MainActivity)
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start()
|
||||
}
|
||||
}
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
PushNotificationUtils.init(LocalPreferences.allSavedAccounts())
|
||||
}
|
||||
|
||||
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager).registerDefaultNetworkCallback(networkCallback)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
LanguageTranslatorService.clear()
|
||||
ServiceManager.cleanObservers()
|
||||
|
||||
// if (BuildConfig.DEBUG) {
|
||||
debugState(this)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
debugState(this@MainActivity)
|
||||
}
|
||||
// }
|
||||
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.pause()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.pause()
|
||||
}
|
||||
}
|
||||
|
||||
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
|
||||
.unregisterNetworkCallback(networkCallback)
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
@@ -145,19 +159,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
// network is available for use
|
||||
override fun onAvailable(network: Network) {
|
||||
super.onAvailable(network)
|
||||
Log.d("NETWORKCALLBACK", "onAvailable: Disconnecting and connecting again")
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start(this@MainActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Network capabilities have changed for the network
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
@@ -166,26 +167,14 @@ class MainActivity : AppCompatActivity() {
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val hasMobileData =
|
||||
networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
val hasWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasMobileData $hasMobileData")
|
||||
Log.d("NETWORKCALLBACK", "onCapabilitiesChanged: hasWifi $hasWifi")
|
||||
ConnectivityStatus.updateConnectivityStatus(
|
||||
hasMobileData,
|
||||
hasWifi
|
||||
)
|
||||
}
|
||||
}
|
||||
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")
|
||||
|
||||
// lost network connection
|
||||
override fun onLost(network: Network) {
|
||||
super.onLost(network)
|
||||
Log.d("NETWORKCALLBACK", "onLost: Disconnecting and pausing relay's connection")
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (ServiceManager.shouldPauseService) {
|
||||
ServiceManager.pause()
|
||||
if (isOnMobileDataState.value != isOnMobileData) {
|
||||
isOnMobileDataState.value = isOnMobileData
|
||||
|
||||
ServiceManager.forceRestartIfItShould()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
import okio.buffer
|
||||
|
||||
@@ -10,8 +10,13 @@ import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import okio.BufferedSink
|
||||
import okio.IOException
|
||||
import okio.source
|
||||
|
||||
@@ -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
|
||||
@@ -28,7 +27,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -52,25 +50,24 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
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
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -85,7 +82,7 @@ import kotlinx.coroutines.withContext
|
||||
@Composable
|
||||
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val searchBarViewModel: SearchBarViewModel = viewModel(
|
||||
key = accountViewModel.account.userProfile().pubkeyHex + "SearchBarViewModel",
|
||||
key = "SearchBarViewModel",
|
||||
factory = SearchBarViewModel.Factory(
|
||||
accountViewModel.account
|
||||
)
|
||||
@@ -302,15 +299,11 @@ private fun RenderSearchResults(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (searchBarViewModel.isSearching) {
|
||||
val users by searchBarViewModel.searchResultsUsers.collectAsState()
|
||||
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val users by searchBarViewModel.searchResultsUsers.collectAsStateWithLifecycle()
|
||||
val channels by searchBarViewModel.searchResultsChannels.collectAsStateWithLifecycle()
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -321,10 +314,7 @@ private fun RenderSearchResults(
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
contentPadding = FeedPadding,
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(
|
||||
@@ -332,10 +322,8 @@ private fun RenderSearchResults(
|
||||
key = { _, item -> "u" + item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserComposeForChat(item, accountViewModel) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val withKey = ChatroomKey(persistentSetOf(item.pubkeyHex))
|
||||
accountViewModel.userProfile().createChatroom(withKey)
|
||||
nav("Room/${withKey.hashCode()}")
|
||||
accountViewModel.createChatRoomFor(item) {
|
||||
nav("Room/$it")
|
||||
}
|
||||
|
||||
searchBarViewModel.clear()
|
||||
@@ -421,7 +409,7 @@ fun UserComposeForChat(
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@@ -3,8 +3,13 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
||||
@@ -12,8 +12,6 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@@ -9,9 +9,26 @@ import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -31,11 +48,27 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -68,6 +101,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
@@ -82,7 +116,15 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.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.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.ZapRaiserRequest
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtensions
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtensions
|
||||
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.CancelIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.CloseIcon
|
||||
@@ -101,6 +143,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font14SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
@@ -129,8 +172,6 @@ fun NewPostView(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val account = remember(accountViewModel) { accountViewModel.account }
|
||||
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
postViewModel.wantsDirectMessage = enableMessageInterface
|
||||
|
||||
@@ -141,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)
|
||||
@@ -344,10 +387,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
|
||||
@@ -371,7 +414,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 = {
|
||||
@@ -545,7 +588,7 @@ private fun PollField(postViewModel: NewPostViewModel) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
modifier = Size18Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -907,7 +950,7 @@ fun LocationAsHash(postViewModel: NewPostViewModel) {
|
||||
|
||||
@Composable
|
||||
fun DisplayLocationObserver(geoLocation: Flow<String>) {
|
||||
val location by geoLocation.collectAsState(initial = null)
|
||||
val location by geoLocation.collectAsStateWithLifecycle(null)
|
||||
|
||||
location?.let {
|
||||
DisplayLocationInTitle(geohash = it)
|
||||
@@ -1236,12 +1279,9 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier =
|
||||
Button(
|
||||
modifier = modifier,
|
||||
enabled = isActive,
|
||||
onClick = {
|
||||
onPost()
|
||||
},
|
||||
shape = ButtonBorder
|
||||
onClick = onPost
|
||||
) {
|
||||
Text(text = stringResource(R.string.post), color = Color.White)
|
||||
Text(text = stringResource(R.string.post))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -14,7 +15,11 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ServersAvailable
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.LocationUtil
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
@@ -146,7 +151,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
|
||||
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
@@ -278,6 +283,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(
|
||||
@@ -318,28 +325,30 @@ open class NewPostViewModel() : ViewModel() {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
|
||||
}
|
||||
} else {
|
||||
ImageUploader.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (isNIP94Server(server)) {
|
||||
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ImageUploader.uploadImage(
|
||||
uri = fileUri,
|
||||
contentType = contentType,
|
||||
size = size,
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (isNIP94Server(server)) {
|
||||
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
@@ -552,9 +561,9 @@ open class NewPostViewModel() : ViewModel() {
|
||||
isUploadingImage = false
|
||||
|
||||
if (note == null) {
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
message = TextFieldValue(message.text + "\n" + imageUrl)
|
||||
} else {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent())
|
||||
message = TextFieldValue(message.text + "\nnostr:" + note.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
@@ -584,7 +593,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
isUploadingImage = false
|
||||
|
||||
note?.let {
|
||||
message = TextFieldValue(message.text + "\n\nnostr:" + it.toNEvent())
|
||||
message = TextFieldValue(message.text + "\nnostr:" + it.toNEvent())
|
||||
}
|
||||
|
||||
urlPreview = findUrlInMessage()
|
||||
@@ -625,6 +634,7 @@ open class NewPostViewModel() : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
locUtil?.stop()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -38,7 +37,6 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -56,19 +54,20 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.Nip11Retriever
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants.defaultRelays
|
||||
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
|
||||
@@ -89,7 +88,7 @@ import java.lang.Math.round
|
||||
@Composable
|
||||
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "", nav: (String) -> Unit) {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
val feedState by postViewModel.relays.collectAsState()
|
||||
val feedState by postViewModel.relays.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(accountViewModel.account)
|
||||
@@ -104,9 +103,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 10.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -124,7 +121,7 @@ fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, re
|
||||
Text(stringResource(R.string.default_relays))
|
||||
}
|
||||
|
||||
PostButton(
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
@@ -157,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(
|
||||
@@ -255,7 +249,7 @@ fun ServerConfigHeader() {
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -304,7 +298,6 @@ fun ServerConfig(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
relayInfo?.let {
|
||||
@@ -318,16 +311,12 @@ fun ServerConfig(
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
ServerConfigClickableLine(
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
item = item,
|
||||
loadProfilePicture = automaticallyShowProfilePicture,
|
||||
onToggleDownload = onToggleDownload,
|
||||
onToggleUpload = onToggleUpload,
|
||||
onToggleFollows = onToggleFollows,
|
||||
@@ -362,8 +351,8 @@ fun ServerConfig(
|
||||
|
||||
@Composable
|
||||
fun ServerConfigClickableLine(
|
||||
loadProfilePicture: Boolean,
|
||||
item: RelaySetupInfo,
|
||||
loadProfilePicture: Boolean,
|
||||
onToggleDownload: (RelaySetupInfo) -> Unit,
|
||||
onToggleUpload: (RelaySetupInfo) -> Unit,
|
||||
onToggleFollows: (RelaySetupInfo) -> Unit,
|
||||
@@ -417,7 +406,7 @@ fun ServerConfigClickableLine(
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
|
||||
@@ -26,10 +26,8 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.RelayInformation
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
@@ -51,11 +49,7 @@ fun RelayInformationDialog(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
Dialog(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,12 +9,22 @@ import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.buttons
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
@@ -8,7 +11,11 @@ import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,15 +7,23 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.requiredHeight
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.DrawStyle
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -81,13 +81,17 @@ class BundledInsert<T>(
|
||||
try {
|
||||
val mySet = mutableSetOf<T>()
|
||||
queue.drainTo(mySet)
|
||||
onUpdate(mySet)
|
||||
if (mySet.isNotEmpty()) {
|
||||
onUpdate(mySet)
|
||||
}
|
||||
|
||||
delay(delay)
|
||||
|
||||
val mySet2 = mutableSetOf<T>()
|
||||
queue.drainTo(mySet2)
|
||||
onUpdate(mySet2)
|
||||
if (mySet2.isNotEmpty()) {
|
||||
onUpdate(mySet2)
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
onlyOneInBlock.set(false)
|
||||
|
||||
@@ -9,8 +9,20 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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
|
||||
|
||||
@@ -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]")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,19 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -12,8 +17,15 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -22,7 +34,12 @@ import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
@@ -132,13 +149,16 @@ private fun RenderRegular(
|
||||
}
|
||||
|
||||
val currentTextStyle = LocalTextStyle.current
|
||||
val currentTextColor = LocalContentColor.current
|
||||
|
||||
val textStyle = currentTextStyle.copy(
|
||||
lineHeight = 1.4.em,
|
||||
color = currentTextStyle.color.takeOrElse {
|
||||
LocalContentColor.current
|
||||
}
|
||||
)
|
||||
val textStyle = remember(currentTextStyle, currentTextColor) {
|
||||
currentTextStyle.copy(
|
||||
lineHeight = 1.4.em,
|
||||
color = currentTextStyle.color.takeOrElse {
|
||||
currentTextColor
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
MeasureSpaceWidth() { spaceWidth ->
|
||||
Column() {
|
||||
@@ -302,10 +322,8 @@ private fun NoProtocolUrlRenderer(word: SchemelessUrlSegment) {
|
||||
|
||||
@Composable
|
||||
private fun RenderUrl(segment: SchemelessUrlSegment) {
|
||||
Row() {
|
||||
ClickableUrl(segment.url, "https://${segment.url}")
|
||||
segment.extras?.let { it1 -> Text(it1) }
|
||||
}
|
||||
ClickableUrl(segment.url, "https://${segment.url}")
|
||||
segment.extras?.let { it1 -> Text(it1) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -529,9 +547,7 @@ private fun DisplayFullNote(
|
||||
|
||||
@Composable
|
||||
fun HashTag(word: HashTagSegment, nav: (String) -> Unit) {
|
||||
Row() {
|
||||
RenderHashtag(word, nav)
|
||||
}
|
||||
RenderHashtag(word, nav)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -540,39 +556,53 @@ private fun RenderHashtag(
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val hashtagIcon = remember(segment.hashtag) { checkForHashtagWithIcon(segment.hashtag, primary) }
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary).toSpanStyle()
|
||||
) {
|
||||
val background = MaterialTheme.colorScheme.onBackground
|
||||
val hashtagIcon = remember(segment.hashtag) {
|
||||
checkForHashtagWithIcon(segment.hashtag, primary)
|
||||
}
|
||||
|
||||
val regularText = remember { SpanStyle(color = background) }
|
||||
val clickableTextStyle = remember { SpanStyle(color = primary) }
|
||||
|
||||
val annotatedTermsString = remember {
|
||||
buildAnnotatedString {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
append("#${segment.hashtag}")
|
||||
}
|
||||
},
|
||||
onClick = { nav("Hashtag/${segment.hashtag}") }
|
||||
)
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
if (hashtagIcon != null) {
|
||||
withStyle(clickableTextStyle) {
|
||||
pushStringAnnotation("routeToHashtag", "")
|
||||
appendInlineContent("inlineContent", "[icon]")
|
||||
}
|
||||
}
|
||||
|
||||
segment.extras?.ifBlank { "" }?.let {
|
||||
withStyle(regularText) {
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
inlineContent = mapOf(
|
||||
myId to InlineIcon(hashtagIcon)
|
||||
)
|
||||
)
|
||||
}
|
||||
segment.extras?.ifBlank { "" }?.let {
|
||||
Text(text = it)
|
||||
|
||||
val inlineContent = if (hashtagIcon != null) {
|
||||
mapOf("inlineContent" to InlineIcon(hashtagIcon))
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val pressIndicator = remember {
|
||||
Modifier.clickable {
|
||||
nav("Hashtag/${segment.hashtag}")
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedTermsString,
|
||||
modifier = pressIndicator,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -11,9 +17,19 @@ import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import coil.decode.DataSource
|
||||
import coil.fetch.DrawableResult
|
||||
import coil.fetch.FetchResult
|
||||
import coil.fetch.Fetcher
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import java.util.Base64
|
||||
|
||||
@Composable
|
||||
fun RobohashAsyncImage(
|
||||
@@ -67,19 +83,40 @@ fun RobohashFallbackAsyncImage(
|
||||
)
|
||||
|
||||
if (model != null && loadProfilePicture) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
val isBase64 by remember {
|
||||
derivedStateOf {
|
||||
model.startsWith("data:image/jpeg;base64,")
|
||||
}
|
||||
}
|
||||
|
||||
if (isBase64) {
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = Base64Requester.imageRequest(context, model)
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = painter,
|
||||
contentDescription = null,
|
||||
modifier = modifier,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
placeholder = painter,
|
||||
fallback = painter,
|
||||
error = painter,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Image(
|
||||
painter = painter,
|
||||
@@ -118,3 +155,45 @@ fun RobohashAsyncImageProxy(
|
||||
loadProfilePicture = loadProfilePicture
|
||||
)
|
||||
}
|
||||
|
||||
object Base64Requester {
|
||||
fun imageRequest(context: Context, message: String): ImageRequest {
|
||||
return ImageRequest
|
||||
.Builder(context)
|
||||
.data(message)
|
||||
.fetcherFactory(Base64Fetcher.Factory)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
class Base64Fetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val base64String = data.toString().removePrefix("data:image/jpeg;base64,")
|
||||
|
||||
val byteArray = Base64.getDecoder().decode(base64String)
|
||||
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
|
||||
|
||||
if (bitmap == null) {
|
||||
throw Exception("Unable to load base64 $base64String")
|
||||
}
|
||||
|
||||
return DrawableResult(
|
||||
drawable = bitmap.toDrawable(options.context.resources),
|
||||
isSampled = false,
|
||||
dataSource = DataSource.MEMORY
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Fetcher.Factory<Uri> {
|
||||
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
|
||||
return Base64Fetcher(options, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -29,8 +29,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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
|
||||
|
||||
@@ -85,11 +87,13 @@ fun TextSpinner(
|
||||
|
||||
@Composable
|
||||
fun SpinnerSelectionDialog(
|
||||
title: String? = null,
|
||||
options: ImmutableList<TitleExplainer>,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
SpinnerSelectionDialog(
|
||||
title = title,
|
||||
options = options,
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss
|
||||
@@ -104,8 +108,7 @@ fun SpinnerSelectionDialog(
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray, fontSize = Font14SP)
|
||||
}
|
||||
@@ -115,6 +118,7 @@ fun SpinnerSelectionDialog(
|
||||
|
||||
@Composable
|
||||
fun <T> SpinnerSelectionDialog(
|
||||
title: String? = null,
|
||||
options: ImmutableList<T>,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -126,21 +130,38 @@ fun <T> SpinnerSelectionDialog(
|
||||
shape = RoundedCornerShape(5.dp)
|
||||
) {
|
||||
LazyColumn() {
|
||||
title?.let {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 16.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Divider(color = Color.LightGray, thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
itemsIndexed(options) { index, item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 16.dp)
|
||||
.clickable {
|
||||
onSelect(index)
|
||||
}
|
||||
.padding(16.dp, 16.dp)
|
||||
) {
|
||||
Column() {
|
||||
onRenderItem(item)
|
||||
}
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
Divider(color = Color.LightGray, thickness = 0.25.dp)
|
||||
Divider(color = Color.LightGray, thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,13 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
|
||||
val automaticallyShowUrlPreview = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowUrlPreview) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showUrlPreview.value
|
||||
}
|
||||
|
||||
if (!automaticallyShowUrlPreview) {
|
||||
|
||||
@@ -17,8 +17,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@@ -33,11 +31,7 @@ fun UrlPreviewCard(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val automaticallyShowUrlPreview = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowUrlPreview) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showUrlPreview.value
|
||||
}
|
||||
|
||||
if (!automaticallyShowUrlPreview) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -29,6 +28,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -44,11 +44,15 @@ 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.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
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -59,12 +63,8 @@ import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.linc.audiowaveform.infiniteLinearGradient
|
||||
import com.vitorpamplona.amethyst.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.MuteIcon
|
||||
@@ -98,24 +98,23 @@ fun LoadThumbAndThenVideoView(
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val request = ImageRequest.Builder(context).data(thumbUri).build()
|
||||
val myCover = context.imageLoader.execute(request).drawable
|
||||
if (myCover != null) {
|
||||
loadingFinished = Pair(true, myCover)
|
||||
accountViewModel.loadThumb(
|
||||
context,
|
||||
thumbUri,
|
||||
onReady = {
|
||||
if (it != null) {
|
||||
loadingFinished = Pair(true, it)
|
||||
} else {
|
||||
loadingFinished = Pair(true, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("VideoView", "Fail to load cover $thumbUri", e)
|
||||
},
|
||||
onError = {
|
||||
loadingFinished = Pair(true, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (loadingFinished.first) {
|
||||
@@ -153,29 +152,33 @@ fun VideoView(
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
roundedCorner: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwaysShowVideo: Boolean = false
|
||||
) {
|
||||
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
|
||||
|
||||
VideoViewInner(
|
||||
videoUri,
|
||||
defaultToStart,
|
||||
title,
|
||||
thumb,
|
||||
roundedCorner,
|
||||
waveform,
|
||||
artworkUri,
|
||||
authorName,
|
||||
nostrUriCallback,
|
||||
alwaysShowVideo,
|
||||
accountViewModel,
|
||||
onDialog
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
title = title,
|
||||
thumb = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
artworkUri = artworkUri,
|
||||
authorName = authorName,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
alwaysShowVideo = alwaysShowVideo,
|
||||
accountViewModel = accountViewModel,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,30 +190,26 @@ fun VideoViewInner(
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
roundedCorner: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
artworkUri: String? = null,
|
||||
authorName: String? = null,
|
||||
nostrUriCallback: String? = null,
|
||||
alwaysShowVideo: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)? = null
|
||||
) {
|
||||
val automaticallyStartPlayback = remember {
|
||||
mutableStateOf(
|
||||
if (alwaysShowVideo) { true } else {
|
||||
when (accountViewModel.account.settings.automaticallyStartPlayback) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
}
|
||||
mutableStateOf<Boolean>(
|
||||
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
|
||||
)
|
||||
}
|
||||
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
|
||||
} else {
|
||||
VideoPlayerActiveMutex(videoUri) { activeOnScreen ->
|
||||
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
|
||||
val mediaItem = remember(videoUri) {
|
||||
mutableStateOf(
|
||||
MediaItem.Builder()
|
||||
@@ -247,10 +246,13 @@ fun VideoViewInner(
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
waveform = waveform,
|
||||
keepPlaying = keepPlaying,
|
||||
automaticallyStartPlayback = automaticallyStartPlayback,
|
||||
activeOnScreen = activeOnScreen,
|
||||
modifier = modifier,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
onDialog = onDialog
|
||||
)
|
||||
}
|
||||
@@ -449,7 +451,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()
|
||||
}
|
||||
@@ -498,9 +500,7 @@ fun VideoPlayerActiveMutex(videoUri: String, inner: @Composable (MutableState<Bo
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = myModifier) {
|
||||
inner(active)
|
||||
}
|
||||
inner(myModifier, active)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -514,10 +514,13 @@ private fun RenderVideoPlayer(
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
roundedCorner: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
activeOnScreen: MutableState<Boolean>,
|
||||
modifier: Modifier,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onDialog: ((Boolean) -> Unit)?
|
||||
) {
|
||||
ControlWhenPlayerIsActive(controller, keepPlaying, automaticallyStartPlayback, activeOnScreen)
|
||||
@@ -526,92 +529,137 @@ private fun RenderVideoPlayer(
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
BoxWithConstraints() {
|
||||
val borders = MaterialTheme.colorScheme.imageModifier
|
||||
val parentVideoPlaybackSize = remember {
|
||||
mutableStateOf<IntSize>(IntSize.Zero)
|
||||
}
|
||||
|
||||
val myModifier = remember {
|
||||
if (roundedCorner) {
|
||||
borders
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.align(Alignment.Center)
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.align(Alignment.Center)
|
||||
}
|
||||
val videoPlaybackSize = remember {
|
||||
mutableStateOf<IntSize>(IntSize.Zero)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize().onSizeChanged {
|
||||
parentVideoPlaybackSize.value = it
|
||||
}
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.onSizeChanged {
|
||||
videoPlaybackSize.value = it
|
||||
}
|
||||
) {
|
||||
val borders = MaterialTheme.colorScheme.imageModifier
|
||||
|
||||
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 {
|
||||
controllerVisible.value = it == View.VISIBLE
|
||||
}
|
||||
val myModifier = remember {
|
||||
if (roundedCorner) {
|
||||
modifier.then(
|
||||
borders
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
} else {
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 100.dp)
|
||||
.align(Alignment.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory
|
||||
)
|
||||
|
||||
waveform?.let {
|
||||
Waveform(it, controller, remember { Modifier.align(Alignment.Center) })
|
||||
}
|
||||
|
||||
val startingMuteState = remember(controller) {
|
||||
controller.volume < 0.001
|
||||
}
|
||||
|
||||
MuteButton(controllerVisible, startingMuteState) { 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) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
keepPlayingMutex = null
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
AndroidView(
|
||||
modifier = myModifier,
|
||||
factory = factory
|
||||
)
|
||||
|
||||
KeepPlayingButton(keepPlaying, controllerVisible, remember { Modifier.align(Alignment.TopEnd) }) { newKeepPlaying: Boolean ->
|
||||
// If something else is playing and the user marks this video to keep playing, stops the other one.
|
||||
if (newKeepPlaying) {
|
||||
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
waveform?.let {
|
||||
Waveform(it, controller, remember { Modifier.align(Alignment.Center) })
|
||||
}
|
||||
|
||||
val startingMuteState = remember(controller) {
|
||||
controller.volume < 0.001
|
||||
}
|
||||
|
||||
val spaceModifier = remember {
|
||||
if (topPaddingForControllers.isSpecified && videoPlaybackSize.value.height > 0) {
|
||||
val space = (abs(parentVideoPlaybackSize.value.height - videoPlaybackSize.value.height) / 2).dp
|
||||
if (space > topPaddingForControllers) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.padding(top = topPaddingForControllers - space)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
}
|
||||
|
||||
MuteButton(
|
||||
controllerVisible,
|
||||
startingMuteState,
|
||||
spaceModifier
|
||||
) { 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) {
|
||||
keepPlayingMutex?.stop()
|
||||
keepPlayingMutex?.release()
|
||||
}
|
||||
keepPlayingMutex = controller
|
||||
} else {
|
||||
if (keepPlayingMutex == controller) {
|
||||
keepPlayingMutex = null
|
||||
}
|
||||
|
||||
controller.volume = if (mute) 0f else 1f
|
||||
}
|
||||
|
||||
keepPlaying.value = newKeepPlaying
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -683,32 +731,34 @@ fun DrawWaveform(waveform: ImmutableList<Int>, waveformProgress: MutableState<Fl
|
||||
fun ControlWhenPlayerIsActive(
|
||||
controller: Player,
|
||||
keepPlaying: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: MutableState<Boolean>,
|
||||
automaticallyStartPlayback: State<Boolean>,
|
||||
activeOnScreen: MutableState<Boolean>
|
||||
) {
|
||||
// active means being fully visible
|
||||
if (activeOnScreen.value) {
|
||||
// should auto start video from settings?
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
if (controller.isPlaying) {
|
||||
// if it is visible, it's playing but it wasn't supposed to start automatically.
|
||||
LaunchedEffect(key1 = activeOnScreen.value) {
|
||||
// active means being fully visible
|
||||
if (activeOnScreen.value) {
|
||||
// should auto start video from settings?
|
||||
if (!automaticallyStartPlayback.value) {
|
||||
if (controller.isPlaying) {
|
||||
// if it is visible, it's playing but it wasn't supposed to start automatically.
|
||||
controller.pause()
|
||||
}
|
||||
} else if (!controller.isPlaying) {
|
||||
// if it is visible, was supposed to start automatically, but it's not
|
||||
|
||||
// If something else is playing, play on mute.
|
||||
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
controller.volume = 0f
|
||||
}
|
||||
controller.play()
|
||||
}
|
||||
} else {
|
||||
// Pauses the video when it becomes invisible.
|
||||
// Destroys the video later when it Disposes the element
|
||||
// meanwhile if the user comes back, the position in the track is saved.
|
||||
if (!keepPlaying.value) {
|
||||
controller.pause()
|
||||
}
|
||||
} else if (!controller.isPlaying) {
|
||||
// if it is visible, was supposed to start automatically, but it's not
|
||||
|
||||
// If something else is playing, play on mute.
|
||||
if (keepPlayingMutex != null && keepPlayingMutex != controller) {
|
||||
controller.volume = 0f
|
||||
}
|
||||
controller.play()
|
||||
}
|
||||
} else {
|
||||
// Pauses the video when it becomes invisible.
|
||||
// Destroys the video later when it Disposes the element
|
||||
// meanwhile if the user comes back, the position in the track is saved.
|
||||
if (!keepPlaying.value) {
|
||||
controller.pause()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,6 +819,7 @@ fun LayoutCoordinates.getDistanceToVertCenterIfVisible(view: View): Float? {
|
||||
private fun MuteButton(
|
||||
controllerVisible: MutableState<Boolean>,
|
||||
startingMuteState: Boolean,
|
||||
modifier: Modifier,
|
||||
toggle: (Boolean) -> Unit
|
||||
) {
|
||||
val holdOn = remember {
|
||||
@@ -788,8 +839,9 @@ private fun MuteButton(
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
modifier = modifier,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() }
|
||||
) {
|
||||
Box(modifier = VolumeBottomIconSize) {
|
||||
Box(
|
||||
@@ -829,8 +881,8 @@ private fun KeepPlayingButton(
|
||||
AnimatedVisibility(
|
||||
visible = controllerVisible.value,
|
||||
modifier = alignment,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() }
|
||||
) {
|
||||
Box(modifier = PinBottomIconSize) {
|
||||
Box(
|
||||
|
||||
@@ -11,8 +11,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@@ -5,7 +5,13 @@ import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@@ -35,7 +41,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
|
||||
@@ -45,6 +50,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -53,16 +59,21 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
@@ -72,9 +83,7 @@ import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
@@ -89,6 +98,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
@@ -231,7 +241,7 @@ fun ZoomableContentView(
|
||||
}
|
||||
|
||||
when (content) {
|
||||
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel)
|
||||
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier, accountViewModel = accountViewModel)
|
||||
is ZoomableUrlVideo -> VideoView(
|
||||
videoUri = content.url,
|
||||
title = content.description,
|
||||
@@ -243,7 +253,7 @@ fun ZoomableContentView(
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
|
||||
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel)
|
||||
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier, accountViewModel = accountViewModel)
|
||||
is ZoomableLocalVideo ->
|
||||
content.localFile?.let {
|
||||
VideoView(
|
||||
@@ -268,21 +278,15 @@ fun ZoomableContentView(
|
||||
private fun LocalImageView(
|
||||
content: ZoomableLocalImage,
|
||||
mainImageModifier: Modifier,
|
||||
accountViewModel: AccountViewModel?,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false
|
||||
) {
|
||||
if (content.localFile != null && content.localFile.exists()) {
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val showImage = remember {
|
||||
mutableStateOf(
|
||||
if (alwayShowImage) { true } else {
|
||||
when (accountViewModel?.account?.settings?.automaticallyShowImages) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
if (alwayShowImage) true else accountViewModel.settings.showImages.value
|
||||
)
|
||||
}
|
||||
|
||||
@@ -301,7 +305,13 @@ private fun LocalImageView(
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
|
||||
val verifierModifier = Modifier.align(Alignment.TopEnd)
|
||||
val verifierModifier = if (topPaddingForControllers.isSpecified) {
|
||||
Modifier
|
||||
.padding(top = topPaddingForControllers)
|
||||
.align(Alignment.TopEnd)
|
||||
} else {
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
}
|
||||
|
||||
val painterState = remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
@@ -337,20 +347,14 @@ private fun LocalImageView(
|
||||
private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
mainImageModifier: Modifier,
|
||||
accountViewModel: AccountViewModel?,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
accountViewModel: AccountViewModel,
|
||||
alwayShowImage: Boolean = false
|
||||
) {
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val showImage = remember {
|
||||
mutableStateOf(
|
||||
if (alwayShowImage) { true } else {
|
||||
when (accountViewModel?.account?.settings?.automaticallyShowImages) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
mutableStateOf<Boolean>(
|
||||
if (alwayShowImage) true else accountViewModel.settings.showImages.value
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,18 +362,26 @@ private fun UrlImageView(
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
.heightIn(max = maxHeight)
|
||||
/* Is this necessary? It makes images bleed into other pages
|
||||
.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, false)
|
||||
} ?: this
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
val contentScale = remember {
|
||||
if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
}
|
||||
|
||||
val verifierModifier = Modifier.align(Alignment.TopEnd)
|
||||
val verifierModifier = if (topPaddingForControllers.isSpecified) {
|
||||
Modifier
|
||||
.padding(top = topPaddingForControllers)
|
||||
.align(Alignment.TopEnd)
|
||||
} else {
|
||||
Modifier.align(Alignment.TopEnd)
|
||||
}
|
||||
|
||||
val painterState = remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
@@ -398,11 +410,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 }
|
||||
@@ -410,7 +469,6 @@ fun ImageUrlWithDownloadButton(url: String, showImage: MutableState<Boolean>) {
|
||||
DownloadForOfflineIcon(Size24dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@@ -541,43 +599,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?,
|
||||
@@ -601,7 +685,6 @@ private fun DisplayBlurHash(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ZoomableImageDialog(
|
||||
imageUrl: ZoomableContent,
|
||||
@@ -609,75 +692,141 @@ fun ZoomableImageDialog(
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val view = LocalView.current
|
||||
|
||||
DisposableEffect(key1 = view) {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.hide(
|
||||
android.view.WindowInsets.Type.systemBars()
|
||||
)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.show(
|
||||
android.view.WindowInsets.Type.systemBars()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
usePlatformDefaultWidth = true,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
val view = LocalView.current
|
||||
val orientation = LocalConfiguration.current.orientation
|
||||
println("This Log only exists to force orientation listener $orientation")
|
||||
|
||||
val activityWindow = getActivityWindow()
|
||||
val dialogWindow = getDialogWindow()
|
||||
val parentView = LocalView.current.parent as View
|
||||
SideEffect {
|
||||
if (activityWindow != null && dialogWindow != null) {
|
||||
val attributes = WindowManager.LayoutParams()
|
||||
attributes.copyFrom(activityWindow.attributes)
|
||||
attributes.type = dialogWindow.attributes.type
|
||||
dialogWindow.attributes = attributes
|
||||
parentView.layoutParams = FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height)
|
||||
view.layoutParams = FrameLayout.LayoutParams(activityWindow.decorView.width, activityWindow.decorView.height)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(key1 = Unit) {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.hide(
|
||||
android.view.WindowInsets.Type.systemBars()
|
||||
)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
view.windowInsetsController?.show(
|
||||
android.view.WindowInsets.Type.systemBars()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
val pagerState: PagerState = rememberPagerState() { allImages.size }
|
||||
DialogContent(allImages, imageUrl, onDismiss, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState
|
||||
) { index ->
|
||||
RenderImageOrVideo(allImages[index], false, accountViewModel)
|
||||
}
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl, false, accountViewModel)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onPress = onDismiss)
|
||||
|
||||
val myContent = allImages[pagerState.currentPage]
|
||||
if (myContent is ZoomableUrlContent) {
|
||||
Row() {
|
||||
CopyToClipboard(content = myContent)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
SaveToGallery(url = myContent.url)
|
||||
}
|
||||
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
|
||||
SaveToGallery(
|
||||
localFile = myContent.localFile,
|
||||
mimeType = myContent.mimeType
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun DialogContent(
|
||||
allImages: ImmutableList<ZoomableContent>,
|
||||
imageUrl: ZoomableContent,
|
||||
onDismiss: () -> Unit,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val pagerState: PagerState = rememberPagerState() { allImages.size }
|
||||
val controllerVisible = remember { mutableStateOf(false) }
|
||||
val holdOn = remember { mutableStateOf<Boolean>(true) }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
launch {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
launch(Dispatchers.Default) {
|
||||
delay(2000)
|
||||
holdOn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState
|
||||
) { index ->
|
||||
RenderImageOrVideo(
|
||||
content = allImages[index],
|
||||
roundedCorner = false,
|
||||
topPaddingForControllers = Size55dp,
|
||||
onControllerVisibilityChanged = {
|
||||
controllerVisible.value = it
|
||||
},
|
||||
onToggleControllerVisibility = {
|
||||
controllerVisible.value = !controllerVisible.value
|
||||
},
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
} else {
|
||||
RenderImageOrVideo(
|
||||
content = imageUrl,
|
||||
roundedCorner = false,
|
||||
topPaddingForControllers = Size55dp,
|
||||
onControllerVisibilityChanged = {
|
||||
controllerVisible.value = it
|
||||
},
|
||||
onToggleControllerVisibility = {
|
||||
controllerVisible.value = !controllerVisible.value
|
||||
},
|
||||
accountViewModel = accountViewModel
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = holdOn.value || controllerVisible.value,
|
||||
enter = remember { fadeIn() },
|
||||
exit = remember { fadeOut() }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onPress = onDismiss)
|
||||
|
||||
val myContent = allImages[pagerState.currentPage]
|
||||
if (myContent is ZoomableUrlContent) {
|
||||
Row() {
|
||||
CopyToClipboard(content = myContent)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
SaveToGallery(url = myContent.url)
|
||||
}
|
||||
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
|
||||
SaveToGallery(
|
||||
localFile = myContent.localFile,
|
||||
mimeType = myContent.mimeType
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -751,13 +900,33 @@ private fun ShareImageAction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(content: ZoomableContent, roundedCorner: Boolean, accountViewModel: AccountViewModel) {
|
||||
val mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
|
||||
private fun RenderImageOrVideo(
|
||||
content: ZoomableContent,
|
||||
roundedCorner: Boolean,
|
||||
topPaddingForControllers: Dp = Dp.Unspecified,
|
||||
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
|
||||
onToggleControllerVisibility: (() -> Unit)? = null,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
if (content is ZoomableUrlImage) {
|
||||
UrlImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
|
||||
val mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
UrlImageView(
|
||||
content = content,
|
||||
mainImageModifier = mainModifier,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
accountViewModel,
|
||||
alwayShowImage = true
|
||||
)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(
|
||||
@@ -766,12 +935,31 @@ private fun RenderImageOrVideo(content: ZoomableContent, roundedCorner: Boolean,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
)
|
||||
}
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
LocalImageView(content = content, mainImageModifier = mainModifier, accountViewModel, alwayShowImage = true)
|
||||
val mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
rememberZoomState(),
|
||||
onTap = {
|
||||
if (onToggleControllerVisibility != null) {
|
||||
onToggleControllerVisibility()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LocalImageView(
|
||||
content = content,
|
||||
mainImageModifier = mainModifier,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
accountViewModel,
|
||||
alwayShowImage = true
|
||||
)
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
content.localFile?.let {
|
||||
@@ -781,6 +969,8 @@ private fun RenderImageOrVideo(content: ZoomableContent, roundedCorner: Boolean,
|
||||
artworkUri = content.artworkUri,
|
||||
authorName = content.authorName,
|
||||
roundedCorner = roundedCorner,
|
||||
topPaddingForControllers = topPaddingForControllers,
|
||||
onControllerVisibilityChanged = onControllerVisibilityChanged,
|
||||
accountViewModel = accountViewModel,
|
||||
alwaysShowVideo = true
|
||||
)
|
||||
@@ -858,3 +1048,13 @@ private tailrec fun Context.getActivityWindow(): Window? =
|
||||
is ContextWrapper -> baseContext.getActivityWindow()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun getActivity(): Activity? = LocalView.current.context.getActivity()
|
||||
|
||||
private tailrec fun Context.getActivity(): Activity? =
|
||||
when (this) {
|
||||
is Activity -> this
|
||||
is ContextWrapper -> baseContext.getActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.IsInPublicChatChannel
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
@@ -14,7 +16,7 @@ open class DiscoverChatFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
|
||||
return account.defaultDiscoveryFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -5,7 +5,9 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
@@ -14,7 +16,7 @@ open class DiscoverCommunityFeedFilter(val account: Account) : AdditiveFeedFilte
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultDiscoveryFollowList == PeopleListEvent.blockList
|
||||
return account.defaultDiscoveryFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -5,10 +5,11 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
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.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
open class DiscoverLiveFeedFilter(
|
||||
@@ -23,7 +24,7 @@ open class DiscoverLiveFeedFilter(
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return followList() == PeopleListEvent.blockList
|
||||
return followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
|
||||
|
||||
class DiscoverLiveNowFeedFilter(
|
||||
|
||||
@@ -18,7 +18,7 @@ class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Not
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultHomeFollowList == PeopleListEvent.blockList
|
||||
return account.defaultHomeFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -17,12 +17,13 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feedKey(): String {
|
||||
return account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultHomeFollowList == PeopleListEvent.blockList
|
||||
return account.defaultHomeFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -5,7 +5,18 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
|
||||
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
|
||||
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.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.events.ReactionEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
|
||||
class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String {
|
||||
@@ -13,7 +24,7 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultNotificationFollowList == PeopleListEvent.blockList
|
||||
return account.defaultNotificationFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -4,7 +4,9 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
@@ -13,7 +15,7 @@ class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun showHiddenKey(): Boolean {
|
||||
return account.defaultStoriesFollowList == PeopleListEvent.blockList
|
||||
return account.defaultStoriesFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -29,7 +31,7 @@ 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.blockList
|
||||
val isHiddenList = account.defaultStoriesFollowList == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
|
||||
@@ -45,10 +45,8 @@ import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
@@ -57,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
|
||||
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKey
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
@@ -78,7 +78,7 @@ fun AccountSwitchBottomSheet(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
.padding(Size10dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -90,7 +90,7 @@ fun AccountSwitchBottomSheet(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
.padding(top = Size10dp, bottom = Size55dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -141,14 +141,6 @@ fun DisplayAccount(
|
||||
)
|
||||
}
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
}
|
||||
|
||||
if (baseUser == null) {
|
||||
LaunchedEffect(key1 = acc.npub) {
|
||||
launch(Dispatchers.IO) {
|
||||
@@ -168,7 +160,7 @@ fun DisplayAccount(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
accountStateViewModel.switchUser(acc.npub)
|
||||
accountStateViewModel.switchUser(acc)
|
||||
}
|
||||
.padding(16.dp, 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -186,6 +178,10 @@ fun DisplayAccount(
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
AccountPicture(it, automaticallyShowProfilePicture)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
@@ -281,7 +277,7 @@ private fun LogoutButton(
|
||||
TextButton(
|
||||
onClick = {
|
||||
logoutDialog = false
|
||||
accountStateViewModel.logOff(acc.npub)
|
||||
accountStateViewModel.logOff(acc)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.log_out))
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -31,13 +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]?.collectAsState()
|
||||
|
||||
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()
|
||||
|
||||
@@ -30,7 +30,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
|
||||
@@ -69,7 +69,7 @@ fun AppNavigation(
|
||||
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel,
|
||||
themeViewModel: ThemeViewModel
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val nav = remember {
|
||||
@@ -160,6 +160,7 @@ fun AppNavigation(
|
||||
NotificationScreen(
|
||||
notifFeedViewModel = notifFeedViewModel,
|
||||
userReactionsStatsModel = userReactionsStatsModel,
|
||||
sharedPreferencesViewModel = sharedPreferencesViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
@@ -263,8 +264,7 @@ fun AppNavigation(
|
||||
Route.Settings.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
SettingsScreen(
|
||||
accountViewModel = accountViewModel,
|
||||
themeViewModel
|
||||
sharedPreferencesViewModel
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Debug
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -32,7 +33,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -50,14 +50,15 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.map
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import coil.Coil
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -78,7 +79,6 @@ import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
@@ -211,7 +211,6 @@ private fun HashTagTopBar(
|
||||
title = {
|
||||
Text(
|
||||
remember(tag) { "#$tag" },
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
@@ -232,7 +231,7 @@ private fun CommunityTopBar(
|
||||
if (baseNote != null) {
|
||||
FlexibleTopBarWithBackButton(
|
||||
title = {
|
||||
ShortCommunityHeader(baseNote, fontWeight = FontWeight.Medium, accountViewModel, nav)
|
||||
ShortCommunityHeader(baseNote, accountViewModel, nav)
|
||||
},
|
||||
extendableRow = {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
@@ -299,7 +298,7 @@ private fun RenderRoomTopBar(
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Medium)
|
||||
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -330,7 +329,7 @@ private fun RenderRoomTopBar(
|
||||
Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f),
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontWeight = FontWeight.Normal,
|
||||
accountViewModel.userProfile()
|
||||
)
|
||||
},
|
||||
@@ -355,7 +354,6 @@ private fun ChannelTopBar(
|
||||
ShortChannelHeader(
|
||||
baseChannel = baseChannel,
|
||||
accountViewModel = accountViewModel,
|
||||
fontWeight = FontWeight.Medium,
|
||||
nav = nav,
|
||||
showFlag = true
|
||||
)
|
||||
@@ -506,11 +504,7 @@ private fun LoggedInUserPictureDrawer(
|
||||
val pubkeyHex = remember { accountViewModel.userProfile().pubkeyHex }
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
IconButton(
|
||||
@@ -533,7 +527,7 @@ fun FollowListWithRoutes(
|
||||
listName: String,
|
||||
onChange: (CodeName) -> Unit
|
||||
) {
|
||||
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsState()
|
||||
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
|
||||
|
||||
SimpleTextSpinner(
|
||||
placeholderCode = listName,
|
||||
@@ -550,7 +544,7 @@ fun FollowListWithoutRoutes(
|
||||
listName: String,
|
||||
onChange: (CodeName) -> Unit
|
||||
) {
|
||||
val allLists by followListsModel.kind3GlobalPeople.collectAsState()
|
||||
val allLists by followListsModel.kind3GlobalPeople.collectAsStateWithLifecycle()
|
||||
|
||||
SimpleTextSpinner(
|
||||
placeholderCode = listName,
|
||||
@@ -673,6 +667,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
collectorJob?.cancel()
|
||||
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -698,7 +693,7 @@ fun SimpleTextSpinner(
|
||||
id = R.string.select_an_option
|
||||
)
|
||||
|
||||
var currentText by remember {
|
||||
var currentText by remember(placeholderCode) {
|
||||
mutableStateOf(
|
||||
options.firstOrNull { it.code == placeholderCode }?.name?.name(context) ?: selectAnOption
|
||||
)
|
||||
@@ -751,12 +746,15 @@ fun SimpleTextSpinner(
|
||||
fun RenderOption(option: Name) {
|
||||
when (option) {
|
||||
is GeoHashName -> {
|
||||
LoadCityName(option.geoHashTag) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
|
||||
val geohash = runCatching { option.geoHashTag.toGeoHash() }.getOrNull()
|
||||
if (geohash != null) {
|
||||
LoadCityName(geohash) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = "/g/$it", color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -815,7 +813,7 @@ fun TopBarWithBackButton(caption: String, popBack: () -> Unit) {
|
||||
},
|
||||
actions = {}
|
||||
)
|
||||
Divider(thickness = 0.25.dp)
|
||||
Divider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,17 +828,14 @@ fun FlexibleTopBarWithBackButton(
|
||||
title = title,
|
||||
extendableRow = extendableRow,
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = popBack,
|
||||
modifier = Modifier
|
||||
) {
|
||||
IconButton(onClick = popBack) {
|
||||
ArrowBackIcon()
|
||||
}
|
||||
},
|
||||
actions = {}
|
||||
)
|
||||
Spacer(modifier = HalfVertSpacer)
|
||||
Divider(thickness = 0.25.dp)
|
||||
Divider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,6 +879,17 @@ fun debugState(context: Context) {
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
NostrVideoDataSource.printCounter()
|
||||
|
||||
val totalMemoryKb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
|
||||
val freeMemoryKb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
|
||||
|
||||
val jvmHeapAllocatedKb = totalMemoryKb - freeMemoryKb
|
||||
|
||||
Log.d("STATE DUMP", "Total Heap Allocated: " + jvmHeapAllocatedKb + " MB")
|
||||
|
||||
val nativeHeap = Debug.getNativeHeapAllocatedSize() / (1024 * 1024)
|
||||
|
||||
Log.d("STATE DUMP", "Total Native Heap Allocated: " + nativeHeap + " MB")
|
||||
|
||||
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(context)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -28,7 +27,6 @@ import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -39,7 +37,6 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -62,17 +59,15 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.map
|
||||
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.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
@@ -82,6 +77,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
|
||||
@@ -89,11 +85,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.events.toImmutableListOfLists
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DrawerContent(
|
||||
nav: (String) -> Unit,
|
||||
@@ -102,11 +96,7 @@ fun DrawerContent(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
ModalDrawerSheet(
|
||||
@@ -125,7 +115,7 @@ fun DrawerContent(
|
||||
nav
|
||||
)
|
||||
Divider(
|
||||
thickness = 0.25.dp,
|
||||
thickness = DividerThickness,
|
||||
modifier = Modifier.padding(top = 20.dp)
|
||||
)
|
||||
ListContent(
|
||||
@@ -138,7 +128,12 @@ fun DrawerContent(
|
||||
accountViewModel
|
||||
)
|
||||
|
||||
BottomContent(accountViewModel.account.userProfile(), drawerState, automaticallyShowProfilePicture, nav)
|
||||
BottomContent(
|
||||
accountViewModel.account.userProfile(),
|
||||
drawerState,
|
||||
automaticallyShowProfilePicture,
|
||||
nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,11 +160,7 @@ fun ProfileContent(
|
||||
val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" }
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
Box {
|
||||
@@ -187,7 +178,8 @@ fun ProfileContent(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = stringResource(R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
)
|
||||
}
|
||||
@@ -231,8 +223,8 @@ fun ProfileContent(
|
||||
)
|
||||
}
|
||||
|
||||
Row(Modifier.padding(top = Size10dp)) {
|
||||
EditStatusBox(baseAccountUser, accountViewModel)
|
||||
Column(Modifier.padding(top = Size10dp)) {
|
||||
EditStatusBoxes(baseAccountUser, accountViewModel)
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -252,8 +244,7 @@ fun ProfileContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewModel) {
|
||||
val scope = rememberCoroutineScope()
|
||||
private fun EditStatusBoxes(baseAccountUser: User, accountViewModel: AccountViewModel) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
|
||||
@@ -300,9 +291,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
|
||||
)
|
||||
} else {
|
||||
statuses.forEach {
|
||||
val originalStatus by it.live().metadata.map {
|
||||
it.note.event?.content() ?: ""
|
||||
}.observeAsState(it.event?.content() ?: "")
|
||||
val originalStatus by it.live().content.observeAsState("")
|
||||
|
||||
val thisStatus = remember {
|
||||
mutableStateOf(originalStatus)
|
||||
@@ -575,7 +564,9 @@ fun ListContent(
|
||||
conectOrbotDialogOpen = false
|
||||
disconnectTorDialog = false
|
||||
checked = true
|
||||
enableTor(accountViewModel.account, true, proxyPort, context, coroutineScope)
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
enableTor(accountViewModel.account, true, proxyPort)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(
|
||||
@@ -603,7 +594,13 @@ fun ListContent(
|
||||
onClick = {
|
||||
disconnectTorDialog = false
|
||||
checked = false
|
||||
enableTor(accountViewModel.account, false, proxyPort, context, coroutineScope)
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
enableTor(
|
||||
accountViewModel.account,
|
||||
false,
|
||||
proxyPort
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.yes))
|
||||
@@ -622,25 +619,21 @@ fun ListContent(
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableTor(
|
||||
private suspend fun enableTor(
|
||||
account: Account,
|
||||
checked: Boolean,
|
||||
portNumber: MutableState<String>,
|
||||
context: Context,
|
||||
scope: CoroutineScope
|
||||
portNumber: MutableState<String>
|
||||
) {
|
||||
account.proxyPort = portNumber.value.toInt()
|
||||
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalPreferences.saveToEncryptedStorage(account)
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start(context)
|
||||
}
|
||||
LocalPreferences.saveToEncryptedStorage(account)
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(accountViewModel: AccountViewModel) {
|
||||
val connectedRelaysText by RelayPool.statusFlow.collectAsState(initial = RelayPoolStatus(0, 0))
|
||||
val connectedRelaysText by RelayPool.statusFlow.collectAsStateWithLifecycle(RelayPoolStatus(0, 0))
|
||||
|
||||
RenderRelayStatus(connectedRelaysText)
|
||||
}
|
||||
@@ -769,7 +762,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(),
|
||||
|
||||
@@ -24,7 +24,6 @@ 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.graphics.Color
|
||||
@@ -36,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
|
||||
@@ -162,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -30,7 +31,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.BottomStart
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Alignment.Companion.TopEnd
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -50,7 +50,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ParticipantListBuilder
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -75,6 +74,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.Participant
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -372,6 +372,18 @@ private fun RenderNoteRow(
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class LiveActivityCard(
|
||||
val name: String,
|
||||
val cover: String?,
|
||||
val media: String?,
|
||||
val subject: String?,
|
||||
val content: String?,
|
||||
val participants: ImmutableList<Participant>,
|
||||
val status: String?,
|
||||
val starts: Long?
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RenderLiveActivityThumb(
|
||||
baseNote: Note,
|
||||
@@ -380,38 +392,279 @@ fun RenderLiveActivityThumb(
|
||||
) {
|
||||
val noteEvent = baseNote.event as? LiveActivitiesEvent ?: return
|
||||
|
||||
val eventUpdates by baseNote.live().metadata.observeAsState()
|
||||
val card by baseNote.live().metadata.map {
|
||||
val noteEvent = it.note.event as? LiveActivitiesEvent
|
||||
|
||||
val media = remember(eventUpdates) { noteEvent.streaming() }
|
||||
val cover by remember(eventUpdates) {
|
||||
derivedStateOf {
|
||||
noteEvent.image()?.ifBlank { null }
|
||||
}
|
||||
}
|
||||
val subject = remember(eventUpdates) { noteEvent.title()?.ifBlank { null } }
|
||||
val content = remember(eventUpdates) { noteEvent.summary() }
|
||||
val participants = remember(eventUpdates) { noteEvent.participants() }
|
||||
val status = remember(eventUpdates) { noteEvent.status() }
|
||||
val starts = remember(eventUpdates) { noteEvent.starts() }
|
||||
LiveActivityCard(
|
||||
name = noteEvent?.dTag() ?: "",
|
||||
cover = noteEvent?.image()?.ifBlank { null },
|
||||
media = noteEvent?.streaming(),
|
||||
subject = noteEvent?.title()?.ifBlank { null },
|
||||
content = noteEvent?.summary(),
|
||||
participants = noteEvent?.participants()?.toImmutableList() ?: persistentListOf(),
|
||||
status = noteEvent?.status(),
|
||||
starts = noteEvent?.starts()
|
||||
)
|
||||
}.distinctUntilChanged().observeAsState(
|
||||
LiveActivityCard(
|
||||
name = noteEvent.dTag(),
|
||||
cover = noteEvent.image()?.ifBlank { null },
|
||||
media = noteEvent.streaming(),
|
||||
subject = noteEvent.title()?.ifBlank { null },
|
||||
content = noteEvent.summary(),
|
||||
participants = noteEvent.participants().toImmutableList(),
|
||||
status = noteEvent.status(),
|
||||
starts = noteEvent.starts()
|
||||
)
|
||||
)
|
||||
|
||||
var isOnline by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = media) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newIsOnline = OnlineChecker.isOnline(media)
|
||||
LaunchedEffect(key1 = card.media) {
|
||||
accountViewModel.checkIsOnline(card.media) { newIsOnline ->
|
||||
if (isOnline != newIsOnline) {
|
||||
isOnline = newIsOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = TopEnd,
|
||||
modifier = Modifier
|
||||
.aspectRatio(ratio = 16f / 9f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
card.cover?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder)
|
||||
)
|
||||
} ?: run {
|
||||
baseNote.author?.let {
|
||||
DisplayAuthorBanner(it)
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
Crossfade(targetState = card.status) {
|
||||
when (it) {
|
||||
STATUS_LIVE -> {
|
||||
if (card.media.isNullOrBlank()) {
|
||||
LiveFlag()
|
||||
} else if (isOnline) {
|
||||
LiveFlag()
|
||||
} else {
|
||||
OfflineFlag()
|
||||
}
|
||||
}
|
||||
STATUS_ENDED -> {
|
||||
EndedFlag()
|
||||
}
|
||||
STATUS_PLANNED -> {
|
||||
ScheduledFlag(card.starts)
|
||||
}
|
||||
else -> {
|
||||
EndedFlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LoadParticipants(card.participants, baseNote, accountViewModel) { participantUsers ->
|
||||
Box(
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.align(BottomStart)
|
||||
) {
|
||||
if (participantUsers.isNotEmpty()) {
|
||||
Gallery(participantUsers, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
ChannelHeader(
|
||||
channelHex = remember { baseNote.idHex },
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
showFlag = false,
|
||||
sendToChannel = true,
|
||||
modifier = remember {
|
||||
Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class CommunityCard(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val cover: String?,
|
||||
val moderators: ImmutableList<Participant>
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return
|
||||
|
||||
val card by baseNote.live().metadata.map {
|
||||
val noteEvent = it.note.event as? CommunityDefinitionEvent
|
||||
|
||||
CommunityCard(
|
||||
name = noteEvent?.dTag() ?: "",
|
||||
description = noteEvent?.description(),
|
||||
cover = noteEvent?.image()?.ifBlank { null },
|
||||
moderators = noteEvent?.moderators()?.toImmutableList() ?: persistentListOf()
|
||||
)
|
||||
}.distinctUntilChanged().observeAsState(
|
||||
CommunityCard(
|
||||
name = noteEvent.dTag(),
|
||||
description = noteEvent.description(),
|
||||
cover = noteEvent.image()?.ifBlank { null },
|
||||
moderators = noteEvent.moderators().toImmutableList()
|
||||
)
|
||||
)
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.3f)
|
||||
.aspectRatio(ratio = 1f)
|
||||
) {
|
||||
card.cover?.let {
|
||||
Box(contentAlignment = BottomStart) {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder)
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
baseNote.author?.let {
|
||||
DisplayAuthorBanner(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = card.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
card.description?.let {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row() {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LoadModerators(card.moderators, baseNote, accountViewModel) { participantUsers ->
|
||||
if (participantUsers.isNotEmpty()) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Gallery(participantUsers, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadModerators(
|
||||
moderators: ImmutableList<Participant>,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable (ImmutableList<User>) -> Unit
|
||||
) {
|
||||
var participantUsers by remember {
|
||||
mutableStateOf<ImmutableList<User>>(
|
||||
persistentListOf()
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = eventUpdates) {
|
||||
LaunchedEffect(key1 = moderators) {
|
||||
launch(Dispatchers.IO) {
|
||||
val hosts = moderators.mapNotNull { part ->
|
||||
if (part.key != baseNote.author?.pubkeyHex) {
|
||||
LocalCache.checkGetOrCreateUser(part.key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
|
||||
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
|
||||
|
||||
val newParticipantUsers = if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
|
||||
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
|
||||
|
||||
(hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
|
||||
} else {
|
||||
(hosts + allParticipants).toImmutableList()
|
||||
}
|
||||
|
||||
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
|
||||
participantUsers = newParticipantUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content(participantUsers)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadParticipants(
|
||||
participants: ImmutableList<Participant>,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
inner: @Composable (ImmutableList<User>) -> Unit
|
||||
) {
|
||||
var participantUsers by remember {
|
||||
mutableStateOf<ImmutableList<User>>(
|
||||
persistentListOf()
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = participants) {
|
||||
launch(Dispatchers.IO) {
|
||||
val hosts = participants.mapNotNull { part ->
|
||||
if (part.key != baseNote.author?.pubkeyHex) {
|
||||
@@ -445,198 +698,7 @@ fun RenderLiveActivityThumb(
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = TopEnd,
|
||||
modifier = Modifier
|
||||
.aspectRatio(ratio = 16f / 9f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
cover?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder)
|
||||
)
|
||||
} ?: run {
|
||||
baseNote.author?.let {
|
||||
DisplayAuthorBanner(it)
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.padding(10.dp)) {
|
||||
Crossfade(targetState = status) {
|
||||
when (it) {
|
||||
STATUS_LIVE -> {
|
||||
if (media.isNullOrBlank()) {
|
||||
LiveFlag()
|
||||
} else if (isOnline) {
|
||||
LiveFlag()
|
||||
} else {
|
||||
OfflineFlag()
|
||||
}
|
||||
}
|
||||
STATUS_ENDED -> {
|
||||
EndedFlag()
|
||||
}
|
||||
STATUS_PLANNED -> {
|
||||
ScheduledFlag(starts)
|
||||
}
|
||||
else -> {
|
||||
EndedFlag()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.padding(10.dp)
|
||||
.align(BottomStart)
|
||||
) {
|
||||
if (participantUsers.isNotEmpty()) {
|
||||
Gallery(participantUsers, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
ChannelHeader(
|
||||
channelHex = remember { baseNote.idHex },
|
||||
showVideo = false,
|
||||
showBottomDiviser = false,
|
||||
showFlag = false,
|
||||
sendToChannel = true,
|
||||
modifier = remember {
|
||||
Modifier.padding(start = 0.dp, end = 0.dp, top = 5.dp, bottom = 5.dp)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteEvent = baseNote.event as? CommunityDefinitionEvent ?: return
|
||||
|
||||
val eventUpdates by baseNote.live().metadata.observeAsState()
|
||||
|
||||
val name = remember(eventUpdates) { noteEvent.dTag() }
|
||||
val description = remember(eventUpdates) { noteEvent.description() }
|
||||
val cover by remember(eventUpdates) {
|
||||
derivedStateOf {
|
||||
noteEvent.image()?.ifBlank { null }
|
||||
}
|
||||
}
|
||||
val moderators = remember(eventUpdates) { noteEvent.moderators() }
|
||||
|
||||
var participantUsers by remember {
|
||||
mutableStateOf<ImmutableList<User>>(
|
||||
persistentListOf()
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = eventUpdates) {
|
||||
launch(Dispatchers.IO) {
|
||||
val hosts = moderators.mapNotNull { part ->
|
||||
if (part.key != baseNote.author?.pubkeyHex) {
|
||||
LocalCache.checkGetOrCreateUser(part.key)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val followingKeySet = accountViewModel.account.selectedUsersFollowList(accountViewModel.account.defaultDiscoveryFollowList)
|
||||
val allParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, followingKeySet).minus(hosts)
|
||||
|
||||
val newParticipantUsers = if (followingKeySet == null) {
|
||||
val allFollows = accountViewModel.account.selectedUsersFollowList(KIND3_FOLLOWS)
|
||||
val followingParticipants = ParticipantListBuilder().followsThatParticipateOn(baseNote, allFollows).minus(hosts)
|
||||
|
||||
(hosts + followingParticipants + (allParticipants - followingParticipants)).toImmutableList()
|
||||
} else {
|
||||
(hosts + allParticipants).toImmutableList()
|
||||
}
|
||||
|
||||
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
|
||||
participantUsers = newParticipantUsers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.3f)
|
||||
.aspectRatio(ratio = 1f)
|
||||
) {
|
||||
cover?.let {
|
||||
Box(contentAlignment = BottomStart) {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(QuoteBorder)
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
baseNote.author?.let {
|
||||
DisplayAuthorBanner(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
description?.let {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row() {
|
||||
Text(
|
||||
text = it,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (participantUsers.isNotEmpty()) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Gallery(participantUsers, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inner(participantUsers)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -757,7 +819,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)
|
||||
|
||||
@@ -47,10 +47,8 @@ import androidx.lifecycle.map
|
||||
import com.patrykandpatrick.vico.core.extension.forEachIndexedExtended
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -188,11 +186,7 @@ private fun ChannelRoomCompose(
|
||||
val hasNewMessages = remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages ->
|
||||
@@ -244,7 +238,9 @@ private fun ChannelTitleWithLabelInfo(channelName: String, modifier: Modifier) {
|
||||
text = channelNameAndBoostInfo,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = modifier,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,12 +41,11 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.distinctUntilChanged
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
@@ -123,9 +122,11 @@ fun CheckHiddenChatMessage(
|
||||
nav: (String) -> Unit,
|
||||
onWantsToReply: (Note) -> Unit
|
||||
) {
|
||||
val isHidden by accountViewModel.account.liveHiddenUsers.map {
|
||||
baseNote.isHiddenFor(it)
|
||||
}.observeAsState(accountViewModel.isNoteHidden(baseNote))
|
||||
val isHidden by remember {
|
||||
accountViewModel.account.liveHiddenUsers.map {
|
||||
baseNote.isHiddenFor(it)
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState(false)
|
||||
|
||||
if (!isHidden) {
|
||||
LoadedChatMessageCompose(
|
||||
@@ -396,11 +397,7 @@ private fun MessageBubbleLines(
|
||||
availableBubbleSize: MutableState<Int>
|
||||
) {
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
if (drawAuthorInfo) {
|
||||
|
||||
@@ -14,11 +14,9 @@ import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.graphics.Color
|
||||
|
||||
@@ -43,10 +43,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
@@ -524,11 +522,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
WatchUserMetadata(author) { baseUserPicture ->
|
||||
|
||||
@@ -53,8 +53,8 @@ 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.Alignment.Companion.TopStart
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -71,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
|
||||
@@ -80,16 +81,15 @@ import androidx.lifecycle.map
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.request.SuccessResult
|
||||
import com.fonfon.kgeohash.GeoHash
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.RelayBriefInfo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
|
||||
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
|
||||
@@ -212,6 +212,7 @@ import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.math.BigDecimal
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.util.Locale
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -303,8 +304,10 @@ fun CheckHiddenNoteCompose(
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
val isHidden by accountViewModel.account.liveHiddenUsers.map {
|
||||
note.isHiddenFor(it)
|
||||
val isHidden by remember(note) {
|
||||
accountViewModel.account.liveHiddenUsers.map {
|
||||
note.isHiddenFor(it)
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState(accountViewModel.isNoteHidden(note))
|
||||
|
||||
Crossfade(targetState = isHidden) {
|
||||
@@ -580,7 +583,7 @@ fun CommunityHeader(
|
||||
|
||||
if (showBottomDiviser) {
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
thickness = DividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -607,9 +610,9 @@ fun LongCommunityHeader(
|
||||
|
||||
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
|
||||
if (rules == null) {
|
||||
"## $subject\n$body"
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
"## $subject\n$body\n\n## $rulesLabel\n\n$rules"
|
||||
"### $subject\n$body\n\n### $rulesLabel\n\n$rules"
|
||||
}
|
||||
} else {
|
||||
if (rules == null) {
|
||||
@@ -724,16 +727,12 @@ fun LongCommunityHeader(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShortCommunityHeader(baseNote: AddressableNote, fontWeight: FontWeight = FontWeight.Bold, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
fun ShortCommunityHeader(baseNote: AddressableNote, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val noteEvent = remember(noteState) { noteState?.note?.event as? CommunityDefinitionEvent } ?: return
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -758,7 +757,6 @@ fun ShortCommunityHeader(baseNote: AddressableNote, fontWeight: FontWeight = Fon
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = remember(noteState) { noteEvent.dTag() },
|
||||
fontWeight = fontWeight,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -797,11 +795,11 @@ private fun ShortCommunityActionOptions(
|
||||
|
||||
@Composable
|
||||
fun WatchAddressableNoteFollows(note: AddressableNote, accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit) {
|
||||
val showFollowingMark by accountViewModel.userFollows.map {
|
||||
it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false
|
||||
}.distinctUntilChanged().observeAsState(
|
||||
accountViewModel.userProfile().latestContactList?.isTaggedAddressableNote(note.idHex) ?: false
|
||||
)
|
||||
val showFollowingMark by remember {
|
||||
accountViewModel.userFollows.map {
|
||||
it.user.latestContactList?.isTaggedAddressableNote(note.idHex) ?: false
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState(false)
|
||||
|
||||
onFollowChanges(showFollowingMark)
|
||||
}
|
||||
@@ -1115,7 +1113,12 @@ fun DisplayZapSplits(noteEvent: EventInterface, accountViewModel: AccountViewMod
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
} else {
|
||||
UserPicture(userHex = it.lnAddressOrPubKeyHex, size = 25.dp, accountViewModel = accountViewModel, nav = nav)
|
||||
UserPicture(
|
||||
userHex = it.lnAddressOrPubKeyHex,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1271,16 +1274,16 @@ fun routeFor(note: Note, loggedIn: User): String? {
|
||||
}
|
||||
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
|
||||
note.channelHex()?.let {
|
||||
return "Channel/$it"
|
||||
return "Channel/${URLEncoder.encode(it, "utf-8")}"
|
||||
}
|
||||
} else if (noteEvent is ChatroomKeyable) {
|
||||
val room = noteEvent.chatroomKey(loggedIn.pubkeyHex)
|
||||
loggedIn.createChatroom(room)
|
||||
return "Room/${room.hashCode()}"
|
||||
} else if (noteEvent is CommunityDefinitionEvent) {
|
||||
return "Community/${note.idHex}"
|
||||
return "Community/${URLEncoder.encode(note.idHex, "utf-8")}"
|
||||
} else {
|
||||
return "Note/${note.idHex}"
|
||||
return "Note/${URLEncoder.encode(note.idHex, "utf-8")}"
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -1326,7 +1329,7 @@ fun RenderTextEvent(
|
||||
val body = accountViewModel.decrypt(note)
|
||||
|
||||
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
|
||||
"## $subject\n$body"
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
@@ -2213,9 +2216,11 @@ private fun EmojiListOptions(
|
||||
accountViewModel
|
||||
) {
|
||||
it?.let { usersEmojiList ->
|
||||
val hasAddedThis by usersEmojiList.live().metadata.map {
|
||||
usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex)
|
||||
}.distinctUntilChanged().observeAsState()
|
||||
val hasAddedThis by remember {
|
||||
usersEmojiList.live().metadata.map {
|
||||
usersEmojiList.event?.isTaggedAddressableNote(emojiPackNote.idHex)
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState()
|
||||
|
||||
Crossfade(targetState = hasAddedThis) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -2590,15 +2595,15 @@ fun LoadStatuses(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadCityName(geohash: String, content: @Composable (String) -> Unit) {
|
||||
fun LoadCityName(geohash: GeoHash, content: @Composable (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var cityName by remember(geohash) {
|
||||
mutableStateOf<String>(geohash)
|
||||
mutableStateOf<String>(geohash.toString())
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = geohash) {
|
||||
launch(Dispatchers.IO) {
|
||||
val newCityName = ReverseGeoLocationUtil().execute(geohash.toGeoHash().toLocation(), context)?.ifBlank { null }
|
||||
val newCityName = ReverseGeoLocationUtil().execute(geohash.toLocation(), context)?.ifBlank { null }
|
||||
if (newCityName != null && newCityName != cityName) {
|
||||
cityName = newCityName
|
||||
}
|
||||
@@ -2609,20 +2614,23 @@ fun LoadCityName(geohash: String, content: @Composable (String) -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
|
||||
LoadCityName(geohash) { cityName ->
|
||||
ClickableText(
|
||||
text = AnnotatedString(cityName),
|
||||
onClick = { nav("Geohash/$geohash") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.52f
|
||||
fun DisplayLocation(geohashStr: String, nav: (String) -> Unit) {
|
||||
val geoHash = runCatching { geohashStr.toGeoHash() }.getOrNull()
|
||||
if (geoHash != null) {
|
||||
LoadCityName(geoHash) { cityName ->
|
||||
ClickableText(
|
||||
text = AnnotatedString(cityName),
|
||||
onClick = { nav("Geohash/$geoHash") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.primary.copy(
|
||||
alpha = 0.52f
|
||||
),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
fontSize = Font14SP,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
maxLines = 1
|
||||
)
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2782,11 +2790,7 @@ private fun RenderAuthorImages(
|
||||
val isChannel = baseNote.event is ChannelMessageEvent && baseNote.channelHex() != null
|
||||
|
||||
val automaticallyShowProfilePicture = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowProfilePictures) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showProfilePictures.value
|
||||
}
|
||||
|
||||
if (isChannel) {
|
||||
@@ -2859,21 +2863,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,
|
||||
@@ -2881,6 +2880,39 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2917,6 +2949,7 @@ fun DisplayHighlight(
|
||||
DisplayQuoteAuthor(authorHex ?: "", url, postAddress, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DisplayQuoteAuthor(
|
||||
authorHex: String,
|
||||
@@ -2936,7 +2969,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)
|
||||
}
|
||||
@@ -3564,7 +3597,7 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
|
||||
val body = accountViewModel.decrypt(note)
|
||||
|
||||
if (!subject.isNullOrBlank() && body?.split("\n")?.get(0)?.contains(subject) == false) {
|
||||
"## $subject\n$body"
|
||||
"### $subject\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
@@ -3784,11 +3817,7 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountView
|
||||
) {
|
||||
Column {
|
||||
val automaticallyShowUrlPreview = remember {
|
||||
when (accountViewModel.account.settings.automaticallyShowUrlPreview) {
|
||||
ConnectivityType.WIFI_ONLY -> !ConnectivityStatus.isOnMobileData.value
|
||||
ConnectivityType.NEVER -> false
|
||||
ConnectivityType.ALWAYS -> true
|
||||
}
|
||||
accountViewModel.settings.showUrlPreview.value
|
||||
}
|
||||
|
||||
if (automaticallyShowUrlPreview) {
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.ripple.rememberRipple
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
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
|
||||
@@ -22,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -54,9 +81,7 @@ fun PollNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val pollViewModel: PollNoteViewModel = viewModel(
|
||||
key = baseNote.idHex + "PollNoteViewModel"
|
||||
)
|
||||
val pollViewModel: PollNoteViewModel = viewModel(key = "PollNoteViewModel")
|
||||
|
||||
pollViewModel.load(accountViewModel.account, baseNote)
|
||||
|
||||
@@ -81,7 +106,7 @@ fun PollNote(
|
||||
) {
|
||||
WatchZapsAndUpdateTallies(baseNote, pollViewModel)
|
||||
|
||||
val tallies by pollViewModel.tallies.collectAsState()
|
||||
val tallies by pollViewModel.tallies.collectAsStateWithLifecycle()
|
||||
|
||||
tallies.forEach { poll_op ->
|
||||
OptionNote(
|
||||
|
||||
@@ -7,7 +7,12 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.quartz.events.*
|
||||
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.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.VALUE_MAXIMUM
|
||||
import com.vitorpamplona.quartz.events.VALUE_MINIMUM
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -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
|
||||
@@ -106,16 +105,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,6 +149,50 @@ 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) {
|
||||
if (showReactionDetail) {
|
||||
@@ -157,9 +201,7 @@ private fun InnerReactionRow(
|
||||
modifier = ReactionRowExpandButton
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
WatchReactionsZapsBoostsAndDisplayIfExists(baseNote) {
|
||||
RenderShowIndividualReactionsButton(wantsToSeeReactions)
|
||||
}
|
||||
one()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,69 +210,45 @@ private fun InnerReactionRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ReplyReactionWithDialog(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
two()
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Reply: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
BoostWithDialog(
|
||||
baseNote,
|
||||
MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
three()
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Boost: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
four()
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Likes: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = remember { Modifier.weight(1f) }
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
ZapReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
five()
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
six()
|
||||
}
|
||||
Log.d("Rendering Metrics", "Reaction Views: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,9 +305,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
|
||||
)
|
||||
@@ -324,10 +344,8 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content:
|
||||
baseNote.reactions.isNotEmpty()
|
||||
)
|
||||
|
||||
Crossfade(targetState = hasReactions) {
|
||||
if (it) {
|
||||
content()
|
||||
}
|
||||
if (hasReactions) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +389,7 @@ private fun RenderShowIndividualReactionsButton(wantsToSeeReactions: MutableStat
|
||||
},
|
||||
modifier = Size20Modifier
|
||||
) {
|
||||
Crossfade(targetState = wantsToSeeReactions.value) {
|
||||
Crossfade(targetState = wantsToSeeReactions.value, label = "RenderShowIndividualReactionsButton") {
|
||||
if (it) {
|
||||
ExpandLessIcon(modifier = Size22Modifier)
|
||||
} else {
|
||||
@@ -644,25 +662,8 @@ 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
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -692,9 +693,13 @@ fun BoostReaction(
|
||||
|
||||
@Composable
|
||||
fun BoostIcon(baseNote: Note, iconSize: Dp = Size20dp, grayTint: Color, accountViewModel: AccountViewModel) {
|
||||
val iconTint by baseNote.live().boosts.map {
|
||||
if (it.note.isBoostedBy(accountViewModel.userProfile())) Color.Unspecified else grayTint
|
||||
}.distinctUntilChanged().observeAsState(grayTint)
|
||||
val iconTint by remember(baseNote) {
|
||||
baseNote.live().boosts.map {
|
||||
if (it.note.isBoostedBy(accountViewModel.userProfile())) Color.Unspecified else grayTint
|
||||
}.distinctUntilChanged()
|
||||
}.observeAsState(
|
||||
if (baseNote.isBoostedBy(accountViewModel.userProfile())) Color.Unspecified else grayTint
|
||||
)
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
@@ -831,13 +836,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,
|
||||
@@ -1274,7 +1282,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) {
|
||||
@@ -1309,7 +1317,7 @@ private fun ActionableReactionButton(
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
toRemove: Set<String>
|
||||
toRemove: ImmutableSet<String>
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user