mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b2a9e67c0 | ||
|
|
931081c5e7 | ||
|
|
f7237b5666 | ||
|
|
d851637ea2 | ||
|
|
2d39b4edfe | ||
|
|
5001c5bef8 | ||
|
|
6ac1db9fc8 | ||
|
|
52f33d79e0 | ||
|
|
1a9d91cdc0 | ||
|
|
2f444e6ec5 | ||
|
|
4665c488d5 | ||
|
|
42ab826d02 | ||
|
|
80a3f7424b | ||
|
|
1bd3e347b3 | ||
|
|
a332126e71 | ||
|
|
d97abe6af9 | ||
|
|
62d341bd8a | ||
|
|
a95d2e903e | ||
|
|
2b5e62aaf7 | ||
|
|
1c3e82913f | ||
|
|
ceaa20fabf | ||
|
|
1bbabb747e | ||
|
|
914c0a2926 | ||
|
|
4b249156f9 | ||
|
|
7b8c7a1a78 | ||
|
|
66ed26dcea | ||
|
|
356740479c | ||
|
|
c88dded01b | ||
|
|
7c974bc8ef | ||
|
|
efbc3e3e78 | ||
|
|
fac803e208 | ||
|
|
484a3d6b32 | ||
|
|
4e3b6d0299 | ||
|
|
f3161ada8d | ||
|
|
5c9b84476b | ||
|
|
24ea0cabda | ||
|
|
8b617fbd60 | ||
|
|
34149e6ce2 | ||
|
|
315a662c10 | ||
|
|
c373843e0f | ||
|
|
448ea796ef | ||
|
|
71b111ce8b | ||
|
|
ae686671ba | ||
|
|
6c45bb1ca1 | ||
|
|
a05ad6e686 | ||
|
|
485f7becd4 | ||
|
|
83f1e523ea | ||
|
|
a8a3c94115 | ||
|
|
4d21857adf | ||
|
|
0e7f41efe8 | ||
|
|
8d9bf60dba | ||
|
|
7c67924914 | ||
|
|
7626839037 | ||
|
|
23825ceed5 | ||
|
|
8d8016e01b | ||
|
|
6668e5a178 | ||
|
|
7d6bef9576 | ||
|
|
1aea7fbd03 | ||
|
|
ef96d296f8 | ||
|
|
d4da58e14a | ||
|
|
f646866192 | ||
|
|
e0849b12e7 | ||
|
|
1271343582 | ||
|
|
d61d14ea75 | ||
|
|
668d604aa9 | ||
|
|
6df2c3a0cf | ||
|
|
b0c33808e6 | ||
|
|
7bd3e0beab | ||
|
|
3a70cfb9e2 | ||
|
|
17c758f8f8 | ||
|
|
7cb03b939f | ||
|
|
481ef91cb1 | ||
|
|
1183aa8d11 | ||
|
|
4023261400 | ||
|
|
f1433504e9 | ||
|
|
989cbb35d3 | ||
|
|
9262b90096 | ||
|
|
ab3b7685cf | ||
|
|
40865ac97d | ||
|
|
ded67964b0 | ||
|
|
e991707ddf | ||
|
|
1b429475aa | ||
|
|
9613afb7ea | ||
|
|
26a12f2dbe | ||
|
|
23da02f932 | ||
|
|
59ff9007a7 | ||
|
|
0f817dbf00 | ||
|
|
7ab6322886 | ||
|
|
223c0c1263 | ||
|
|
d3a639a2ee | ||
|
|
2fb1cbe11f | ||
|
|
29c5b45764 | ||
|
|
ec83ad9d20 | ||
|
|
239f973e89 | ||
|
|
6c81fbbcf9 | ||
|
|
75e45f37b2 | ||
|
|
0bff3462b1 | ||
|
|
990db68a4d | ||
|
|
4343c744db | ||
|
|
5cb6f97178 | ||
|
|
a8efc9d348 | ||
|
|
e0b667fd2d | ||
|
|
a8ad2437fe | ||
|
|
83018e621e | ||
|
|
68b15ba483 | ||
|
|
7513036d47 | ||
|
|
c82c594926 | ||
|
|
bd18d4b4b2 | ||
|
|
2f5b99a1c7 | ||
|
|
1be6e0ec0f | ||
|
|
f43c0e5cf5 | ||
|
|
d6d8719c8d | ||
|
|
7478899388 | ||
|
|
6b193894bd | ||
|
|
69f5371ab8 | ||
|
|
526e3db3c1 | ||
|
|
3c9e8854eb | ||
|
|
7ee0aa47a9 | ||
|
|
5fbf0462fa | ||
|
|
eda5bc50e6 | ||
|
|
d6d26d466b | ||
|
|
6acd3ca091 | ||
|
|
c64d179f7f | ||
|
|
32bc8fe667 | ||
|
|
0a8c792d53 | ||
|
|
c32dc61ea1 | ||
|
|
3af2cb52cc | ||
|
|
ddb1d7ff82 | ||
|
|
fd0523085d | ||
|
|
1f9fbb0c86 | ||
|
|
5b95a1c177 | ||
|
|
2e144ebdf8 | ||
|
|
ac5f550ff3 | ||
|
|
9af123b709 | ||
|
|
75efd2b372 | ||
|
|
2cface9ac9 | ||
|
|
58491a97ac | ||
|
|
7a86d78c50 | ||
|
|
97a00ff83c | ||
|
|
8adddd0bdb |
@@ -16,9 +16,9 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk libs.versions.android.minSdk.get().toInteger()
|
||||
targetSdk libs.versions.android.targetSdk.get().toInteger()
|
||||
versionCode 396
|
||||
versionName "0.89.10"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"1130badb252bdf62aaf93cb65bfa2bb09e7600c7d6764894f2954555b3b73018\""
|
||||
versionCode 400
|
||||
versionName "0.90.3"
|
||||
buildConfigField "String", "RELEASE_NOTES_ID", "\"5d9a4fd0aba7ffefbf989152479237bd182578e58e59eb2fbfe94f6de8011803\""
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -164,6 +164,7 @@ kotlin {
|
||||
|
||||
composeCompiler {
|
||||
reportsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
metricsDestination = layout.buildDirectory.dir("compose_compiler")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -252,6 +253,7 @@ dependencies {
|
||||
|
||||
// Language picker and Theme chooser
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.androidx.window.core.android
|
||||
|
||||
// Local model for language identification
|
||||
playImplementation libs.google.mlkit.language.id
|
||||
|
||||
@@ -25,6 +25,7 @@ import android.graphics.Color
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.Nip96Retriever
|
||||
@@ -33,6 +34,9 @@ import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.fail
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert
|
||||
import org.junit.Ignore
|
||||
@@ -61,7 +65,11 @@ class ImageUploadTesting {
|
||||
val bytes = baos.toByteArray()
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
val account = Account(KeyPair())
|
||||
val account =
|
||||
Account(
|
||||
AccountSettings(KeyPair()),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
|
||||
val result =
|
||||
Nip96Uploader(account)
|
||||
|
||||
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
@@ -30,7 +31,9 @@ import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.junit.Test
|
||||
@@ -133,7 +136,7 @@ class ThreadAssemblerTest {
|
||||
null,
|
||||
)
|
||||
|
||||
val account = Account(KeyPair())
|
||||
val account = Account(AccountSettings(KeyPair()), scope = CoroutineScope(Dispatchers.IO + SupervisorJob()))
|
||||
withContext(Dispatchers.Main) {
|
||||
val user = account.userProfile().live()
|
||||
}
|
||||
|
||||
@@ -42,9 +42,8 @@ object PushDistributorHandler : PushDistributorActions {
|
||||
private val unifiedPush: UnifiedPush = UnifiedPush
|
||||
|
||||
private var endpointInternal = ""
|
||||
val endpoint = endpointInternal
|
||||
|
||||
fun getSavedEndpoint() = endpoint
|
||||
fun getSavedEndpoint() = endpointInternal
|
||||
|
||||
fun setEndpoint(newEndpoint: String) {
|
||||
endpointInternal = newEndpoint
|
||||
|
||||
@@ -22,7 +22,6 @@ 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
|
||||
@@ -33,10 +32,7 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrC
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
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
|
||||
|
||||
@@ -46,7 +42,7 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
}
|
||||
|
||||
private val appContext = Amethyst.instance.applicationContext
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val scope = Amethyst.instance.applicationIOScope
|
||||
private val eventCache = LruCache<String, String>(100)
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
@@ -55,11 +51,13 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
message: ByteArray,
|
||||
instance: String,
|
||||
) {
|
||||
val messageStr = String(message)
|
||||
Log.d(TAG, "New message ${message.decodeToString()} for Instance: $instance")
|
||||
val messageStr = message.decodeToString()
|
||||
Log.d(TAG, "New message $messageStr for Instance: $instance")
|
||||
scope.launch {
|
||||
try {
|
||||
parseMessage(messageStr)?.let { receiveIfNew(it) }
|
||||
parseMessage(messageStr)?.let {
|
||||
receiveIfNew(it)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message could not be parsed: ${e.message}")
|
||||
@@ -86,32 +84,25 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
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)
|
||||
if (sanitizedEndpoint != pushHandler.getSavedEndpoint()) {
|
||||
Log.d(TAG, "New endpoint provided:- $endpoint for Instance: $instance ${pushHandler.getSavedEndpoint()} $sanitizedEndpoint")
|
||||
pushHandler.setEndpoint(sanitizedEndpoint)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
RegisterAccounts(LocalPreferences.allSavedAccounts()).go(sanitizedEndpoint)
|
||||
notificationManager().getOrCreateZapChannel(appContext)
|
||||
notificationManager().getOrCreateDMChannel(appContext)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Same endpoint provided:- $endpoint for Instance: $instance $sanitizedEndpoint")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -119,7 +110,7 @@ class PushMessageReceiver : MessagingReceiver() {
|
||||
context: Context,
|
||||
instance: String,
|
||||
) {
|
||||
val removedEndpoint = pushHandler.endpoint
|
||||
val removedEndpoint = pushHandler.getSavedEndpoint()
|
||||
Log.d(TAG, "Endpoint: $removedEndpoint removed for Instance: $instance")
|
||||
Log.d(TAG, "App is unregistered. ")
|
||||
pushHandler.forceRemoveDistributor(context)
|
||||
|
||||
@@ -29,11 +29,14 @@ object PushNotificationUtils {
|
||||
private val pushHandler = PushDistributorHandler
|
||||
|
||||
suspend fun init(accounts: List<AccountInfo>) {
|
||||
if (hasInit || pushHandler.savedDistributorExists()) {
|
||||
if (hasInit) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (pushHandler.savedDistributorExists()) {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
|
||||
RegisterAccounts(accounts).go(pushHandler.getSavedEndpoint())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -55,10 +55,10 @@ 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 com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -67,7 +67,7 @@ import kotlinx.collections.immutable.toImmutableList
|
||||
@Composable
|
||||
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val notificationPermissionState =
|
||||
CheckifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
|
||||
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
|
||||
|
||||
if (notificationPermissionState.status.isGranted) {
|
||||
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
|
||||
@@ -150,9 +150,6 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val currentDistributor = PushDistributorHandler.getSavedDistributor()
|
||||
PushDistributorHandler.saveDistributor(currentDistributor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package androidx.compose.material3.adaptive
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.toComposeRect
|
||||
import androidx.window.layout.FoldingFeature
|
||||
|
||||
/**
|
||||
* Calculates the [Posture] for a given list of [FoldingFeature]s. This methods converts framework
|
||||
* folding info into the Material-opinionated posture info.
|
||||
*/
|
||||
fun calculatePosture(foldingFeatures: List<FoldingFeature>): Posture {
|
||||
var isTableTop = false
|
||||
val hingeList = mutableListOf<HingeInfo>()
|
||||
@Suppress("ListIterator")
|
||||
foldingFeatures.forEach {
|
||||
if (it.orientation == FoldingFeature.Orientation.HORIZONTAL &&
|
||||
it.state == FoldingFeature.State.HALF_OPENED
|
||||
) {
|
||||
isTableTop = true
|
||||
}
|
||||
hingeList.add(
|
||||
HingeInfo(
|
||||
bounds = it.bounds.toComposeRect(),
|
||||
isFlat = it.state == FoldingFeature.State.FLAT,
|
||||
isVertical = it.orientation == FoldingFeature.Orientation.VERTICAL,
|
||||
isSeparating = it.isSeparating,
|
||||
isOccluding = it.occlusionType == FoldingFeature.OcclusionType.FULL,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Posture(isTableTop, hingeList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Posture info that can help make layout adaptation decisions. For example when
|
||||
* [Posture.separatingVerticalHingeBounds] is not empty, the layout may want to avoid putting any
|
||||
* content over those hinge area. We suggest to use [calculatePosture] to retrieve instances of this
|
||||
* class in applications, unless you have a strong need of customization that cannot be fulfilled by
|
||||
* the default implementation.
|
||||
*
|
||||
* Note that the hinge bounds will be represent as [Rect] with window coordinates, instead of layout
|
||||
* coordinate.
|
||||
*
|
||||
* @constructor create an instance of [Posture]
|
||||
* @property isTabletop `true` if the current window is considered as in the table top mode, i.e.
|
||||
* there is one half-opened horizontal hinge in the middle of the current window. When
|
||||
* this is `true` it usually means it's hard for users to interact with the window area
|
||||
* around the hinge and developers may consider separating the layout along the hinge and
|
||||
* show software keyboard or other controls in the bottom half of the window.
|
||||
* @property hingeList a list of all hinges that are relevant to the posture.
|
||||
*/
|
||||
@Immutable
|
||||
class Posture(
|
||||
val isTabletop: Boolean = false,
|
||||
val hingeList: List<HingeInfo> = emptyList(),
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is Posture) return false
|
||||
if (isTabletop != other.isTabletop) return false
|
||||
if (hingeList != other.hingeList) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = isTabletop.hashCode()
|
||||
result = 31 * result + hingeList.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
@Suppress("ListIterator")
|
||||
return "Posture(isTabletop=$isTabletop, " +
|
||||
"hinges=[${hingeList.joinToString(", ")}])"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of vertical hinge bounds that are separating.
|
||||
*/
|
||||
val Posture.separatingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isSeparating }
|
||||
|
||||
/**
|
||||
* Returns the list of vertical hinge bounds that are occluding.
|
||||
*/
|
||||
val Posture.occludingVerticalHingeBounds get() = hingeList.getBounds { isVertical && isOccluding }
|
||||
|
||||
/**
|
||||
* Returns the list of all vertical hinge bounds.
|
||||
*/
|
||||
val Posture.allVerticalHingeBounds get() = hingeList.getBounds { isVertical }
|
||||
|
||||
/**
|
||||
* Returns the list of horizontal hinge bounds that are separating.
|
||||
*/
|
||||
val Posture.separatingHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical && isSeparating }
|
||||
|
||||
/**
|
||||
* Returns the list of horizontal hinge bounds that are occluding.
|
||||
*/
|
||||
val Posture.occludingHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical && isOccluding }
|
||||
|
||||
/**
|
||||
* Returns the list of all horizontal hinge bounds.
|
||||
*/
|
||||
val Posture.allHorizontalHingeBounds
|
||||
get() = hingeList.getBounds { !isVertical }
|
||||
|
||||
/**
|
||||
* A class that contains the info of a hinge relevant to a [Posture].
|
||||
*
|
||||
* @param bounds the bounds of the hinge in the relevant viewport.
|
||||
* @param isFlat `true` if the hinge is fully open and the relevant window space presented to the
|
||||
* user is flat.
|
||||
* @param isVertical `true` if the hinge is a vertical one, i.e., it separates the viewport into
|
||||
* left and right; `false` if the hinge is horizontal, i.e., it separates the viewport
|
||||
* into top and bottom.
|
||||
* @param isSeparating `true` if the hinge creates two logical display areas.
|
||||
* @param isOccluding `true` if the hinge conceals part of the display.
|
||||
*/
|
||||
@Immutable
|
||||
class HingeInfo(
|
||||
val bounds: Rect,
|
||||
val isFlat: Boolean,
|
||||
val isVertical: Boolean,
|
||||
val isSeparating: Boolean,
|
||||
val isOccluding: Boolean,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is HingeInfo) return false
|
||||
if (bounds != other.bounds) return false
|
||||
if (isFlat != other.isFlat) return false
|
||||
if (isVertical != other.isVertical) return false
|
||||
if (isSeparating != other.isSeparating) return false
|
||||
if (isOccluding != other.isOccluding) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = bounds.hashCode()
|
||||
result = 31 * result + isFlat.hashCode()
|
||||
result = 31 * result + isVertical.hashCode()
|
||||
result = 31 * result + isSeparating.hashCode()
|
||||
result = 31 * result + isOccluding.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String =
|
||||
"HingeInfo(bounds=$bounds, " +
|
||||
"isFlat=$isFlat, " +
|
||||
"isVertical=$isVertical, " +
|
||||
"isSeparating=$isSeparating, " +
|
||||
"isOccluding=$isOccluding)"
|
||||
}
|
||||
|
||||
private inline fun List<HingeInfo>.getBounds(predicate: HingeInfo.() -> Boolean): List<Rect> =
|
||||
@Suppress("ListIterator")
|
||||
mapNotNull { if (it.predicate()) it.bounds else null }
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package androidx.compose.material3.adaptive
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import androidx.window.layout.FoldingFeature
|
||||
import androidx.window.layout.WindowInfoTracker
|
||||
import androidx.window.layout.WindowMetricsCalculator
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
@Composable
|
||||
fun currentWindowAdaptiveInfo(): WindowAdaptiveInfo {
|
||||
val windowSize =
|
||||
with(LocalDensity.current) {
|
||||
currentWindowSize().toSize().toDpSize()
|
||||
}
|
||||
return WindowAdaptiveInfo(
|
||||
WindowSizeClass.compute(windowSize.width.value, windowSize.height.value),
|
||||
calculatePosture(collectFoldingFeaturesAsState().value),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns and automatically update the current window size from [WindowMetricsCalculator].
|
||||
*
|
||||
* @return an [IntSize] that represents the current window size.
|
||||
*/
|
||||
@Composable
|
||||
fun currentWindowSize(): IntSize {
|
||||
// Observe view configuration changes and recalculate the size class on each change. We can't
|
||||
// use Activity#onConfigurationChanged as this will sometimes fail to be called on different
|
||||
// API levels, hence why this function needs to be @Composable so we can observe the
|
||||
// ComposeView's configuration changes.
|
||||
LocalConfiguration.current
|
||||
val windowBounds =
|
||||
WindowMetricsCalculator
|
||||
.getOrCreate()
|
||||
.computeCurrentWindowMetrics(LocalContext.current)
|
||||
.bounds
|
||||
return IntSize(windowBounds.width(), windowBounds.height())
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the current window folding features from [WindowInfoTracker] in to a [State].
|
||||
*
|
||||
* @return a [State] of a [FoldingFeature] list.
|
||||
*/
|
||||
@Composable
|
||||
fun collectFoldingFeaturesAsState(): State<List<FoldingFeature>> {
|
||||
val context = LocalContext.current
|
||||
return remember(context) {
|
||||
if (context is Activity) {
|
||||
// TODO(b/284347941) remove the instance check after the test bug is fixed.
|
||||
WindowInfoTracker
|
||||
.getOrCreate(context)
|
||||
.windowLayoutInfo(context)
|
||||
} else {
|
||||
WindowInfoTracker
|
||||
.getOrCreate(context)
|
||||
.windowLayoutInfo(context)
|
||||
}.map { it.displayFeatures.filterIsInstance<FoldingFeature>() }
|
||||
}.collectAsState(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* This class collects window info that affects adaptation decisions. An adaptive layout is supposed
|
||||
* to use the info from this class to decide how the layout is supposed to be adapted.
|
||||
*
|
||||
* @constructor create an instance of [WindowAdaptiveInfo]
|
||||
* @param windowSizeClass [WindowSizeClass] of the current window.
|
||||
* @param windowPosture [Posture] of the current window.
|
||||
*/
|
||||
@Immutable
|
||||
class WindowAdaptiveInfo(
|
||||
val windowSizeClass: WindowSizeClass,
|
||||
val windowPosture: Posture,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is WindowAdaptiveInfo) return false
|
||||
if (windowSizeClass != other.windowSizeClass) return false
|
||||
if (windowPosture != other.windowPosture) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = windowSizeClass.hashCode()
|
||||
result = 31 * result + windowPosture.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String = "WindowAdaptiveInfo(windowSizeClass=$windowSizeClass, windowPosture=$windowPosture)"
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class Amethyst : Application() {
|
||||
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Service Manager is only active when the activity is active.
|
||||
val serviceManager = ServiceManager()
|
||||
val serviceManager = ServiceManager(applicationIOScope)
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
|
||||
@@ -26,7 +26,7 @@ import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.DefaultReactions
|
||||
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
@@ -47,9 +47,11 @@ import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -91,9 +93,13 @@ private object PrefKeys {
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_USER_METADATA = "latestUserMetadata"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val LATEST_DM_RELAY_LIST = "latestDMRelayList"
|
||||
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
|
||||
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
|
||||
const val LATEST_MUTE_LIST = "latestMuteList"
|
||||
const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
@@ -117,58 +123,68 @@ object LocalPreferences {
|
||||
|
||||
private var currentAccount: String? = null
|
||||
private var savedAccounts: List<AccountInfo>? = null
|
||||
private var cachedAccounts: MutableMap<String, Account?> = mutableMapOf()
|
||||
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
|
||||
|
||||
suspend fun currentAccount(): String? {
|
||||
if (currentAccount == null) {
|
||||
currentAccount = encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
currentAccount =
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().getString(PrefKeys.CURRENT_ACCOUNT, null)
|
||||
}
|
||||
}
|
||||
return currentAccount
|
||||
}
|
||||
|
||||
private fun updateCurrentAccount(npub: String?) {
|
||||
private suspend fun updateCurrentAccount(npub: String?) {
|
||||
if (npub == null) {
|
||||
currentAccount = null
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().edit().clear().apply()
|
||||
}
|
||||
} else if (currentAccount != npub) {
|
||||
currentAccount = npub
|
||||
|
||||
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
|
||||
withContext(Dispatchers.IO) {
|
||||
encryptedPreferences().edit().apply { putString(PrefKeys.CURRENT_ACCOUNT, npub) }.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun savedAccounts(): List<AccountInfo> {
|
||||
private suspend fun savedAccounts(): List<AccountInfo> {
|
||||
if (savedAccounts == null) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
Event.mapper.readValue<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
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(),
|
||||
)
|
||||
withContext(Dispatchers.IO) {
|
||||
with(encryptedPreferences()) {
|
||||
val newSystemOfAccounts =
|
||||
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
|
||||
Event.mapper.readValue<List<AccountInfo>>(it)
|
||||
}
|
||||
|
||||
savedAccounts = migrated
|
||||
if (!newSystemOfAccounts.isNullOrEmpty()) {
|
||||
savedAccounts = newSystemOfAccounts
|
||||
} else {
|
||||
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
|
||||
|
||||
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
|
||||
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
|
||||
|
||||
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return savedAccounts!!
|
||||
}
|
||||
|
||||
fun cachedAccounts() = savedAccounts
|
||||
|
||||
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (savedAccounts != accounts) {
|
||||
@@ -189,35 +205,33 @@ object LocalPreferences {
|
||||
updateSavedAccounts(accounts)
|
||||
}
|
||||
|
||||
private suspend fun setCurrentAccount(account: Account) =
|
||||
withContext(Dispatchers.IO) {
|
||||
val npub = account.userProfile().pubkeyNpub()
|
||||
val accInfo =
|
||||
AccountInfo(
|
||||
npub,
|
||||
account.isWriteable(),
|
||||
account.signer is NostrSignerExternal,
|
||||
)
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(accInfo)
|
||||
}
|
||||
private suspend fun setCurrentAccount(accountSettings: AccountSettings) {
|
||||
val npub = accountSettings.keyPair.pubKey.toNpub()
|
||||
val accInfo =
|
||||
AccountInfo(
|
||||
npub,
|
||||
accountSettings.isWriteable(),
|
||||
accountSettings.externalSignerPackageName != null,
|
||||
)
|
||||
updateCurrentAccount(npub)
|
||||
addAccount(accInfo)
|
||||
}
|
||||
|
||||
suspend fun switchToAccount(accountInfo: AccountInfo) = withContext(Dispatchers.IO) { updateCurrentAccount(accountInfo.npub) }
|
||||
suspend fun switchToAccount(accountInfo: AccountInfo) = updateCurrentAccount(accountInfo.npub)
|
||||
|
||||
/** Removes the account from the app level shared preferences */
|
||||
private suspend fun removeAccount(accountInfo: AccountInfo) {
|
||||
val accounts = savedAccounts().filter { it.npub != accountInfo.npub }
|
||||
updateSavedAccounts(accounts)
|
||||
updateSavedAccounts(savedAccounts().filter { it.npub != accountInfo.npub })
|
||||
}
|
||||
|
||||
/** Deletes the npub-specific shared preference file */
|
||||
private fun deleteUserPreferenceFile(npub: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
private suspend fun deleteUserPreferenceFile(npub: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val prefsDir = File(prefsDirPath)
|
||||
prefsDir.list()?.forEach {
|
||||
if (it.contains(npub)) {
|
||||
File(prefsDir, it).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,8 +260,7 @@ object LocalPreferences {
|
||||
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
|
||||
withContext(Dispatchers.IO) {
|
||||
val userPrefs = encryptedPreferences(accountInfo.npub)
|
||||
userPrefs.edit().clear().commit()
|
||||
encryptedPreferences(accountInfo.npub).edit().clear().commit()
|
||||
removeAccount(accountInfo)
|
||||
deleteUserPreferenceFile(accountInfo.npub)
|
||||
|
||||
@@ -259,117 +272,158 @@ object LocalPreferences {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePrefsForLogin(account: Account) {
|
||||
setCurrentAccount(account)
|
||||
saveToEncryptedStorage(account)
|
||||
suspend fun updatePrefsForLogin(accountSettings: AccountSettings) {
|
||||
setCurrentAccount(accountSettings)
|
||||
saveToEncryptedStorage(accountSettings)
|
||||
}
|
||||
|
||||
fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
|
||||
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
|
||||
|
||||
suspend fun saveToEncryptedStorage(account: Account) {
|
||||
suspend fun saveToEncryptedStorage(settings: AccountSettings) {
|
||||
Log.d("LocalPreferences", "Saving to encrypted storage")
|
||||
withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val prefs = encryptedPreferences(account.userProfile().pubkeyNpub())
|
||||
val prefs = encryptedPreferences(settings.keyPair.pubKey.toNpub())
|
||||
prefs
|
||||
.edit()
|
||||
.apply {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, account.signer is NostrSignerExternal)
|
||||
if (account.signer is NostrSignerExternal) {
|
||||
putBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, settings.externalSignerPackageName != null)
|
||||
if (settings.externalSignerPackageName != null) {
|
||||
remove(PrefKeys.NOSTR_PRIVKEY)
|
||||
putString(PrefKeys.SIGNER_PACKAGE_NAME, account.signer.launcher.signerPackageName)
|
||||
putString(PrefKeys.SIGNER_PACKAGE_NAME, settings.externalSignerPackageName)
|
||||
} else {
|
||||
account.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
remove(PrefKeys.SIGNER_PACKAGE_NAME)
|
||||
settings.keyPair.privKey?.let { putString(PrefKeys.NOSTR_PRIVKEY, it.toHexKey()) }
|
||||
}
|
||||
account.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(account.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, account.dontTranslateFrom)
|
||||
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, account.localRelayServers)
|
||||
settings.keyPair.pubKey.let { putString(PrefKeys.NOSTR_PUBKEY, it.toHexKey()) }
|
||||
putString(PrefKeys.RELAYS, Event.mapper.writeValueAsString(settings.localRelays))
|
||||
putStringSet(PrefKeys.DONT_TRANSLATE_FROM, settings.dontTranslateFrom)
|
||||
putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers)
|
||||
putString(
|
||||
PrefKeys.LANGUAGE_PREFS,
|
||||
Event.mapper.writeValueAsString(account.languagePreferences),
|
||||
Event.mapper.writeValueAsString(settings.languagePreferences),
|
||||
)
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(account.zapAmountChoices))
|
||||
putString(PrefKeys.TRANSLATE_TO, settings.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, Event.mapper.writeValueAsString(settings.zapAmountChoices.value))
|
||||
putString(
|
||||
PrefKeys.REACTION_CHOICES,
|
||||
Event.mapper.writeValueAsString(account.reactionChoices),
|
||||
Event.mapper.writeValueAsString(settings.reactionChoices.value),
|
||||
)
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, account.defaultZapType.name)
|
||||
putString(PrefKeys.DEFAULT_ZAPTYPE, settings.defaultZapType.value.name)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_FILE_SERVER,
|
||||
Event.mapper.writeValueAsString(account.defaultFileServer),
|
||||
Event.mapper.writeValueAsString(settings.defaultFileServer),
|
||||
)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST,
|
||||
account.defaultNotificationFollowList.value,
|
||||
settings.defaultNotificationFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
settings.defaultDiscoveryFollowList.value,
|
||||
)
|
||||
putString(
|
||||
PrefKeys.ZAP_PAYMENT_REQUEST_SERVER,
|
||||
Event.mapper.writeValueAsString(account.zapPaymentRequest),
|
||||
Event.mapper.writeValueAsString(settings.zapPaymentRequest),
|
||||
)
|
||||
if (account.backupContactList != null) {
|
||||
if (settings.backupContactList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_CONTACT_LIST,
|
||||
Event.mapper.writeValueAsString(account.backupContactList),
|
||||
Event.mapper.writeValueAsString(settings.backupContactList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_CONTACT_LIST)
|
||||
}
|
||||
|
||||
if (account.backupDMRelayList != null) {
|
||||
if (settings.backupUserMetadata != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_USER_METADATA,
|
||||
Event.mapper.writeValueAsString(settings.backupUserMetadata),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_USER_METADATA)
|
||||
}
|
||||
|
||||
if (settings.backupDMRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_DM_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(account.backupDMRelayList),
|
||||
Event.mapper.writeValueAsString(settings.backupDMRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (account.backupNIP65RelayList != null) {
|
||||
if (settings.backupNIP65RelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_NIP65_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(account.backupNIP65RelayList),
|
||||
Event.mapper.writeValueAsString(settings.backupNIP65RelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
}
|
||||
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, account.hideNIP17WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, account.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, account.filterSpamFromStrangers)
|
||||
if (settings.backupSearchRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_SEARCH_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupSearchRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupMuteList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_MUTE_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupMuteList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_MUTE_LIST)
|
||||
}
|
||||
|
||||
if (settings.backupPrivateHomeRelayList != null) {
|
||||
putString(
|
||||
PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST,
|
||||
Event.mapper.writeValueAsString(settings.backupPrivateHomeRelayList),
|
||||
)
|
||||
} else {
|
||||
remove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
|
||||
}
|
||||
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, settings.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, settings.proxyPort)
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
|
||||
|
||||
val regularMap =
|
||||
settings.lastReadPerRoute.value.mapValues {
|
||||
it.value.value
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.LAST_READ_PER_ROUTE,
|
||||
Event.mapper.writeValueAsString(account.lastReadPerRoute),
|
||||
Event.mapper.writeValueAsString(regularMap),
|
||||
)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, account.hasDonatedInVersion)
|
||||
putStringSet(PrefKeys.HAS_DONATED_IN_VERSION, settings.hasDonatedInVersion.value)
|
||||
|
||||
if (account.showSensitiveContent.value == null) {
|
||||
if (settings.showSensitiveContent.value == null) {
|
||||
remove(PrefKeys.SHOW_SENSITIVE_CONTENT)
|
||||
} else {
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, account.showSensitiveContent.value!!)
|
||||
putBoolean(PrefKeys.SHOW_SENSITIVE_CONTENT, settings.showSensitiveContent.value!!)
|
||||
}
|
||||
|
||||
putString(
|
||||
PrefKeys.PENDING_ATTESTATIONS,
|
||||
Event.mapper.writeValueAsString(account.pendingAttestations.value),
|
||||
Event.mapper.writeValueAsString(settings.pendingAttestations.value),
|
||||
)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): Account? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadCurrentAccountFromEncryptedStorage(it) }
|
||||
|
||||
suspend fun saveSharedSettings(
|
||||
sharedSettings: Settings,
|
||||
@@ -402,39 +456,39 @@ object LocalPreferences {
|
||||
|
||||
val mutex = Mutex()
|
||||
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): Account? =
|
||||
withContext(Dispatchers.IO) {
|
||||
suspend fun loadCurrentAccountFromEncryptedStorage(npub: String): AccountSettings? {
|
||||
// if already loaded, return right away
|
||||
if (cachedAccounts.containsKey(npub)) {
|
||||
return cachedAccounts[npub]
|
||||
}
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
if (cachedAccounts.containsKey(npub)) {
|
||||
return@withContext cachedAccounts.get(npub)
|
||||
}
|
||||
|
||||
val account = innerLoadCurrentAccountFromEncryptedStorage(npub)
|
||||
account?.registerObservers()
|
||||
val accountSettings = innerLoadCurrentAccountFromEncryptedStorage(npub)
|
||||
|
||||
cachedAccounts.put(npub, account)
|
||||
cachedAccounts.put(npub, accountSettings)
|
||||
|
||||
return@withContext account
|
||||
return@withContext accountSettings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): Account? {
|
||||
Log.d("LocalPreferences", "Load account from file")
|
||||
private suspend fun innerLoadCurrentAccountFromEncryptedStorage(npub: String?): AccountSettings? {
|
||||
Log.d("LocalPreferences", "Load account from file $npub")
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
checkNotInMainThread()
|
||||
|
||||
return@withContext with(encryptedPreferences(npub)) {
|
||||
val privKey = getString(PrefKeys.NOSTR_PRIVKEY, null)
|
||||
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 localRelays =
|
||||
getString(PrefKeys.RELAYS, "[]")?.let {
|
||||
println("LocalRelays: $it")
|
||||
Event.mapper.readValue<Set<RelaySetupInfo>?>(it)
|
||||
}
|
||||
?: setOf<RelaySetupInfo>()
|
||||
val externalSignerPackageName =
|
||||
getString(PrefKeys.SIGNER_PACKAGE_NAME, null)
|
||||
?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
|
||||
@@ -448,149 +502,27 @@ object LocalPreferences {
|
||||
val defaultDiscoveryFollowList =
|
||||
getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices =
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]")
|
||||
?.let { Event.mapper.readValue<List<Long>?>(it) }
|
||||
?.ifEmpty { DefaultZapAmounts }
|
||||
?: DefaultZapAmounts
|
||||
|
||||
val reactionChoices =
|
||||
getString(PrefKeys.REACTION_CHOICES, "[]")
|
||||
?.let { Event.mapper.readValue<List<String>?>(it) }
|
||||
?.ifEmpty { DefaultReactions }
|
||||
?: DefaultReactions
|
||||
|
||||
val defaultZapType =
|
||||
getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
|
||||
LnZapEvent.ZapType.values().firstOrNull { it.name == serverName }
|
||||
}
|
||||
?: LnZapEvent.ZapType.PUBLIC
|
||||
LnZapEvent.ZapType.entries.firstOrNull { it.name == serverName }
|
||||
} ?: LnZapEvent.ZapType.PUBLIC
|
||||
|
||||
val defaultFileServer =
|
||||
try {
|
||||
getString(PrefKeys.DEFAULT_FILE_SERVER, "")?.let { serverName ->
|
||||
Event.mapper.readValue<Nip96MediaServers.ServerName>(serverName)
|
||||
}
|
||||
?: Nip96MediaServers.DEFAULT[0]
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Failed to decode saved File Server", e)
|
||||
e.printStackTrace()
|
||||
Nip96MediaServers.DEFAULT[0]
|
||||
}
|
||||
val localRelays = parseOrNull<Set<RelaySetupInfo>>(PrefKeys.RELAYS) ?: emptySet()
|
||||
val reactionChoices = parseOrNull<List<String>>(PrefKeys.REACTION_CHOICES)?.ifEmpty { DefaultReactions } ?: DefaultReactions
|
||||
val zapAmountChoices = parseOrNull<List<Long>>(PrefKeys.ZAP_AMOUNTS)?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
|
||||
|
||||
val zapPaymentRequestServer =
|
||||
try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
Event.mapper.readValue<Nip47WalletConnect.Nip47URI?>(it)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
val defaultFileServer = parseOrNull<Nip96MediaServers.ServerName>(PrefKeys.DEFAULT_FILE_SERVER) ?: Nip96MediaServers.DEFAULT[0]
|
||||
val zapPaymentRequestServer = parseOrNull<Nip47WalletConnect.Nip47URI>(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER)
|
||||
val pendingAttestations = parseOrNull<Map<HexKey, String>>(PrefKeys.PENDING_ATTESTATIONS) ?: mapOf()
|
||||
val languagePreferences = parseOrNull<Map<String, String>>(PrefKeys.LANGUAGE_PREFS) ?: mapOf()
|
||||
|
||||
val latestContactList =
|
||||
try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
if (it != "null") {
|
||||
println("Decoding Contact List: $it")
|
||||
Event.fromJson(it) as ContactListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val latestDmRelayList =
|
||||
try {
|
||||
getString(PrefKeys.LATEST_DM_RELAY_LIST, null)?.let {
|
||||
if (it != "null") {
|
||||
println("Decoding DM Relay List: $it")
|
||||
Event.fromJson(it) as ChatMessageRelayListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding DM Relay List ${getString(PrefKeys.LATEST_DM_RELAY_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val latestNip65RelayList =
|
||||
try {
|
||||
getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)?.let {
|
||||
if (it != "null") {
|
||||
println("Decoding NIP65 Relay List: $it")
|
||||
Event.fromJson(it) as AdvertisedRelayListEvent?
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding NIP65 Relay List ${getString(PrefKeys.LATEST_NIP65_RELAY_LIST, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val pendingAttestations =
|
||||
try {
|
||||
getString(PrefKeys.PENDING_ATTESTATIONS, null)?.let {
|
||||
println("Decoding Attestation List: " + it)
|
||||
if (it != null) {
|
||||
Event.mapper.readValue<Map<HexKey, String>>(it)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Contact List ${getString(PrefKeys.PENDING_ATTESTATIONS, null)}",
|
||||
e,
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
val languagePreferences =
|
||||
try {
|
||||
getString(PrefKeys.LANGUAGE_PREFS, null)?.let {
|
||||
Event.mapper.readValue<Map<String, String>?>(it)
|
||||
}
|
||||
?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
val latestUserMetadata = parseEventOrNull<MetadataEvent>(PrefKeys.LATEST_USER_METADATA)
|
||||
val latestContactList = parseEventOrNull<ContactListEvent>(PrefKeys.LATEST_CONTACT_LIST)
|
||||
val latestDmRelayList = parseEventOrNull<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
|
||||
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
|
||||
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
|
||||
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
|
||||
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
@@ -609,81 +541,84 @@ object LocalPreferences {
|
||||
val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true)
|
||||
|
||||
val lastReadPerRoute =
|
||||
try {
|
||||
getString(PrefKeys.LAST_READ_PER_ROUTE, null)?.let {
|
||||
Event.mapper.readValue<Map<String, Long>?>(it)
|
||||
}
|
||||
?: mapOf()
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w(
|
||||
"LocalPreferences",
|
||||
"Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}",
|
||||
e,
|
||||
)
|
||||
e.printStackTrace()
|
||||
mapOf()
|
||||
}
|
||||
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
|
||||
MutableStateFlow(it.value)
|
||||
} ?: mapOf()
|
||||
|
||||
val keyPair = KeyPair(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray())
|
||||
val signer =
|
||||
if (loginWithExternalSigner) {
|
||||
val packageName =
|
||||
getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: "com.greenart7c3.nostrsigner"
|
||||
NostrSignerExternal(
|
||||
pubKey,
|
||||
ExternalSignerLauncher(pubKey.hexToByteArray().toNpub(), packageName),
|
||||
)
|
||||
} else {
|
||||
NostrSignerInternal(keyPair)
|
||||
}
|
||||
|
||||
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||
|
||||
val account =
|
||||
Account(
|
||||
keyPair = keyPair,
|
||||
signer = signer,
|
||||
localRelays = localRelays,
|
||||
localRelayServers = localRelayServers,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = zapAmountChoices,
|
||||
reactionChoices = reactionChoices,
|
||||
defaultZapType = defaultZapType,
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
backupContactList = latestContactList,
|
||||
backupNIP65RelayList = latestNip65RelayList,
|
||||
backupDMRelayList = latestDmRelayList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = MutableStateFlow(showSensitiveContent),
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = lastReadPerRoute,
|
||||
hasDonatedInVersion = hasDonatedInVersion,
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations ?: emptyMap()),
|
||||
)
|
||||
|
||||
// Loads from DB
|
||||
account.userProfile()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
// Loads Live Objects
|
||||
account.userProfile().live()
|
||||
}
|
||||
|
||||
return@with account
|
||||
return@with AccountSettings(
|
||||
keyPair = keyPair,
|
||||
externalSignerPackageName = externalSignerPackageName,
|
||||
localRelays = localRelays,
|
||||
localRelayServers = localRelayServers,
|
||||
dontTranslateFrom = dontTranslateFrom,
|
||||
languagePreferences = languagePreferences,
|
||||
translateTo = translateTo,
|
||||
zapAmountChoices = MutableStateFlow(zapAmountChoices.toImmutableList()),
|
||||
reactionChoices = MutableStateFlow(reactionChoices.toImmutableList()),
|
||||
defaultZapType = MutableStateFlow(defaultZapType),
|
||||
defaultFileServer = defaultFileServer,
|
||||
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList),
|
||||
defaultStoriesFollowList = MutableStateFlow(defaultStoriesFollowList),
|
||||
defaultNotificationFollowList = MutableStateFlow(defaultNotificationFollowList),
|
||||
defaultDiscoveryFollowList = MutableStateFlow(defaultDiscoveryFollowList),
|
||||
zapPaymentRequest = zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||
backupUserMetadata = latestUserMetadata,
|
||||
backupContactList = latestContactList,
|
||||
backupNIP65RelayList = latestNip65RelayList,
|
||||
backupDMRelayList = latestDmRelayList,
|
||||
backupSearchRelayList = latestSearchRelayList,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
|
||||
backupMuteList = latestMuteList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
showSensitiveContent = MutableStateFlow(showSensitiveContent),
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
|
||||
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
|
||||
pendingAttestations = MutableStateFlow(pendingAttestations),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> SharedPreferences.parseOrNull(key: String): T? {
|
||||
val value = getString(key, null)
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
if (T::class.java.isInstance(Event::class.java)) {
|
||||
Event.fromJson(value) as T?
|
||||
} else {
|
||||
Event.mapper.readValue<T?>(value)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> SharedPreferences.parseEventOrNull(key: String): T? {
|
||||
val value = getString(key, null)
|
||||
if (value.isNullOrEmpty() || value == "null") {
|
||||
return null
|
||||
}
|
||||
return try {
|
||||
Event.fromJson(value) as T?
|
||||
} catch (e: Throwable) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("LocalPreferences", "Error Decoding $key from Preferences with value $value", e)
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.size.Precision
|
||||
import coil.util.DebugLogger
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.Base64Fetcher
|
||||
@@ -57,18 +58,19 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@Stable
|
||||
class ServiceManager {
|
||||
class ServiceManager(
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private var isStarted: Boolean =
|
||||
false // to not open amber in a loop trying to use auth relays and registering for notifications
|
||||
private var account: Account? = null
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var collectorJob: Job? = null
|
||||
|
||||
private fun start(account: Account) {
|
||||
@@ -86,9 +88,9 @@ class ServiceManager {
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClientManager.setDefaultProxy(account?.proxy)
|
||||
HttpClientManager.setDefaultProxy(account?.settings?.proxy)
|
||||
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
LocalCache.antiSpam.active = account?.filterSpamFromStrangers ?: true
|
||||
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
|
||||
Coil.setImageLoader {
|
||||
Amethyst.instance
|
||||
.imageLoaderBuilder()
|
||||
@@ -100,8 +102,11 @@ class ServiceManager {
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
add(Base64Fetcher.Factory)
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClientManager.getHttpClient() }
|
||||
}.apply {
|
||||
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
|
||||
this.logger(DebugLogger())
|
||||
}
|
||||
}.okHttpClient { HttpClientManager.getHttpClient() }
|
||||
.precision(Precision.INEXACT)
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
@@ -126,20 +131,23 @@ class ServiceManager {
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrAccountDataSource.otherAccounts =
|
||||
LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
try {
|
||||
it.npub.bechToBytes().toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
NostrDiscoveryDataSource.account = myAccount
|
||||
|
||||
NostrAccountDataSource.otherAccounts =
|
||||
runBlocking {
|
||||
LocalPreferences.allSavedAccounts().mapNotNull {
|
||||
try {
|
||||
it.npub.bechToBytes().toHexKey()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification Elements
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
@@ -190,7 +198,7 @@ class ServiceManager {
|
||||
LocalCache.cleanObservers()
|
||||
}
|
||||
|
||||
fun trimMemory() {
|
||||
suspend fun trimMemory() {
|
||||
LocalCache.cleanObservers()
|
||||
|
||||
val accounts =
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.encoders.toNpub
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateOutboxRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerExternal
|
||||
import com.vitorpamplona.quartz.signers.NostrSignerInternal
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels =
|
||||
setOf(
|
||||
// Anigma's Nostr
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb",
|
||||
// Amethyst's Group
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5",
|
||||
)
|
||||
|
||||
val DefaultReactions =
|
||||
persistentListOf(
|
||||
"\uD83D\uDE80",
|
||||
"\uD83E\uDEC2",
|
||||
"\uD83D\uDC40",
|
||||
"\uD83D\uDE02",
|
||||
"\uD83C\uDF89",
|
||||
"\uD83E\uDD14",
|
||||
"\uD83D\uDE31",
|
||||
)
|
||||
|
||||
val DefaultNIP65List =
|
||||
listOf(
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.mom/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nos.lol/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
AdvertisedRelayListEvent.AdvertisedRelayInfo(RelayUrlFormatter.normalize("wss://nostr.bitcoiner.social/"), AdvertisedRelayListEvent.AdvertisedRelayType.BOTH),
|
||||
)
|
||||
|
||||
val DefaultDMRelayList =
|
||||
listOf(
|
||||
RelayUrlFormatter.normalize("wss://auth.nostr1.com/"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.mom/"),
|
||||
RelayUrlFormatter.normalize("wss://nos.lol/"),
|
||||
)
|
||||
|
||||
val DefaultSearchRelayList =
|
||||
listOf(
|
||||
RelayUrlFormatter.normalize("wss://relay.nostr.band"),
|
||||
RelayUrlFormatter.normalize("wss://nostr.wine"),
|
||||
RelayUrlFormatter.normalize("wss://relay.noswhere.com"),
|
||||
)
|
||||
|
||||
val DefaultZapAmounts = persistentListOf(100L, 500L, 1000L)
|
||||
|
||||
fun getLanguagesSpokenByUser(): Set<String> {
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
|
||||
val codedList = mutableSetOf<String>()
|
||||
for (i in 0 until languageList.size()) {
|
||||
languageList.get(i)?.let { codedList.add(it.language) }
|
||||
}
|
||||
return codedList
|
||||
}
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val GLOBAL_FOLLOWS = " Global "
|
||||
|
||||
// This has spaces to avoid mixing with a potential NIP-51 list with the same name.
|
||||
val KIND3_FOLLOWS = " All Follows "
|
||||
|
||||
@Stable
|
||||
class AccountSettings(
|
||||
val keyPair: KeyPair,
|
||||
var externalSignerPackageName: String? = null,
|
||||
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
|
||||
var localRelayServers: Set<String> = setOf(),
|
||||
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: MutableStateFlow<ImmutableList<Long>> = MutableStateFlow(DefaultZapAmounts),
|
||||
var reactionChoices: MutableStateFlow<ImmutableList<String>> = MutableStateFlow(DefaultReactions),
|
||||
val defaultZapType: MutableStateFlow<LnZapEvent.ZapType> = MutableStateFlow(LnZapEvent.ZapType.PUBLIC),
|
||||
var defaultFileServer: Nip96MediaServers.ServerName = Nip96MediaServers.DEFAULT[0],
|
||||
val defaultHomeFollowList: MutableStateFlow<String> = MutableStateFlow(KIND3_FOLLOWS),
|
||||
val defaultStoriesFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultNotificationFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
val defaultDiscoveryFollowList: MutableStateFlow<String> = MutableStateFlow(GLOBAL_FOLLOWS),
|
||||
var zapPaymentRequest: Nip47WalletConnect.Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
var backupUserMetadata: MetadataEvent? = null,
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var backupDMRelayList: ChatMessageRelayListEvent? = null,
|
||||
var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
|
||||
var backupSearchRelayList: SearchRelayListEvent? = null,
|
||||
var backupMuteList: MuteListEvent? = null,
|
||||
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
|
||||
var proxy: Proxy? = null,
|
||||
var proxyPort: Int = 9050,
|
||||
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
|
||||
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
|
||||
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
|
||||
) {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
|
||||
|
||||
fun saveAccountSettings() {
|
||||
saveable.update { AccountSettingsUpdater(this) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
|
||||
|
||||
fun createSigner() =
|
||||
if (keyPair.privKey != null) {
|
||||
NostrSignerInternal(keyPair)
|
||||
} else {
|
||||
when (val packageName = externalSignerPackageName) {
|
||||
null -> NostrSignerInternal(keyPair)
|
||||
else -> NostrSignerExternal(keyPair.pubKey.toHexKey(), ExternalSignerLauncher(keyPair.pubKey.toNpub(), packageName))
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// Zaps and Reactions
|
||||
// ---
|
||||
|
||||
fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
|
||||
if (defaultZapType.value != zapType) {
|
||||
defaultZapType.tryEmit(zapType)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
if (zapAmountChoices.value != newAmounts) {
|
||||
zapAmountChoices.tryEmit(newAmounts.toImmutableList())
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47WalletConnect.Nip47URI?) {
|
||||
if (zapPaymentRequest != newServer) {
|
||||
zapPaymentRequest = newServer
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeReactionTypes(newTypes: List<String>) {
|
||||
if (reactionChoices.value != newTypes) {
|
||||
reactionChoices.tryEmit(newTypes.toImmutableList())
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// file servers
|
||||
// ---
|
||||
|
||||
fun changeDefaultFileServer(server: Nip96MediaServers.ServerName) {
|
||||
if (defaultFileServer != server) {
|
||||
defaultFileServer = server
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// list names
|
||||
// ---
|
||||
|
||||
fun changeDefaultHomeFollowList(name: String) {
|
||||
if (defaultHomeFollowList.value != name) {
|
||||
defaultHomeFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: String) {
|
||||
if (defaultStoriesFollowList.value != name) {
|
||||
defaultStoriesFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultNotificationFollowList(name: String) {
|
||||
if (defaultNotificationFollowList.value != name) {
|
||||
defaultNotificationFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeDefaultDiscoveryFollowList(name: String) {
|
||||
if (defaultDiscoveryFollowList.value != name) {
|
||||
defaultDiscoveryFollowList.tryEmit(name)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// proxy settings
|
||||
// ---
|
||||
|
||||
fun isProxyEnabled() = proxy != null
|
||||
|
||||
fun updateProxy(
|
||||
enabled: Boolean,
|
||||
portNumber: String,
|
||||
) {
|
||||
val port = portNumber.toIntOrNull() ?: return
|
||||
if (proxyPort != port && isProxyEnabled() != enabled) {
|
||||
proxyPort = portNumber.toInt()
|
||||
proxy = HttpClientManager.initProxy(enabled, "127.0.0.1", proxyPort)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// language services
|
||||
// ---
|
||||
fun addDontTranslateFrom(languageCode: String) {
|
||||
if (!dontTranslateFrom.contains(languageCode)) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun translateToContains(languageCode: Locale) = translateTo.contains(languageCode.language)
|
||||
|
||||
fun updateTranslateTo(languageCode: Locale) {
|
||||
if (translateTo != languageCode.language) {
|
||||
translateTo = languageCode.language
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun prefer(
|
||||
source: String,
|
||||
target: String,
|
||||
preference: String,
|
||||
) {
|
||||
val key = "$source,$target"
|
||||
if (key !in languagePreferences) {
|
||||
languagePreferences = languagePreferences + Pair(key, preference)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun preferenceBetween(
|
||||
source: String,
|
||||
target: String,
|
||||
): String? = languagePreferences["$source,$target"]
|
||||
|
||||
// ----
|
||||
// Backup Lists
|
||||
// ----
|
||||
|
||||
fun updateLocalRelayServers(servers: Set<String>) {
|
||||
if (localRelayServers != servers) {
|
||||
localRelayServers = servers
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUserMetadata(newMetadata: MetadataEvent?) {
|
||||
if (newMetadata == null) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupUserMetadata?.id != newMetadata.id) {
|
||||
backupUserMetadata = newMetadata
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList == null || newContactList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList.id) {
|
||||
backupContactList = newContactList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDMRelayList(newDMRelayList: ChatMessageRelayListEvent?) {
|
||||
if (newDMRelayList == null || newDMRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupDMRelayList?.id != newDMRelayList.id) {
|
||||
backupDMRelayList = newDMRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNIP65RelayList(newNIP65RelayList: AdvertisedRelayListEvent?) {
|
||||
if (newNIP65RelayList == null || newNIP65RelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupNIP65RelayList?.id != newNIP65RelayList.id) {
|
||||
backupNIP65RelayList = newNIP65RelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSearchRelayList(newSearchRelayList: SearchRelayListEvent?) {
|
||||
if (newSearchRelayList == null || newSearchRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupSearchRelayList?.id != newSearchRelayList.id) {
|
||||
backupSearchRelayList = newSearchRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) {
|
||||
if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupPrivateHomeRelayList?.id != newPrivateHomeRelayList.id) {
|
||||
backupPrivateHomeRelayList = newPrivateHomeRelayList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMuteList(newMuteList: MuteListEvent?) {
|
||||
if (newMuteList == null || newMuteList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupMuteList?.id != newMuteList.id) {
|
||||
backupMuteList = newMuteList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// Warning dialogs
|
||||
// ----
|
||||
|
||||
fun setHideDeleteRequestDialog() {
|
||||
if (!hideDeleteRequestDialog) {
|
||||
hideDeleteRequestDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideNIP17WarningDialog() {
|
||||
if (!hideNIP17WarningDialog) {
|
||||
hideNIP17WarningDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun setHideBlockAlertDialog() {
|
||||
if (!hideBlockAlertDialog) {
|
||||
hideBlockAlertDialog = true
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateShowSensitiveContent(show: Boolean?): Boolean {
|
||||
if (showSensitiveContent.value != show) {
|
||||
showSensitiveContent.update { show }
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---
|
||||
// donations
|
||||
// ---
|
||||
|
||||
fun hasDonatedInVersion(versionName: String) = hasDonatedInVersion.value.contains(versionName)
|
||||
|
||||
fun observeDonatedInVersion(versionName: String) =
|
||||
hasDonatedInVersion
|
||||
.map {
|
||||
it.contains(versionName)
|
||||
}
|
||||
|
||||
fun markDonatedInThisVersion(versionName: String): Boolean {
|
||||
if (!hasDonatedInVersion.value.contains(versionName)) {
|
||||
hasDonatedInVersion.update {
|
||||
it + BuildConfig.VERSION_NAME
|
||||
}
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ----
|
||||
// last read flows
|
||||
// ----
|
||||
|
||||
fun getLastReadFlow(route: String): StateFlow<Long> = lastReadPerRoute.value[route] ?: addLastRead(route, 0)
|
||||
|
||||
private fun addLastRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
): MutableStateFlow<Long> =
|
||||
MutableStateFlow<Long>(timestampInSecs).also { newFlow ->
|
||||
lastReadPerRoute.update { it + Pair(route, newFlow) }
|
||||
saveAccountSettings()
|
||||
}
|
||||
|
||||
fun markAsRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
): Boolean {
|
||||
val lastTime = lastReadPerRoute.value[route]
|
||||
return if (lastTime == null) {
|
||||
addLastRead(route, timestampInSecs)
|
||||
true
|
||||
} else if (timestampInSecs > lastTime.value) {
|
||||
lastTime.tryEmit(timestampInSecs)
|
||||
saveAccountSettings()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// local relays
|
||||
// ----
|
||||
|
||||
fun updateLocalRelays(newLocalRelays: Set<RelaySetupInfo>) {
|
||||
if (!localRelays.equals(newLocalRelays)) {
|
||||
localRelays = newLocalRelays
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// attestations
|
||||
// ---
|
||||
|
||||
fun addPendingAttestation(
|
||||
id: HexKey,
|
||||
stamp: String,
|
||||
) {
|
||||
val current = pendingAttestations.value.get(id)
|
||||
if (current == null) {
|
||||
pendingAttestations.update {
|
||||
it + Pair(id, stamp)
|
||||
}
|
||||
saveAccountSettings()
|
||||
} else {
|
||||
if (current != stamp) {
|
||||
pendingAttestations.update {
|
||||
it + Pair(id, stamp)
|
||||
}
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---
|
||||
// filters
|
||||
// ---
|
||||
fun updateOptOutOptions(
|
||||
warnReports: Boolean,
|
||||
filterSpam: Boolean,
|
||||
): Boolean =
|
||||
if (warnAboutPostsWithReports != warnReports || filterSpam != filterSpamFromStrangers) {
|
||||
warnAboutPostsWithReports = warnReports
|
||||
filterSpamFromStrangers = filterSpam
|
||||
|
||||
saveAccountSettings()
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
class AccountSettingsUpdater(
|
||||
val accountSettings: AccountSettings,
|
||||
)
|
||||
@@ -22,16 +22,14 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.ammolite.relays.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.njumpLink
|
||||
import com.vitorpamplona.ammolite.relays.Relay
|
||||
import com.vitorpamplona.ammolite.relays.RelayStats
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
data class Spammer(
|
||||
val pubkeyHex: HexKey,
|
||||
@@ -82,10 +80,10 @@ class AntiSpamFilter {
|
||||
logOffender(hash, event)
|
||||
|
||||
if (relay != null) {
|
||||
RelayStats.newSpam(relay.url, "https://njump.me/${Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)}")
|
||||
RelayStats.newSpam(relay.url, njumpLink(Nip19Bech32.createNEvent(event.id, event.pubKey, event.kind, relay.url)))
|
||||
}
|
||||
|
||||
liveSpam.invalidateData()
|
||||
flowSpam.tryEmit(AntiSpamState(this))
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -108,27 +106,7 @@ class AntiSpamFilter {
|
||||
}
|
||||
}
|
||||
|
||||
val liveSpam: AntiSpamLiveData = AntiSpamLiveData(this)
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AntiSpamLiveData(
|
||||
val cache: AntiSpamFilter,
|
||||
) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
checkNotInMainThread()
|
||||
|
||||
bundler.invalidate {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
val flowSpam = MutableStateFlow<AntiSpamState>(AntiSpamState(this))
|
||||
}
|
||||
|
||||
class AntiSpamState(
|
||||
|
||||
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Stable
|
||||
@@ -75,7 +77,7 @@ class Chatroom {
|
||||
fun senderIntersects(keySet: Set<HexKey>): Boolean = authors.any { it.pubkeyHex in keySet }
|
||||
|
||||
fun pruneMessagesToTheLatestOnly(): Set<Note> {
|
||||
val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
|
||||
val sorted = roomMessages.sortedWith(DefaultFeedOrder)
|
||||
|
||||
val toKeep =
|
||||
if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) {
|
||||
@@ -84,7 +86,7 @@ class Chatroom {
|
||||
} else {
|
||||
// Old messages, keep the last one.
|
||||
sorted.take(1).toSet()
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false }
|
||||
} + sorted.filter { it.liveSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
|
||||
|
||||
val toRemove = roomMessages.minus(toKeep)
|
||||
roomMessages = toKeep
|
||||
|
||||
@@ -114,6 +114,8 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.StatusEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
@@ -463,6 +465,61 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: TorrentEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: TorrentCommentEvent,
|
||||
relay: Relay?,
|
||||
) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (relay != null) {
|
||||
author.addRelayBeingUsed(relay, event.createdAt)
|
||||
note.addRelay(relay)
|
||||
}
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
if (antiSpam.isSpam(event, relay)) {
|
||||
return
|
||||
}
|
||||
|
||||
val replyTo = computeReplyTo(event)
|
||||
|
||||
note.loadEvent(event, author, replyTo)
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: NIP90ContentDiscoveryResponseEvent,
|
||||
relay: Relay? = null,
|
||||
@@ -795,6 +852,8 @@ object LocalCache {
|
||||
.tagsWithoutCitations()
|
||||
.filter { it != event.activity()?.toTag() }
|
||||
.mapNotNull { checkGetOrCreateNote(it) }
|
||||
is TorrentCommentEvent ->
|
||||
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
|
||||
|
||||
is DraftEvent -> {
|
||||
event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) }
|
||||
@@ -1303,6 +1362,10 @@ object LocalCache {
|
||||
getChannelIfExists(it.toTag())?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
(deletedEvent as? TorrentCommentEvent)?.torrent()?.let {
|
||||
getNoteIfExists(it)?.removeReply(deleteNote)
|
||||
}
|
||||
|
||||
if (deletedEvent is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
val recipient =
|
||||
@@ -1328,6 +1391,9 @@ object LocalCache {
|
||||
deleteWraps(deletedEvent)
|
||||
}
|
||||
|
||||
deleteNote.clearFlow()
|
||||
deleteNote.clearLive()
|
||||
|
||||
notes.remove(deleteNote.idHex)
|
||||
}
|
||||
|
||||
@@ -1339,7 +1405,10 @@ object LocalCache {
|
||||
if (noteEvent is WrappedEvent) {
|
||||
deleteWraps(noteEvent)
|
||||
}
|
||||
it.clearFlow()
|
||||
it.clearLive()
|
||||
}
|
||||
|
||||
notes.remove(it.id)
|
||||
}
|
||||
}
|
||||
@@ -2114,7 +2183,7 @@ object LocalCache {
|
||||
.filter { _, item ->
|
||||
val noteEvent = item.event
|
||||
|
||||
noteEvent is TextNoteModificationEvent && noteEvent.pubKey == originalAuthor && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
|
||||
noteEvent is TextNoteModificationEvent && note.author == item.author && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time)
|
||||
}.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
|
||||
modificationCache.put(note.idHex, newNotes)
|
||||
@@ -2178,6 +2247,8 @@ object LocalCache {
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
// TODO: NEED TO TEST IF WRAPS COME BACK WHEN NEEDED BEFORE ACTIVATING
|
||||
// childrenToBeRemoved.addAll(removeIfWrap(it))
|
||||
removeFromCache(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.removeAllChildNotes())
|
||||
@@ -2187,13 +2258,31 @@ object LocalCache {
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} private messages with ${user.toBestDisplayName()} removed. ${it.roomMessages.size} kept",
|
||||
"PRUNE: ${toBeRemoved.size} private messages from ${user.toBestDisplayName()} to ${it.authors.joinToString(", ") { it.toBestDisplayName() }} removed. ${it.roomMessages.size} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeIfWrap(note: Note): List<Note> {
|
||||
val noteEvent = note.event
|
||||
|
||||
val children =
|
||||
if (noteEvent is WrappedEvent) {
|
||||
noteEvent.host?.id?.let {
|
||||
getNoteIfExists(it)?.let {
|
||||
removeFromCache(it)
|
||||
it.removeAllChildNotes()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return children ?: emptyList()
|
||||
}
|
||||
|
||||
fun prunePastVersionsOfReplaceables() {
|
||||
val toBeRemoved =
|
||||
notes.filter { _, note ->
|
||||
@@ -2302,6 +2391,9 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
note.clearFlow()
|
||||
note.clearLive()
|
||||
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
@@ -2635,6 +2727,8 @@ object LocalCache {
|
||||
is StatusEvent -> consume(event, relay)
|
||||
is TextNoteEvent -> consume(event, relay)
|
||||
is TextNoteModificationEvent -> consume(event, relay)
|
||||
is TorrentEvent -> consume(event, relay)
|
||||
is TorrentCommentEvent -> consume(event, relay)
|
||||
is VideoHorizontalEvent -> consume(event, relay)
|
||||
is VideoVerticalEvent -> consume(event, relay)
|
||||
is WikiNoteEvent -> consume(event, relay)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
@@ -40,11 +41,13 @@ import kotlinx.collections.immutable.toImmutableMap
|
||||
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
|
||||
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
|
||||
)
|
||||
@Stable
|
||||
open class Resource(
|
||||
var resourceType: String? = null,
|
||||
var id: String = "",
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Practitioner(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -53,6 +56,7 @@ class Practitioner(
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class Patient(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -61,6 +65,7 @@ class Patient(
|
||||
var gender: String? = null,
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class HumanName(
|
||||
var use: String? = null,
|
||||
var family: String? = null,
|
||||
@@ -69,6 +74,7 @@ class HumanName(
|
||||
fun assembleName(): String = given.joinToString(" ") + " " + family
|
||||
}
|
||||
|
||||
@Stable
|
||||
class Bundle(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -77,6 +83,7 @@ class Bundle(
|
||||
var entry: List<Resource> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class VisionPrescription(
|
||||
resourceType: String? = null,
|
||||
id: String = "",
|
||||
@@ -89,6 +96,7 @@ class VisionPrescription(
|
||||
var lensSpecification: List<LensSpecification> = arrayListOf(),
|
||||
) : Resource(resourceType, id)
|
||||
|
||||
@Stable
|
||||
class LensSpecification(
|
||||
var eye: String? = null,
|
||||
var sphere: Double? = null,
|
||||
@@ -105,6 +113,7 @@ class LensSpecification(
|
||||
var note: String? = null,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class Prism(
|
||||
var amount: Double? = null,
|
||||
var base: String? = null,
|
||||
|
||||
@@ -331,6 +331,7 @@ class User(
|
||||
}
|
||||
}
|
||||
|
||||
flowSet?.metadata?.invalidateData()
|
||||
liveSet?.innerMetadata?.invalidateData()
|
||||
}
|
||||
|
||||
@@ -434,12 +435,17 @@ class UserFlowSet(
|
||||
u: User,
|
||||
) {
|
||||
// Observers line up here.
|
||||
val metadata = UserBundledRefresherFlow(u)
|
||||
val follows = UserBundledRefresherFlow(u)
|
||||
val relays = UserBundledRefresherFlow(u)
|
||||
|
||||
fun isInUse(): Boolean = relays.stateFlow.subscriptionCount.value > 0 || follows.stateFlow.subscriptionCount.value > 0
|
||||
fun isInUse(): Boolean =
|
||||
metadata.stateFlow.subscriptionCount.value > 0 ||
|
||||
relays.stateFlow.subscriptionCount.value > 0 ||
|
||||
follows.stateFlow.subscriptionCount.value > 0
|
||||
|
||||
fun destroy() {
|
||||
metadata.destroy()
|
||||
relays.destroy()
|
||||
follows.destroy()
|
||||
}
|
||||
@@ -460,7 +466,6 @@ class UserLiveSet(
|
||||
val innerRelayInfo = UserBundledRefresherLiveData(u)
|
||||
val innerZaps = UserBundledRefresherLiveData(u)
|
||||
val innerBookmarks = UserBundledRefresherLiveData(u)
|
||||
val innerGallery = UserBundledRefresherLiveData(u)
|
||||
val innerStatuses = UserBundledRefresherLiveData(u)
|
||||
|
||||
// UI Observers line up here.
|
||||
@@ -473,7 +478,6 @@ class UserLiveSet(
|
||||
val relayInfo = innerRelayInfo.map { it }
|
||||
val zaps = innerZaps.map { it }
|
||||
val bookmarks = innerBookmarks.map { it }
|
||||
val gallery = innerGallery.map { it }
|
||||
val statuses = innerStatuses.map { it }
|
||||
|
||||
val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged()
|
||||
@@ -492,7 +496,6 @@ class UserLiveSet(
|
||||
relayInfo.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
bookmarks.hasObservers() ||
|
||||
gallery.hasObservers() ||
|
||||
statuses.hasObservers() ||
|
||||
profilePictureChanges.hasObservers() ||
|
||||
nip05Changes.hasObservers() ||
|
||||
@@ -508,7 +511,6 @@ class UserLiveSet(
|
||||
innerRelayInfo.destroy()
|
||||
innerZaps.destroy()
|
||||
innerBookmarks.destroy()
|
||||
innerGallery.destroy()
|
||||
innerStatuses.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,9 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
|
||||
object CachedRichTextParser {
|
||||
val richTextCache = LruCache<Int, RichTextViewerState>(50)
|
||||
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
|
||||
|
||||
// fun getCached(content: String): RichTextViewerState? = richTextCache[content]
|
||||
|
||||
fun hashCodeCache(
|
||||
private fun hashCodeCache(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String?,
|
||||
|
||||
@@ -32,6 +32,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.cbor.ByteString
|
||||
@@ -61,11 +63,11 @@ class Proof(
|
||||
)
|
||||
|
||||
object CachedCashuProcessor {
|
||||
val cashuCache = LruCache<String, GenericLoadable<List<CashuToken>>>(20)
|
||||
val cashuCache = LruCache<String, GenericLoadable<ImmutableList<CashuToken>>>(20)
|
||||
|
||||
fun cached(token: String): GenericLoadable<List<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
|
||||
fun cached(token: String): GenericLoadable<ImmutableList<CashuToken>> = cashuCache[token] ?: GenericLoadable.Loading()
|
||||
|
||||
fun parse(token: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parse(token: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
if (cashuCache[token] !is GenericLoadable.Loaded) {
|
||||
checkNotInMainThread()
|
||||
val newCachuData = CashuProcessor().parse(token)
|
||||
@@ -91,7 +93,7 @@ class CashuProcessor {
|
||||
val proofs: List<Proof>,
|
||||
)
|
||||
|
||||
fun parse(cashuToken: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parse(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (cashuToken.startsWith("cashuA")) {
|
||||
@@ -105,7 +107,7 @@ class CashuProcessor {
|
||||
return GenericLoadable.Error("Could not parse this cashu token")
|
||||
}
|
||||
|
||||
fun parseCashuA(cashuToken: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parseCashuA(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
@@ -129,10 +131,10 @@ class CashuProcessor {
|
||||
CashuToken(cashuToken, mint, totalAmount, proofs)
|
||||
}
|
||||
|
||||
return GenericLoadable.Loaded(converted)
|
||||
return GenericLoadable.Loaded(converted.toImmutableList())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
return GenericLoadable.Error<List<CashuToken>>("Could not parse this cashu token")
|
||||
return GenericLoadable.Error("Could not parse this cashu token")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +174,7 @@ class CashuProcessor {
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
fun parseCashuB(cashuToken: String): GenericLoadable<List<CashuToken>> {
|
||||
fun parseCashuB(cashuToken: String): GenericLoadable<ImmutableList<CashuToken>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
@@ -205,7 +207,7 @@ class CashuProcessor {
|
||||
CashuToken(cashuToken, mint, totalAmount, proofs)
|
||||
}
|
||||
|
||||
return GenericLoadable.Loaded(converted)
|
||||
return GenericLoadable.Loaded(converted.toImmutableList())
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@@ -49,7 +49,7 @@ class FileHeader(
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
@@ -57,12 +57,12 @@ class FileHeader(
|
||||
if (imageData != null) {
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
} else {
|
||||
onError(null)
|
||||
onError("Unable to download image from $fileUrl")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError(e.message)
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ class FileHeader(
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val hash = CryptoUtils.sha256(data).toHexKey()
|
||||
@@ -178,7 +178,7 @@ class FileHeader(
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError(e.message)
|
||||
onError(e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -183,7 +183,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
var since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?.toMutableMap()
|
||||
|
||||
@@ -229,7 +229,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
val since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultNotificationFollowList.value)
|
||||
?.get(account.settings.defaultNotificationFollowList.value)
|
||||
?.relayList
|
||||
?: account.connectToRelays.value.associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
?: account.convertLocalRelays().associate { it.url to EOSETime(TimeUtils.oneWeekAgo()) }
|
||||
@@ -278,7 +278,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
if (hasLoadedTheBasics[account.userProfile()] != null) {
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultNotificationFollowList.value,
|
||||
account.settings.defaultNotificationFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
@@ -299,99 +299,121 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
override fun consume(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
if (LocalCache.justVerify(event)) {
|
||||
consumeAlreadyVerified(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeAlreadyVerified(
|
||||
event: Event,
|
||||
relay: Relay,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (LocalCache.justVerify(event)) {
|
||||
when (event) {
|
||||
is PrivateOutboxRelayListEvent -> {
|
||||
when (event) {
|
||||
is PrivateOutboxRelayListEvent -> {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
|
||||
event.privateTags(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DraftEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
if (!event.isDeleted()) {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent == null || event.createdAt > noteEvent.createdAt()) {
|
||||
event.privateTags(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DraftEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
|
||||
if (!event.isDeleted()) {
|
||||
val note = LocalCache.getAddressableNoteIfExists(event.addressTag())
|
||||
val noteEvent = note?.event
|
||||
if (noteEvent != null) {
|
||||
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
} else {
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? GiftWrapEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
noteEvent.cachedGift(account.signer) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
this.consume(it, relay)
|
||||
}
|
||||
if (event.createdAt > noteEvent.createdAt() || relay.brief !in note.relays) {
|
||||
LocalCache.consume(event, relay)
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGift(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SealedGossipEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? SealedGossipEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
// adds the relay to seal and inner chat
|
||||
noteEvent.cachedGossip(account.signer) {
|
||||
LocalCache.consume(noteEvent, relay)
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.cachedGossip(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note?.event == null) {
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
}
|
||||
}
|
||||
// decrypts
|
||||
event.cachedDraft(account.signer) {}
|
||||
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is GiftWrapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? GiftWrapEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
|
||||
noteEvent.innerEventId?.let {
|
||||
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
|
||||
this.consumeAlreadyVerified(it, relay)
|
||||
}
|
||||
} ?: run {
|
||||
event.unwrap(account.signer) {
|
||||
this.consume(it, relay)
|
||||
noteEvent.innerEventId = it.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.unwrap(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
this.consume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SealedGossipEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
val noteEvent = note?.event as? SealedGossipEvent
|
||||
if (noteEvent != null) {
|
||||
if (relay.brief !in note.relays) {
|
||||
LocalCache.justConsume(noteEvent, relay)
|
||||
|
||||
noteEvent.innerEventId?.let {
|
||||
(LocalCache.getNoteIfExists(it)?.event as? Event)?.let {
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
} ?: run {
|
||||
event.unseal(account.signer) {
|
||||
LocalCache.justConsume(it, relay)
|
||||
noteEvent.innerEventId = it.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// new event
|
||||
event.unseal(account.signer) {
|
||||
LocalCache.justConsume(event, relay)
|
||||
LocalCache.justConsume(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// Avoid decrypting over and over again if the event already exist.
|
||||
val note = LocalCache.getNoteIfExists(event.id)
|
||||
if (note?.event == null) {
|
||||
event.zapRequest?.let {
|
||||
if (it.isPrivateZap()) {
|
||||
it.decryptPrivateZap(account.signer) {}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
LocalCache.justConsume(event, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,16 +431,30 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
}
|
||||
|
||||
private fun markInnerAsSeenOnRelay(
|
||||
noteEvent: EventInterface,
|
||||
newNoteEvent: EventInterface,
|
||||
relay: Relay,
|
||||
) {
|
||||
LocalCache.getNoteIfExists(noteEvent.id())?.addRelay(relay)
|
||||
markInnerAsSeenOnRelay(newNoteEvent.id(), relay)
|
||||
}
|
||||
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
noteEvent.cachedGift(account.signer) { gift -> markInnerAsSeenOnRelay(gift, relay) }
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
noteEvent.cachedGossip(account.signer) { rumor ->
|
||||
markInnerAsSeenOnRelay(rumor, relay)
|
||||
private fun markInnerAsSeenOnRelay(
|
||||
eventId: HexKey,
|
||||
relay: Relay,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(eventId)
|
||||
|
||||
if (note != null) {
|
||||
note.addRelay(relay)
|
||||
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is GiftWrapEvent) {
|
||||
noteEvent.innerEventId?.let {
|
||||
markInnerAsSeenOnRelay(it, relay)
|
||||
}
|
||||
} else if (noteEvent is SealedGossipEvent) {
|
||||
noteEvent.innerEventId?.let {
|
||||
markInnerAsSeenOnRelay(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
@@ -111,7 +111,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -133,7 +133,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -153,7 +153,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
@@ -179,7 +179,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
@@ -194,7 +194,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -217,7 +217,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
),
|
||||
@@ -232,7 +232,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -255,7 +255,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -285,7 +285,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -315,7 +315,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -346,7 +346,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -377,7 +377,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -407,7 +407,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -437,7 +437,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultDiscoveryFollowList.value)
|
||||
?.get(account.settings.defaultDiscoveryFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -447,7 +447,7 @@ object NostrDiscoveryDataSource : AmethystNostrDataSource("DiscoveryFeed") {
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.settings.defaultDiscoveryFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
@@ -46,7 +46,6 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
@@ -55,25 +54,35 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
val latestEOSEs = EOSEAccount()
|
||||
|
||||
var job: Job? = null
|
||||
var job2: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job?.cancel()
|
||||
job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// creates cache on main
|
||||
withContext(Dispatchers.Main) { account.userProfile().live() }
|
||||
account.liveHomeFollowLists.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
job2?.cancel()
|
||||
job2 =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
account.liveHomeListAuthorsPerRelay.collect {
|
||||
if (this@NostrHomeDataSource::account.isInitialized) {
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
job?.cancel()
|
||||
job2?.cancel()
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
@@ -105,7 +114,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -128,7 +137,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -170,7 +179,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -209,7 +218,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -244,7 +253,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultHomeFollowList.value)
|
||||
?.get(account.settings.defaultHomeFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -254,7 +263,7 @@ object NostrHomeDataSource : AmethystNostrDataSource("HomeFeed") {
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultHomeFollowList.value,
|
||||
account.settings.defaultHomeFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.ReportEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
|
||||
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
|
||||
private var nextEventsToWatch = setOf<Note>()
|
||||
@@ -179,6 +180,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
|
||||
DeletionEvent.KIND,
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
NIP90StatusEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to it.map { it.idHex }),
|
||||
since = findMinimumEOSEs(it),
|
||||
|
||||
@@ -42,6 +42,8 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
@@ -89,6 +91,23 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter2() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter =
|
||||
SincePerRelayFilter(
|
||||
kinds =
|
||||
listOf(
|
||||
TorrentEvent.KIND,
|
||||
TorrentCommentEvent.KIND,
|
||||
),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 20,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserReceivedZapsFilter() =
|
||||
user?.let {
|
||||
TypedFilter(
|
||||
@@ -188,6 +207,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
|
||||
listOfNotNull(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createUserPostsFilter2(),
|
||||
createProfileGalleryFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter(),
|
||||
|
||||
@@ -78,7 +78,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultStoriesFollowList.value)
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -109,7 +109,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultStoriesFollowList.value)
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -140,7 +140,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
since =
|
||||
latestEOSEs.users[account.userProfile()]
|
||||
?.followList
|
||||
?.get(account.defaultStoriesFollowList.value)
|
||||
?.get(account.settings.defaultStoriesFollowList.value)
|
||||
?.relayList,
|
||||
),
|
||||
)
|
||||
@@ -150,7 +150,7 @@ object NostrVideoDataSource : AmethystNostrDataSource("VideoFeed") {
|
||||
requestNewChannel { time, relayUrl ->
|
||||
latestEOSEs.addOrUpdate(
|
||||
account.userProfile(),
|
||||
account.defaultStoriesFollowList.value,
|
||||
account.settings.defaultStoriesFollowList.value,
|
||||
relayUrl,
|
||||
time,
|
||||
)
|
||||
|
||||
@@ -81,13 +81,15 @@ class ZapPaymentHandler(
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
if (showErrorIfNoLnAddress) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
@@ -97,13 +99,15 @@ class ZapPaymentHandler(
|
||||
val lud16 = note.author?.info?.lnAddress()
|
||||
|
||||
if (lud16.isNullOrBlank()) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
if (showErrorIfNoLnAddress) {
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
),
|
||||
)
|
||||
}
|
||||
return@withContext
|
||||
}
|
||||
|
||||
|
||||
@@ -26,30 +26,35 @@ import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.ChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.DraftEvent
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
import com.vitorpamplona.quartz.events.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import com.vitorpamplona.quartz.events.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class EventNotificationConsumer(
|
||||
private val applicationContext: Context,
|
||||
) {
|
||||
companion object {
|
||||
const val TAG = "EventNotificationConsumer"
|
||||
}
|
||||
|
||||
suspend fun consume(event: GiftWrapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Notification Arrived")
|
||||
Log.d(TAG, "New Notification Arrived")
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
if (!notificationManager().areNotificationsEnabled()) return
|
||||
|
||||
@@ -57,14 +62,15 @@ class EventNotificationConsumer(
|
||||
// Test with all logged in accounts
|
||||
var matchAccount = false
|
||||
LocalPreferences.allSavedAccounts().forEach {
|
||||
if (!matchAccount && it.hasPrivKey || it.loggedInWithExternalSigner) {
|
||||
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
|
||||
LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)?.let { acc ->
|
||||
Log.d("EventNotificationConsumer", "New Notification Testing if for ${it.npub}")
|
||||
Log.d(TAG, "New Notification Testing if for ${it.npub}")
|
||||
try {
|
||||
consumeIfMatchesAccount(event, acc)
|
||||
matchAccount = true
|
||||
} catch (e: Exception) {
|
||||
// Not for this account.
|
||||
if (e is CancellationException) throw e
|
||||
Log.d(TAG, "Message was not for user ${it.npub}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,28 +79,29 @@ class EventNotificationConsumer(
|
||||
|
||||
private suspend fun consumeIfMatchesAccount(
|
||||
pushWrappedEvent: GiftWrapEvent,
|
||||
account: Account,
|
||||
account: AccountSettings,
|
||||
) {
|
||||
pushWrappedEvent.cachedGift(account.signer) { notificationEvent ->
|
||||
// TODO: Modify the external launcher to launch as different users.
|
||||
// Right now it only registers if Amber has already approved this signature
|
||||
val signer = account.createSigner()
|
||||
|
||||
pushWrappedEvent.unwrapThrowing(signer) { notificationEvent ->
|
||||
val consumed = LocalCache.hasConsumed(notificationEvent)
|
||||
val verified = LocalCache.justVerify(notificationEvent)
|
||||
Log.d("EventNotificationConsumer", "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${account.userProfile().toBestDisplayName()} consumed= $consumed && verified= $verified")
|
||||
Log.d(TAG, "New Notification ${notificationEvent.kind} ${notificationEvent.id} Arrived for ${signer.pubKey} consumed= $consumed && verified= $verified")
|
||||
if (!consumed && verified) {
|
||||
Log.d("EventNotificationConsumer", "New Notification was verified")
|
||||
unwrapAndConsume(notificationEvent, account) { innerEvent ->
|
||||
|
||||
Log.d("EventNotificationConsumer", "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
|
||||
if (!consumed) {
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Nip-04 DM to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
Log.d("EventNotificationConsumer", "New Zap to Notify")
|
||||
notify(innerEvent, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d("EventNotificationConsumer", "New ChatMessage to Notify")
|
||||
notify(innerEvent, account)
|
||||
}
|
||||
Log.d(TAG, "New Notification was verified")
|
||||
unwrapAndConsume(notificationEvent, signer) { innerEvent ->
|
||||
Log.d(TAG, "Unwrapped consume $consumed ${innerEvent.javaClass.simpleName}")
|
||||
if (innerEvent is PrivateDmEvent) {
|
||||
Log.d(TAG, "New Nip-04 DM to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
} else if (innerEvent is LnZapEvent) {
|
||||
Log.d(TAG, "New Zap to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
} else if (innerEvent is ChatMessageEvent) {
|
||||
Log.d(TAG, "New ChatMessage to Notify")
|
||||
notify(innerEvent, signer, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,7 +110,7 @@ class EventNotificationConsumer(
|
||||
|
||||
private fun unwrapAndConsume(
|
||||
event: Event,
|
||||
account: Account,
|
||||
signer: NostrSigner,
|
||||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
if (!LocalCache.justVerify(event)) return
|
||||
@@ -111,15 +118,19 @@ class EventNotificationConsumer(
|
||||
|
||||
when (event) {
|
||||
is GiftWrapEvent -> {
|
||||
event.cachedGift(account.signer) { unwrapAndConsume(it, account, onReady) }
|
||||
event.unwrap(signer) {
|
||||
unwrapAndConsume(it, signer, onReady)
|
||||
LocalCache.justConsume(event, null)
|
||||
}
|
||||
}
|
||||
is SealedGossipEvent -> {
|
||||
event.cachedGossip(account.signer) {
|
||||
event.unseal(signer) {
|
||||
if (!LocalCache.hasConsumed(it)) {
|
||||
// this is not verifiable
|
||||
LocalCache.justConsume(it, null)
|
||||
onReady(it)
|
||||
}
|
||||
LocalCache.justConsume(event, null)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -131,25 +142,26 @@ class EventNotificationConsumer(
|
||||
|
||||
private fun notify(
|
||||
event: ChatMessageEvent,
|
||||
acc: Account,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
if (
|
||||
event.createdAt > TimeUtils.fifteenMinutesAgo() &&
|
||||
// old event being re-broadcasted
|
||||
event.pubKey != acc.userProfile().pubkeyHex
|
||||
event.pubKey != signer.pubKey
|
||||
) { // from the user
|
||||
|
||||
Log.d(TAG, "Notifying")
|
||||
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
|
||||
val chatNote = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
|
||||
val chatRoom = event.chatroomKey(signer.pubKey)
|
||||
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
|
||||
|
||||
val isKnownRoom =
|
||||
(
|
||||
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(chatRoom)
|
||||
) &&
|
||||
!acc.isAllHidden(chatRoom.users)
|
||||
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
myUser.hasSentMessagesTo(chatRoom)
|
||||
)
|
||||
|
||||
if (isKnownRoom) {
|
||||
val content = chatNote.event?.content() ?: ""
|
||||
@@ -172,32 +184,27 @@ class EventNotificationConsumer(
|
||||
|
||||
private fun notify(
|
||||
event: PrivateDmEvent,
|
||||
acc: Account,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
val myUser = LocalCache.getUserIfExists(signer.pubKey) ?: return
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
if (acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.followingKeySet()
|
||||
if (signer.pubKey == event.verifiedRecipientPubKey()) {
|
||||
val followingKeySet = acc.backupContactList?.unverifiedFollowKeySet()?.toSet() ?: return
|
||||
|
||||
val knownChatrooms =
|
||||
acc
|
||||
.userProfile()
|
||||
.privateChatrooms
|
||||
.keys
|
||||
.filter {
|
||||
(
|
||||
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
|
||||
acc.userProfile().hasSentMessagesTo(it)
|
||||
) &&
|
||||
!acc.isAllHidden(it.users)
|
||||
}.toSet()
|
||||
val chatRoom = event.chatroomKey(signer.pubKey)
|
||||
|
||||
note.author?.let {
|
||||
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
|
||||
acc.decryptContent(note) { content ->
|
||||
val isKnownRoom =
|
||||
myUser.privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
|
||||
myUser.hasSentMessagesTo(chatRoom)
|
||||
|
||||
if (isKnownRoom) {
|
||||
note.author?.let {
|
||||
decryptContent(note, signer) { content ->
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
@@ -209,45 +216,80 @@ class EventNotificationConsumer(
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapContentAuthor(
|
||||
note: Note,
|
||||
signer: NostrSigner,
|
||||
onReady: (Event) -> Unit,
|
||||
) {
|
||||
val event = note.event
|
||||
if (event is LnZapRequestEvent) {
|
||||
if (event.isPrivateZap()) {
|
||||
event.decryptPrivateZap(signer) { onReady(it) }
|
||||
} else {
|
||||
onReady(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptContent(
|
||||
note: Note,
|
||||
signer: NostrSigner,
|
||||
onReady: (String) -> Unit,
|
||||
) {
|
||||
val event = note.event
|
||||
if (event is PrivateDmEvent) {
|
||||
event.plainContent(signer, onReady)
|
||||
} else if (event is LnZapRequestEvent) {
|
||||
decryptZapContentAuthor(note, signer) { onReady(it.content) }
|
||||
} else if (event is DraftEvent) {
|
||||
event.cachedDraft(signer) {
|
||||
onReady(it.content)
|
||||
}
|
||||
} else {
|
||||
event?.content()?.let { onReady(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
event: LnZapEvent,
|
||||
acc: Account,
|
||||
signer: NostrSigner,
|
||||
acc: AccountSettings,
|
||||
) {
|
||||
Log.d("EventNotificationConsumer", "Notify Start ${event.toNostrUri()}")
|
||||
Log.d(TAG, "Notify Start ${event.toNostrUri()}")
|
||||
val noteZapEvent = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Not Notified Yet")
|
||||
Log.d(TAG, "Notify Not Notified Yet")
|
||||
|
||||
// old event being re-broadcast
|
||||
if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Not an old event")
|
||||
Log.d(TAG, "Notify Not an old event")
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
val noteZapped =
|
||||
event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify ZapRequest $noteZapRequest zapped $noteZapped")
|
||||
Log.d(TAG, "Notify ZapRequest $noteZapRequest zapped $noteZapped")
|
||||
|
||||
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Amount Bigger than 10")
|
||||
Log.d(TAG, "Notify Amount Bigger than 10")
|
||||
|
||||
if (event.isTaggedUser(acc.userProfile().pubkeyHex)) {
|
||||
if (event.isTaggedUser(signer.pubKey)) {
|
||||
val amount = showAmount(event.amount)
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify Amount $amount")
|
||||
Log.d(TAG, "Notify Amount $amount")
|
||||
|
||||
(noteZapRequest.event as? LnZapRequestEvent)?.let { event ->
|
||||
acc.decryptZapContentAuthor(noteZapRequest) {
|
||||
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Zap ${event.id}")
|
||||
decryptZapContentAuthor(noteZapRequest, signer) {
|
||||
Log.d(TAG, "Notify Decrypted if Private Zap ${event.id}")
|
||||
|
||||
val author = LocalCache.getOrCreateUser(it.pubKey)
|
||||
val senderInfo = Pair(author, it.content.ifBlank { null })
|
||||
|
||||
if (noteZapped.event?.content() != null) {
|
||||
acc.decryptContent(noteZapped) {
|
||||
Log.d("EventNotificationConsumer", "Notify Decrypted if Private Note")
|
||||
decryptContent(noteZapped, signer) {
|
||||
Log.d(TAG, "Notify Decrypted if Private Note")
|
||||
|
||||
val zappedContent = it.split("\n").get(0)
|
||||
|
||||
@@ -270,10 +312,10 @@ class EventNotificationConsumer(
|
||||
zappedContent,
|
||||
)
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
@@ -288,7 +330,7 @@ class EventNotificationConsumer(
|
||||
}
|
||||
} else {
|
||||
// doesn't have a base note to refer to.
|
||||
Log.d("EventNotificationConsumer", "Notify Zapped note not available")
|
||||
Log.d(TAG, "Notify Zapped note not available")
|
||||
|
||||
val user = senderInfo.first.toBestDisplayName()
|
||||
var title = stringRes(applicationContext, R.string.app_notification_zaps_channel_message, amount)
|
||||
@@ -304,7 +346,7 @@ class EventNotificationConsumer(
|
||||
val userPicture = senderInfo.first.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
|
||||
Log.d("EventNotificationConsumer", "Notify ${event.id} $content $title $noteUri")
|
||||
Log.d(TAG, "Notify ${event.id} $content $title $noteUri")
|
||||
|
||||
notificationManager()
|
||||
.sendZapNotification(
|
||||
|
||||
@@ -24,11 +24,8 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.RelayAuthEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -55,7 +52,7 @@ class RegisterAccounts(
|
||||
|
||||
private suspend fun signAllAuths(
|
||||
notificationToken: String,
|
||||
remainingTos: List<Pair<Account, List<String>>>,
|
||||
remainingTos: List<Pair<AccountSettings, List<String>>>,
|
||||
output: MutableList<RelayAuthEvent>,
|
||||
onReady: (List<RelayAuthEvent>) -> Unit,
|
||||
) {
|
||||
@@ -71,7 +68,10 @@ class RegisterAccounts(
|
||||
val result =
|
||||
withTimeoutOrNull(10000) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
accountRelayPair.first.createAuthEvent(accountRelayPair.second, notificationToken) { result ->
|
||||
val signer = accountRelayPair.first.createSigner()
|
||||
// TODO: Modify the external launcher to launch as different users.
|
||||
// Right now it only registers if Amber has already approved this signature
|
||||
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
}
|
||||
@@ -104,32 +104,18 @@ class RegisterAccounts(
|
||||
Log.d(tag, "Register Account ${it.npub}")
|
||||
|
||||
val acc = LocalPreferences.loadCurrentAccountFromEncryptedStorage(it.npub)
|
||||
|
||||
if (acc != null && acc.isWriteable()) {
|
||||
val nip65Read =
|
||||
(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(
|
||||
AdvertisedRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
|
||||
)?.event as? AdvertisedRelayListEvent
|
||||
)?.readRelays() ?: acc.backupNIP65RelayList?.readRelays() ?: emptyList<String>()
|
||||
val nip65Read = acc.backupNIP65RelayList?.readRelays() ?: emptyList()
|
||||
|
||||
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP65 Reads ${nip65Read.joinToString(", ")}")
|
||||
Log.d(tag, "Register Account ${it.npub} NIP65 Reads ${nip65Read.joinToString(", ")}")
|
||||
|
||||
val nip17Read =
|
||||
(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(
|
||||
ChatMessageRelayListEvent.createAddressTag(acc.userProfile().pubkeyHex),
|
||||
)?.event as? ChatMessageRelayListEvent
|
||||
)?.relays() ?: acc.backupDMRelayList?.relays() ?: emptyList<String>()
|
||||
val nip17Read = acc.backupDMRelayList?.relays() ?: emptyList<String>()
|
||||
|
||||
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} NIP17 Reads ${nip17Read.joinToString(", ")}")
|
||||
Log.d(tag, "Register Account ${it.npub} NIP17 Reads ${nip17Read.joinToString(", ")}")
|
||||
|
||||
val kind3Relays = (acc.userProfile().latestContactList?.relays() ?: acc.backupContactList?.relays())
|
||||
val readKind3Relays = kind3Relays?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
|
||||
val readKind3Relays = acc.backupContactList?.relays()?.mapNotNull { if (it.value.read) it.key else null } ?: emptyList<String>()
|
||||
|
||||
Log.d(tag, "Register Account ${acc.userProfile().toBestDisplayName()} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
|
||||
Log.d(tag, "Register Account ${it.npub} Kind3 Reads ${readKind3Relays.joinToString(", ")}")
|
||||
|
||||
val relays = (nip65Read + nip17Read + readKind3Relays)
|
||||
|
||||
@@ -174,6 +160,7 @@ class RegisterAccounts(
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
|
||||
Log.i(tag, "Server registration $isSucess")
|
||||
} catch (e: java.lang.Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e(tag, "Unable to register with push server", e)
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.adaptive.calculateDisplayFeatures
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
@@ -42,10 +41,9 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
|
||||
|
||||
LaunchedEffect(key1 = sharedPreferencesViewModel) {
|
||||
sharedPreferencesViewModel.init()
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = displayFeatures, key2 = windowSizeClass) {
|
||||
LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) {
|
||||
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
|
||||
}
|
||||
|
||||
@@ -57,13 +55,9 @@ fun prepareSharedViewModel(act: MainActivity): SharedPreferencesViewModel {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppScreen(
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel,
|
||||
serviceManager: ServiceManager,
|
||||
) {
|
||||
fun AppScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
AmethystTheme(sharedPreferencesViewModel) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel()
|
||||
accountStateViewModel.serviceManager = serviceManager
|
||||
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
accountStateViewModel.tryLoginExistingAccountAsync()
|
||||
|
||||
@@ -78,7 +78,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContent {
|
||||
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
|
||||
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = Amethyst.instance.serviceManager)
|
||||
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,10 +109,8 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.replyModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -135,12 +133,6 @@ fun EditPostView(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(edit, versionLookingAt, accountViewModel)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
@@ -266,11 +258,11 @@ fun EditPostView(
|
||||
Row(Modifier.heightIn(max = 200.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = 1,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -337,15 +329,15 @@ fun EditPostView(
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context)
|
||||
if (!server.isNip95) {
|
||||
accountViewModel.account.changeDefaultFileServer(server.server)
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server.server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -363,7 +355,7 @@ fun EditPostView(
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
user.pubkeyHex,
|
||||
accountViewModel.account,
|
||||
accountViewModel,
|
||||
stringRes(id = R.string.lightning_invoice),
|
||||
stringRes(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -42,14 +43,13 @@ import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
@@ -68,8 +68,6 @@ open class EditPostViewModel : ViewModel() {
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
@@ -152,6 +150,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
sensitiveContent: Boolean,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
@@ -169,7 +168,16 @@ open class EditPostViewModel : ViewModel() {
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.isNip95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
contentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -193,6 +201,10 @@ open class EditPostViewModel : ViewModel() {
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -202,16 +214,14 @@ open class EditPostViewModel : ViewModel() {
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit(it) }
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -306,6 +316,8 @@ open class EditPostViewModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
@@ -334,7 +346,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -353,7 +365,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -363,10 +375,12 @@ open class EditPostViewModel : ViewModel() {
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Media is too big for NIP-95")
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
@@ -393,7 +407,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,11 +33,41 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
@Composable
|
||||
fun InformationDialog(
|
||||
title: String,
|
||||
textContent: String?,
|
||||
throwable: Throwable,
|
||||
buttonColors: ButtonColors = ButtonDefaults.buttonColors(),
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val stack = stringRes(id = R.string.stack)
|
||||
val str =
|
||||
remember(throwable) {
|
||||
val writer = StringWriter()
|
||||
textContent?.let {
|
||||
writer.append(it)
|
||||
writer.append("\n\n")
|
||||
}
|
||||
writer.append(stack)
|
||||
writer.append("\n")
|
||||
|
||||
throwable.printStackTrace(PrintWriter(writer))
|
||||
|
||||
writer.toString()
|
||||
}
|
||||
|
||||
InformationDialog(title = title, textContent = str, buttonColors, onDismiss)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InformationDialog(
|
||||
@@ -49,7 +79,11 @@ fun InformationDialog(
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = { SelectionContainer { Text(textContent) } },
|
||||
text = {
|
||||
SelectionContainer {
|
||||
Text(textContent)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
|
||||
@@ -50,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -76,12 +75,12 @@ 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.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.screen.loggedIn.chatrooms.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
@@ -387,8 +386,6 @@ private fun RenderChannel(
|
||||
loadRobohash: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val hasNewMessages = remember { mutableStateOf(false) }
|
||||
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
@@ -400,7 +397,7 @@ private fun RenderChannel(
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.summary(),
|
||||
hasNewMessages,
|
||||
hasNewMessages = false,
|
||||
onClick = onClick,
|
||||
loadProfilePicture = loadProfilePicture,
|
||||
loadRobohash = loadRobohash,
|
||||
|
||||
@@ -64,24 +64,22 @@ open class NewMediaModel : ViewModel() {
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
var onError: (String) -> Unit = {}
|
||||
|
||||
open fun load(
|
||||
account: Account,
|
||||
uri: Uri,
|
||||
contentType: String?,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
this.mediaType = contentType
|
||||
this.selectedServer = ServerOption(defaultServer(), false)
|
||||
this.onError = onError
|
||||
}
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onError: (String) -> Unit = {},
|
||||
) {
|
||||
isUploadingImage = true
|
||||
|
||||
@@ -110,6 +108,7 @@ open class NewMediaModel : ViewModel() {
|
||||
alt,
|
||||
sensitiveContent,
|
||||
relayList = relayList,
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
}
|
||||
@@ -148,6 +147,7 @@ open class NewMediaModel : ViewModel() {
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList,
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -189,6 +189,7 @@ open class NewMediaModel : ViewModel() {
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
@@ -282,11 +283,12 @@ open class NewMediaModel : ViewModel() {
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
onError("Media is too big for NIP-95")
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
@@ -331,7 +333,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
|
||||
fun defaultServer() = account?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
||||
fun defaultServer() = account?.settings?.defaultFileServer ?: Nip96MediaServers.DEFAULT[0]
|
||||
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
|
||||
@@ -94,9 +94,7 @@ fun NewMediaView(
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType) {
|
||||
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
}
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
}
|
||||
|
||||
var showRelaysDialog by remember { mutableStateOf(false) }
|
||||
@@ -161,10 +159,12 @@ fun NewMediaView(
|
||||
PostButton(
|
||||
onPost = {
|
||||
onClose()
|
||||
postViewModel.upload(context, relayList)
|
||||
postViewModel.upload(context, relayList) {
|
||||
accountViewModel.toast(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
}
|
||||
postViewModel.selectedServer?.let {
|
||||
if (!it.isNip95) {
|
||||
account.changeDefaultFileServer(it.server)
|
||||
account.settings.changeDefaultFileServer(it.server)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -290,7 +290,7 @@ fun ImageVideoPost(
|
||||
label = stringRes(id = R.string.file_server),
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
|
||||
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
|
||||
?.server
|
||||
?.name
|
||||
?: fileServers[0].server.name,
|
||||
|
||||
@@ -145,10 +145,10 @@ import com.vitorpamplona.amethyst.ui.note.RegularPostIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ShowUserSuggestionList
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
@@ -180,7 +180,6 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.lang.Math.round
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
|
||||
@@ -220,10 +219,6 @@ fun NewPostView(
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +351,11 @@ fun NewPostView(
|
||||
Row(Modifier.heightIn(max = 200.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
makeItShort = true,
|
||||
unPackReply = false,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = 1,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 1,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -496,15 +491,15 @@ fun NewPostView(
|
||||
) {
|
||||
ImageVideoDescription(
|
||||
url,
|
||||
accountViewModel.account.defaultFileServer,
|
||||
accountViewModel.account.settings.defaultFileServer,
|
||||
onAdd = { alt, server, sensitiveContent ->
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, context)
|
||||
postViewModel.upload(url, alt, sensitiveContent, false, server, accountViewModel::toast, context)
|
||||
if (!server.isNip95) {
|
||||
accountViewModel.account.changeDefaultFileServer(server.server)
|
||||
accountViewModel.account.settings.changeDefaultFileServer(server.server)
|
||||
}
|
||||
},
|
||||
onCancel = { postViewModel.contentToAddUrl = null },
|
||||
onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } },
|
||||
onError = { scope.launch { Toast.makeText(context, context.resources.getText(it), Toast.LENGTH_SHORT).show() } },
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -520,7 +515,7 @@ fun NewPostView(
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
accountViewModel.account.userProfile().pubkeyHex,
|
||||
accountViewModel.account,
|
||||
accountViewModel,
|
||||
stringRes(id = R.string.lightning_invoice),
|
||||
stringRes(id = R.string.lightning_create_and_add_invoice),
|
||||
onSuccess = {
|
||||
@@ -1616,7 +1611,7 @@ fun ImageVideoDescription(
|
||||
defaultServer: Nip96MediaServers.ServerName,
|
||||
onAdd: (String, ServerOption, Boolean) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onError: (Int) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
@@ -1753,7 +1748,7 @@ fun ImageVideoDescription(
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError("Unable to load thumbnail")
|
||||
onError(R.string.unable_to_load_thumbnail)
|
||||
Log.w("NewPostView", "Couldn't create thumbnail, but the video can be uploaded", e)
|
||||
}
|
||||
}
|
||||
@@ -1783,7 +1778,7 @@ fun ImageVideoDescription(
|
||||
label = stringRes(id = R.string.file_server),
|
||||
placeholder =
|
||||
fileServers
|
||||
.firstOrNull { it.server == accountViewModel.account.defaultFileServer }
|
||||
.firstOrNull { it.server == accountViewModel.account.settings.defaultFileServer }
|
||||
?.server
|
||||
?.name
|
||||
?: fileServers[0].server.name,
|
||||
|
||||
@@ -35,6 +35,7 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fonfon.kgeohash.toGeoHash
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.components.Split
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
@@ -67,15 +69,15 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.events.Price
|
||||
import com.vitorpamplona.quartz.events.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||
import com.vitorpamplona.quartz.events.findURLs
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -109,8 +111,6 @@ open class NewPostViewModel : ViewModel() {
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
var isUploadingImage by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
@@ -683,6 +683,74 @@ open class NewPostViewModel : ViewModel() {
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else if (originalNote?.event is TorrentCommentEvent) {
|
||||
val originalNoteEvent = originalNote?.event as TorrentCommentEvent
|
||||
// adds markers
|
||||
val rootId =
|
||||
originalNoteEvent.torrent() // 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
|
||||
|
||||
if (rootId != null) {
|
||||
// There must be a torrent ID
|
||||
val replyId = originalNote?.idHex
|
||||
|
||||
val replyToSet =
|
||||
if (forkedFromNote != null) {
|
||||
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
|
||||
} else {
|
||||
tagger.eTags
|
||||
}
|
||||
|
||||
account?.sendTorrentComment(
|
||||
message = tagger.message,
|
||||
replyTo = replyToSet,
|
||||
mentions = tagger.pTags,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = replyId,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
}
|
||||
} else if (originalNote?.event is TorrentEvent) {
|
||||
val originalNoteEvent = originalNote?.event as TorrentEvent
|
||||
// adds markers
|
||||
val rootId = originalNoteEvent.id
|
||||
|
||||
val replyToSet =
|
||||
if (forkedFromNote != null) {
|
||||
(listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null }
|
||||
} else {
|
||||
tagger.eTags
|
||||
}
|
||||
|
||||
account?.sendTorrentComment(
|
||||
message = tagger.message,
|
||||
replyTo = replyToSet,
|
||||
mentions = tagger.pTags,
|
||||
zapReceiver = zapReceiver,
|
||||
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
|
||||
zapRaiserAmount = localZapRaiserAmount,
|
||||
replyingTo = null,
|
||||
root = rootId,
|
||||
directMentions = tagger.directMentions,
|
||||
forkedFrom = forkedFromNote?.event as? Event,
|
||||
relayList = relayList,
|
||||
geohash = geoHash,
|
||||
nip94attachments = usedAttachments,
|
||||
draftTag = localDraft,
|
||||
)
|
||||
} else {
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(
|
||||
@@ -768,6 +836,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
sensitiveContent: Boolean,
|
||||
isPrivate: Boolean = false,
|
||||
server: ServerOption,
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
isUploadingImage = true
|
||||
@@ -785,7 +854,16 @@ open class NewPostViewModel : ViewModel() {
|
||||
onReady = { fileUri, contentType, size ->
|
||||
if (server.isNip95) {
|
||||
contentResolver.openInputStream(fileUri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent)
|
||||
createNIP95Record(
|
||||
it.readBytes(),
|
||||
contentType,
|
||||
alt,
|
||||
sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@@ -809,6 +887,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
context = context,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
@@ -818,16 +900,14 @@ open class NewPostViewModel : ViewModel() {
|
||||
e,
|
||||
)
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload: ${e.message}")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit(it) }
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1064,6 +1144,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1)
|
||||
@@ -1092,7 +1174,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1112,7 +1194,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1126,10 +1208,12 @@ open class NewPostViewModel : ViewModel() {
|
||||
mimeType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
onError: (message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
if (bytes.size > 80000) {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Media is too big for NIP-95")
|
||||
onError(stringRes(context, id = R.string.media_too_big_for_nip95))
|
||||
isUploadingImage = false
|
||||
}
|
||||
return
|
||||
@@ -1157,7 +1241,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") }
|
||||
onError(stringRes(context, R.string.could_not_prepare_header, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -46,29 +45,20 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun NewUserMetadataView(
|
||||
onClose: () -> Unit,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val postViewModel: NewUserMetadataViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
}
|
||||
postViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
@@ -164,7 +154,7 @@ fun NewUserMetadataView(
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
) {
|
||||
postViewModel.uploadForPicture(it, context)
|
||||
postViewModel.uploadForPicture(it, context, onError = accountViewModel::toast)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
@@ -189,7 +179,7 @@ fun NewUserMetadataView(
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
) {
|
||||
postViewModel.uploadForBanner(it, context)
|
||||
postViewModel.uploadForBanner(it, context, onError = accountViewModel::toast)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
|
||||
@@ -27,15 +27,15 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.events.GitHubIdentity
|
||||
import com.vitorpamplona.quartz.events.MastodonIdentity
|
||||
import com.vitorpamplona.quartz.events.TwitterIdentity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -60,8 +60,6 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
|
||||
var isUploadingImageForPicture by mutableStateOf(false)
|
||||
var isUploadingImageForBanner by mutableStateOf(false)
|
||||
val imageUploadingError =
|
||||
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
@@ -130,6 +128,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
fun uploadForPicture(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
upload(
|
||||
@@ -137,6 +136,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context,
|
||||
onUploading = { isUploadingImageForPicture = it },
|
||||
onUploaded = { picture.value = it },
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
fun uploadForBanner(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
upload(
|
||||
@@ -151,6 +152,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context,
|
||||
onUploading = { isUploadingImageForBanner = it },
|
||||
onUploaded = { banner.value = it },
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -160,6 +162,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
context: Context,
|
||||
onUploading: (Boolean) -> Unit,
|
||||
onUploaded: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
@@ -181,7 +184,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
size = size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
server = account.defaultFileServer,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
@@ -194,22 +197,19 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
onUploaded(url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
onUploading(false)
|
||||
viewModelScope.launch { imageUploadingError.emit(it) }
|
||||
|
||||
onError(stringRes(context, R.string.error_when_compressing_media), stringRes(context, it))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ data class Kind3RelayProposalSetupInfo(
|
||||
val feedTypes: Set<FeedType>,
|
||||
val relayStat: RelayStat,
|
||||
val paidRelay: Boolean = false,
|
||||
val users: Set<HexKey>,
|
||||
val users: List<HexKey>,
|
||||
) {
|
||||
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
relayFile
|
||||
.map {
|
||||
val localInfoFeedTypes =
|
||||
account.localRelays
|
||||
account.settings.localRelays
|
||||
.filter { localRelay -> localRelay.url == it.key }
|
||||
.firstOrNull()
|
||||
?.feedTypes
|
||||
@@ -119,7 +119,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
.sortedBy { it.relayStat.receivedBytes }
|
||||
.reversed()
|
||||
} else {
|
||||
account.localRelays
|
||||
account.settings.localRelays
|
||||
.map {
|
||||
Kind3BasicRelaySetupInfo(
|
||||
url = RelayUrlFormatter.normalize(it.url),
|
||||
@@ -164,7 +164,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
write = false,
|
||||
feedTypes = setOf(FeedType.FOLLOWS),
|
||||
relayStat = RelayStats.get(it.url),
|
||||
users = it.users,
|
||||
users = it.users.sorted(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -22,12 +22,14 @@ package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
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.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -41,18 +43,25 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.ui.note.AddRelayButton
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChatMaxWidth
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
|
||||
import com.vitorpamplona.ammolite.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.ammolite.relays.RelayStat
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
@@ -65,7 +74,11 @@ fun Kind3RelaySetupInfoProposalRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
@@ -105,19 +118,10 @@ fun Kind3RelaySetupInfoProposalRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(verticalArrangement = Arrangement.Center) {
|
||||
item.users.forEach {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UsedBy(item, accountViewModel, nav)
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(start = 10.dp),
|
||||
@@ -129,3 +133,68 @@ fun Kind3RelaySetupInfoProposalRow(
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UsedByPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
ThemeComparisonColumn {
|
||||
UsedBy(
|
||||
item =
|
||||
Kind3RelayProposalSetupInfo(
|
||||
"wss://nos.lol",
|
||||
true,
|
||||
true,
|
||||
COMMON_FEED_TYPES,
|
||||
relayStat = RelayStat(),
|
||||
paidRelay = false,
|
||||
users = listOf("User1", "User2", "User3", "User4"),
|
||||
),
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UsedBy(
|
||||
item: Kind3RelayProposalSetupInfo,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
FlowRow(verticalArrangement = Arrangement.Center) {
|
||||
item.users.getOrNull(0)?.let {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
item.users.getOrNull(1)?.let {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
item.users.getOrNull(2)?.let {
|
||||
UserPicture(
|
||||
userHex = it,
|
||||
size = Size25dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
if (item.users.size > 3) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.height(Size25dp)) {
|
||||
Text(
|
||||
text = stringRes(R.string.and_more, item.users.size - 3),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions.relays
|
||||
|
||||
class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
|
||||
override fun getRelayList(): List<String>? = account.localRelayServers.toList()
|
||||
override fun getRelayList(): List<String> = account.settings.localRelayServers.toList()
|
||||
|
||||
override fun saveRelayList(urlList: List<String>) {
|
||||
account.updateLocalRelayServers(urlList.toSet())
|
||||
account.settings.updateLocalRelayServers(urlList.toSet())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,11 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
|
||||
@@ -40,7 +40,6 @@ 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
|
||||
@@ -67,7 +66,6 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
|
||||
@Composable
|
||||
fun AudioWaveformReadOnly(
|
||||
modifier: Modifier = Modifier,
|
||||
style: DrawStyle = Fill,
|
||||
waveformBrush: Brush = SolidColor(Color.White),
|
||||
progressBrush: Brush = SolidColor(Color.Blue),
|
||||
waveformAlignment: WaveformAlignment = WaveformAlignment.Center,
|
||||
@@ -131,7 +129,7 @@ fun AudioWaveformReadOnly(
|
||||
height = amplitude,
|
||||
),
|
||||
cornerRadius = CornerRadius(spikeRadiusState.toPx(), spikeRadiusState.toPx()),
|
||||
style = style,
|
||||
style = Fill,
|
||||
)
|
||||
drawRect(
|
||||
brush = progressBrush,
|
||||
|
||||
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -98,8 +99,8 @@ fun CashuPreview(
|
||||
|
||||
CrossfadeIfEnabled(targetState = cashuData, label = "CashuPreview", accountViewModel = accountViewModel) {
|
||||
when (it) {
|
||||
is GenericLoadable.Loaded<List<CashuToken>> -> CashuPreview(it.loaded, accountViewModel)
|
||||
is GenericLoadable.Error<List<CashuToken>> ->
|
||||
is GenericLoadable.Loaded<ImmutableList<CashuToken>> -> CashuPreview(it.loaded, accountViewModel)
|
||||
is GenericLoadable.Error<ImmutableList<CashuToken>> ->
|
||||
Text(
|
||||
text = "$cashutoken ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
@@ -111,7 +112,7 @@ fun CashuPreview(
|
||||
|
||||
@Composable
|
||||
fun CashuPreview(
|
||||
tokens: List<CashuToken>,
|
||||
tokens: ImmutableList<CashuToken>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
tokens.forEach {
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
@@ -59,7 +60,7 @@ import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.note.njumpLink
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
@@ -80,12 +81,12 @@ fun ClickableRoute(
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
when (val entity = nip19.entity) {
|
||||
is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NProfile -> DisplayUser(entity.hex, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.Note -> DisplayEvent(entity.hex, null, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NPub -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NProfile -> DisplayUser(entity.hex, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.Note -> DisplayEvent(entity.hex, null, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NEvent -> DisplayEvent(entity.hex, entity.kind, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NEmbed -> LoadAndDisplayEvent(entity.event, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NAddress -> DisplayAddress(entity, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NAddress -> DisplayAddress(entity, nip19.nip19raw, nip19.additionalChars, accountViewModel, nav)
|
||||
is Nip19Bech32.NRelay -> {
|
||||
Text(word)
|
||||
}
|
||||
@@ -127,11 +128,16 @@ private fun LoadAndDisplayEvent(
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, event.id, event.kind, additionalChars, accountViewModel, nav)
|
||||
} else {
|
||||
val externalLink = event.toNIP19()
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = remember(event.id) { "@${event.toNIP19()}" },
|
||||
clickablePart = "@$externalLink",
|
||||
suffix = additionalChars,
|
||||
route = remember(event.id) { "Event/${event.id}" },
|
||||
nav = nav,
|
||||
maxLines = 1,
|
||||
onClick = {
|
||||
runCatching { uri.openUri(njumpLink(externalLink)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -141,6 +147,7 @@ private fun LoadAndDisplayEvent(
|
||||
fun DisplayEvent(
|
||||
hex: HexKey,
|
||||
kind: Int?,
|
||||
nip19: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
@@ -149,11 +156,16 @@ fun DisplayEvent(
|
||||
if (it != null) {
|
||||
DisplayNoteLink(it, hex, kind, additionalChars, accountViewModel, nav)
|
||||
} else {
|
||||
val externalLink = njumpLink(nip19)
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = remember(hex) { "@${hex.toShortenHex()}" },
|
||||
clickablePart = remember(nip19) { "@$nip19" },
|
||||
suffix = additionalChars,
|
||||
route = remember(hex) { "Event/$hex" },
|
||||
nav = nav,
|
||||
maxLines = 1,
|
||||
onClick = {
|
||||
runCatching { uri.openUri(externalLink) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -218,6 +230,7 @@ private fun DisplayNoteLink(
|
||||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19Bech32.NAddress,
|
||||
originalNip19: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
@@ -245,21 +258,23 @@ private fun DisplayAddress(
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
if (additionalChars != null) {
|
||||
Text(
|
||||
remember { "@${nip19.atag}$additionalChars" },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
remember { "@${nip19.atag}" },
|
||||
)
|
||||
}
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = "@$originalNip19",
|
||||
suffix = additionalChars,
|
||||
maxLines = 1,
|
||||
onClick = {
|
||||
runCatching { uri.openUri(njumpLink(originalNip19)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun DisplayUser(
|
||||
userHex: HexKey,
|
||||
originalNip19: String,
|
||||
additionalChars: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
@@ -280,15 +295,16 @@ public fun DisplayUser(
|
||||
userBase?.let { RenderUserAsClickableText(it, additionalChars, nav) }
|
||||
|
||||
if (userBase == null) {
|
||||
if (additionalChars != null) {
|
||||
Text(
|
||||
remember { "@${userHex}$additionalChars" },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
remember { "@$userHex" },
|
||||
)
|
||||
}
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = "@$originalNip19",
|
||||
suffix = additionalChars,
|
||||
maxLines = 1,
|
||||
onClick = {
|
||||
runCatching { uri.openUri(njumpLink(originalNip19)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +336,26 @@ fun CreateClickableText(
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
route: String,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
CreateClickableText(
|
||||
clickablePart,
|
||||
suffix,
|
||||
maxLines,
|
||||
overrideColor,
|
||||
fontWeight,
|
||||
fontSize,
|
||||
) { nav(route) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableText(
|
||||
clickablePart: String,
|
||||
suffix: String?,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
onClick: (Int) -> Unit,
|
||||
) {
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val onBackgroundColor = MaterialTheme.colorScheme.onBackground
|
||||
@@ -351,7 +387,8 @@ fun CreateClickableText(
|
||||
ClickableText(
|
||||
text = text,
|
||||
maxLines = maxLines,
|
||||
onClick = { nav(route) },
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.runtime.Composable
|
||||
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
|
||||
@@ -52,24 +51,19 @@ import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.events.LnZapEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun InvoiceRequestCard(
|
||||
lud16: String,
|
||||
toUserPubKeyHex: String,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
titleText: String? = null,
|
||||
buttonText: String? = null,
|
||||
onSuccess: (String) -> Unit,
|
||||
@@ -90,7 +84,7 @@ fun InvoiceRequestCard(
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
toUserPubKeyHex,
|
||||
account,
|
||||
accountViewModel,
|
||||
titleText,
|
||||
buttonText,
|
||||
onSuccess,
|
||||
@@ -105,7 +99,7 @@ fun InvoiceRequestCard(
|
||||
fun InvoiceRequest(
|
||||
lud16: String,
|
||||
toUserPubKeyHex: String,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
titleText: String? = null,
|
||||
buttonText: String? = null,
|
||||
onSuccess: (String) -> Unit,
|
||||
@@ -113,7 +107,6 @@ fun InvoiceRequest(
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -186,36 +179,16 @@ fun InvoiceRequest(
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (account.defaultZapType == LnZapEvent.ZapType.NONZAP) {
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
null,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) { zapRequest ->
|
||||
LocalCache.justConsume(zapRequest, null)
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
lud16,
|
||||
amount * 1000,
|
||||
message,
|
||||
zapRequest.toJson(),
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
accountViewModel.sendSats(
|
||||
lnaddress = lud16,
|
||||
milliSats = amount * 1000,
|
||||
message = message,
|
||||
toUserPubKeyHex = toUserPubKeyHex,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
},
|
||||
shape = QuoteBorder,
|
||||
colors =
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
|
||||
import com.abedelazizshe.lightcompressorlibrary.VideoQuality
|
||||
import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration
|
||||
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import id.zelory.compressor.Compressor
|
||||
import id.zelory.compressor.constraint.default
|
||||
@@ -44,7 +45,7 @@ class MediaCompressor {
|
||||
contentType: String?,
|
||||
applicationContext: Context,
|
||||
onReady: (Uri, String?, Long?) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onError: (Int) -> Unit,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
@@ -87,7 +88,7 @@ class MediaCompressor {
|
||||
if (path != null) {
|
||||
onReady(Uri.fromFile(File(path)), contentType, size)
|
||||
} else {
|
||||
onError("Compression Returned null")
|
||||
onError(R.string.compression_returned_null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ class MediaCompressor {
|
||||
}
|
||||
|
||||
override fun onCancelled(index: Int) {
|
||||
onError("Compression Cancelled")
|
||||
onError(R.string.compression_cancelled)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -60,7 +60,6 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
|
||||
@@ -79,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
|
||||
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Segment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.HashtagIcon
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -87,21 +85,16 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
fun isMarkdown(content: String): Boolean =
|
||||
content.startsWith("> ") ||
|
||||
@@ -223,26 +216,6 @@ fun RenderRegularPreview2() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun mockAccountViewModel(): AccountViewModel {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
sharedPreferencesViewModel.init()
|
||||
|
||||
return AccountViewModel(
|
||||
Account(
|
||||
// blank keys
|
||||
keyPair =
|
||||
KeyPair(
|
||||
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
|
||||
pubKey = Hex.decode("989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799"),
|
||||
forcePubKeyCheck = false,
|
||||
),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
),
|
||||
sharedPreferencesViewModel.sharedPrefs,
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderRegularPreview3() {
|
||||
@@ -519,11 +492,11 @@ fun DisplayFullNote(
|
||||
) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = MaterialTheme.colorScheme.innerPostModifier,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
@@ -671,11 +644,11 @@ private fun DisplayNoteFromTag(
|
||||
if (canPreview && quotesLeft > 0) {
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = MaterialTheme.colorScheme.innerPostModifier,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -97,7 +97,9 @@ fun SensitivityWarning(
|
||||
accountViewModel: AccountViewModel,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val accountState = accountViewModel.account.showSensitiveContent.collectAsStateWithLifecycle()
|
||||
val accountState =
|
||||
accountViewModel.account.settings.showSensitiveContent
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
var showContentWarningNote by remember(accountState) { mutableStateOf(accountState.value != true) }
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ fun VideoViewInner(
|
||||
defaultToStart: Boolean = false,
|
||||
title: String? = null,
|
||||
thumb: VideoThumb? = null,
|
||||
showControls: Boolean = false,
|
||||
showControls: Boolean = true,
|
||||
isFiniteHeight: Boolean,
|
||||
borderModifier: Modifier,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
@@ -375,7 +375,7 @@ fun VideoViewInner(
|
||||
mimeType = mimeType,
|
||||
controller = controller,
|
||||
thumbData = thumb,
|
||||
hideControls = showControls,
|
||||
showControls = showControls,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
waveform = waveform,
|
||||
@@ -723,7 +723,7 @@ private fun RenderVideoPlayer(
|
||||
mimeType: String?,
|
||||
controller: MediaController,
|
||||
thumbData: VideoThumb?,
|
||||
hideControls: Boolean = false,
|
||||
showControls: Boolean = true,
|
||||
isFiniteHeight: Boolean,
|
||||
nostrUriCallback: String?,
|
||||
waveform: ImmutableList<Int>? = null,
|
||||
@@ -754,7 +754,7 @@ private fun RenderVideoPlayer(
|
||||
setBackgroundColor(Color.Transparent.toArgb())
|
||||
setShutterBackgroundColor(Color.Transparent.toArgb())
|
||||
controllerAutoShow = false
|
||||
useController = !hideControls
|
||||
useController = showControls
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
resizeMode =
|
||||
@@ -763,7 +763,7 @@ private fun RenderVideoPlayer(
|
||||
} else {
|
||||
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
}
|
||||
if (!hideControls) {
|
||||
if (showControls) {
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
controller.pause()
|
||||
@@ -783,7 +783,7 @@ private fun RenderVideoPlayer(
|
||||
|
||||
waveform?.let { Waveform(it, controller, remember { Modifier.align(Alignment.Center) }) }
|
||||
|
||||
if (!hideControls) {
|
||||
if (showControls) {
|
||||
val startingMuteState = remember(controller) { controller.volume < 0.001 }
|
||||
|
||||
MuteButton(
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -82,6 +83,8 @@ import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils
|
||||
import com.vitorpamplona.amethyst.ui.navigation.getActivity
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.HashCheckFailedIcon
|
||||
@@ -118,6 +121,12 @@ fun ZoomableContentView(
|
||||
) {
|
||||
var dialogOpen by remember(content) { mutableStateOf(false) }
|
||||
|
||||
val activity = LocalView.current.context.getActivity()
|
||||
val currentWindowSize = currentWindowAdaptiveInfo().windowSizeClass
|
||||
|
||||
val isLandscapeMode = DeviceUtils.isLandscapeMetric(LocalContext.current)
|
||||
val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode)
|
||||
|
||||
val contentScale =
|
||||
if (isFiniteHeight) {
|
||||
ContentScale.Fit
|
||||
@@ -149,7 +158,12 @@ fun ZoomableContentView(
|
||||
roundedCorner = roundedCorner,
|
||||
isFiniteHeight = isFiniteHeight,
|
||||
nostrUriCallback = content.uri,
|
||||
onDialog = { dialogOpen = true },
|
||||
onDialog = {
|
||||
dialogOpen = true
|
||||
// if (!isFoldableOrLarge) {
|
||||
// DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
|
||||
// }
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -181,7 +195,15 @@ fun ZoomableContentView(
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false }, accountViewModel)
|
||||
ZoomableImageDialog(
|
||||
content,
|
||||
images,
|
||||
onDismiss = {
|
||||
dialogOpen = false
|
||||
// if (!isFoldableOrLarge) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
|
||||
},
|
||||
accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,8 +162,8 @@ class MarkdownMediaRenderer(
|
||||
}
|
||||
} else if (loadedLink?.nip19 != null) {
|
||||
when (val entity = loadedLink.nip19.entity) {
|
||||
is Nip19Bech32.NPub -> renderObservableUser(entity.hex, richTextStringBuilder)
|
||||
is Nip19Bech32.NProfile -> renderObservableUser(entity.hex, richTextStringBuilder)
|
||||
is Nip19Bech32.NPub -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder)
|
||||
is Nip19Bech32.NProfile -> renderObservableUser(entity.hex, loadedLink.nip19.nip19raw, richTextStringBuilder)
|
||||
is Nip19Bech32.Note -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NEvent -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
is Nip19Bech32.NEmbed -> renderObservableShortNoteUri(loadedLink, uri, richTextStringBuilder)
|
||||
@@ -201,10 +201,11 @@ class MarkdownMediaRenderer(
|
||||
|
||||
fun renderObservableUser(
|
||||
userHex: String,
|
||||
nip19: String,
|
||||
richTextStringBuilder: RichTextString.Builder,
|
||||
) {
|
||||
renderInline(richTextStringBuilder) {
|
||||
DisplayUser(userHex, null, accountViewModel, nav)
|
||||
DisplayUser(userHex, nip19, null, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,22 +20,41 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.markdown
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.commonmark.MarkdownParseOptions
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import com.vitorpamplona.quartz.events.EmptyTagList
|
||||
import com.vitorpamplona.quartz.events.ImmutableListOfLists
|
||||
import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.MetadataEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
|
||||
@Composable
|
||||
fun RenderContentAsMarkdown(
|
||||
@@ -91,3 +110,348 @@ fun RenderContentAsMarkdown(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderContentAsMarkdownPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
ThemeComparisonRow {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
RenderContentAsMarkdown(
|
||||
"# Hello 1\n" +
|
||||
"## Hello 2\n" +
|
||||
"### Hello 3\n" +
|
||||
"#### Hello 4\n" +
|
||||
"##### Hello 5\n" +
|
||||
"###### Hello 6\n" +
|
||||
"This is regular text\n" +
|
||||
"\n" +
|
||||
"**This is bold text**\n" +
|
||||
"\n" +
|
||||
"__This is bold text__\n" +
|
||||
"\n" +
|
||||
"*This is italic text*\n" +
|
||||
"\n" +
|
||||
"_This is italic text_\n" +
|
||||
"\n" +
|
||||
"~~Strikethrough~~\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"## Blockquotes\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"> Blockquotes can also be nested...\n" +
|
||||
">> ...by using additional greater-than signs right next to each other...\n" +
|
||||
"> > > ...or with spaces between arrows.\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"## End\n",
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 2,
|
||||
backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(background)
|
||||
},
|
||||
callbackUri = "nostr:something",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderContentAsMarkdownListsPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
ThemeComparisonRow {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
RenderContentAsMarkdown(
|
||||
"## Lists\n" +
|
||||
"\n" +
|
||||
"Unordered\n" +
|
||||
"\n" +
|
||||
"+ Create a list by starting a line with `+`, `-`, or `*`\n" +
|
||||
"+ Sub-lists 2 spaces:\n" +
|
||||
" - Character change forces new list:\n" +
|
||||
" * Ac tristique libero volutpat at\n" +
|
||||
" + Facilisis in pretium nisl aliquet\n" +
|
||||
" - Nulla volutpat aliquam velit\n" +
|
||||
"+ Very easy!\n" +
|
||||
"\n" +
|
||||
"Ordered\n" +
|
||||
"\n" +
|
||||
"1. Lorem ipsum dolor sit amet\n" +
|
||||
"2. Consectetur adipiscing elit\n" +
|
||||
"3. Integer molestie lorem at massa\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"1. You can use sequential numbers...\n" +
|
||||
"1. ...or keep all the numbers as `1.`\n" +
|
||||
"\n" +
|
||||
"Start number with offset:\n" +
|
||||
"\n" +
|
||||
"57. foo\n" +
|
||||
"1. bar\n",
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 2,
|
||||
backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(background)
|
||||
},
|
||||
callbackUri = "nostr:something",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderContentAsMarkdownCodePreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
ThemeComparisonRow {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
RenderContentAsMarkdown(
|
||||
"## Code\n" +
|
||||
"\n" +
|
||||
"Inline `code`\n" +
|
||||
"\n" +
|
||||
"Indented code\n" +
|
||||
"\n" +
|
||||
" // Some comments\n" +
|
||||
" line 1 of code\n" +
|
||||
" line 2 of code\n" +
|
||||
" line 3 of code\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"Block code \"fences\"\n" +
|
||||
"\n" +
|
||||
"```\n" +
|
||||
"Sample text here...\n" +
|
||||
"```\n" +
|
||||
"\n" +
|
||||
"Syntax highlighting\n" +
|
||||
"\n" +
|
||||
"``` js\n" +
|
||||
"var foo = function (bar) {\n" +
|
||||
" return bar++;\n" +
|
||||
"};\n" +
|
||||
"var veryVeryVeryLongVariableNameWithALongValueAsWell = 123456789012345678901234567890;\n" +
|
||||
"console.log(foo(5));\n" +
|
||||
"```\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"### End\n",
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 2,
|
||||
backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(background)
|
||||
},
|
||||
callbackUri = "nostr:something",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderContentAsMarkdownTablesPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
ThemeComparisonRow {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
RenderContentAsMarkdown(
|
||||
"## Tables\n" +
|
||||
"\n" +
|
||||
"| Option | Description |\n" +
|
||||
"| ------ | ----------- |\n" +
|
||||
"| data | path to data files to supply the data that will be passed into templates. |\n" +
|
||||
"| engine | engine to be used for processing templates. Handlebars is the default. |\n" +
|
||||
"| ext | extension to be used for dest files. |\n" +
|
||||
"\n" +
|
||||
"Right aligned columns\n" +
|
||||
"\n" +
|
||||
"| Option | Description |\n" +
|
||||
"| ------:| -----------:|\n" +
|
||||
"| data | path to data files to supply the data that will be passed into templates. |\n" +
|
||||
"| engine | engine to be used for processing templates. Handlebars is the default. |\n" +
|
||||
"| ext | extension to be used for dest files. |\n",
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 2,
|
||||
backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(background)
|
||||
},
|
||||
callbackUri = "nostr:something",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderContentAsMarkdownFootNotesPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
ThemeComparisonRow {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
RenderContentAsMarkdown(
|
||||
"### [Footnotes]\n" +
|
||||
"\n" +
|
||||
"Footnote 1 link[^first].\n" +
|
||||
"\n" +
|
||||
"Footnote 2 link[^second].\n" +
|
||||
"\n" +
|
||||
"Duplicated footnote reference[^second].\n" +
|
||||
"\n" +
|
||||
"[^first]: Footnote **can have markup**\n" +
|
||||
"\n" +
|
||||
" and multiple paragraphs.\n" +
|
||||
"\n" +
|
||||
"[^second]: Footnote text.\n",
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 2,
|
||||
backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(background)
|
||||
},
|
||||
callbackUri = "nostr:something",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderContentAsMarkdownUserPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
val qa =
|
||||
MetadataEvent(
|
||||
id = "4d5e1c614e18b915e6899fff93b5345253a5e2a8d0cbdb9ca98758da1612e05b",
|
||||
pubKey = "88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079",
|
||||
createdAt = 1722306330,
|
||||
tags = emptyArray(),
|
||||
content = "{\"lud16\":\"qa@primal.net\",\"website\":\"https:\\/\\/primal.net\",\"lud06\":\"\",\"name\":\"qauser\",\"banner\":\"https:\\/\\/m.primal.net\\/HQTd.jpg\",\"display_name\":\"qa\",\"nip05\":\"qa@primal.net\",\"picture\":\"https:\\/\\/m.primal.net\\/JidC.jpg\",\"about\":\"If you are following this account for quality content, you are going to have a bad time. #qa #primal \"}",
|
||||
sig = "2a5fbd16e8fd67873cee1e60fc7b1b09d8a3cb5d503528507fa47d29669b259153ba710acce725d1f1bec867d7f86cc72820e496604e9183e5f58d0f2fb98f58",
|
||||
)
|
||||
|
||||
UrlCachedPreviewer.cache.put(
|
||||
"https://duckduckgo.com/",
|
||||
UrlPreviewState.Loaded(
|
||||
UrlInfoItem(
|
||||
url = "https://duckduckgo.com/",
|
||||
image = "https://duckduckgo.com/assets/logo_social-media.png",
|
||||
title = "DuckDuckGo — Privacy, simplified.",
|
||||
description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.",
|
||||
mimeType = "text/html".toMediaType(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
LocalCache.justConsume(qa, null)
|
||||
}
|
||||
}
|
||||
|
||||
ThemeComparisonRow {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
RenderContentAsMarkdown(
|
||||
"This is a test by nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf that tests multiple lines of markdown\n" +
|
||||
"\n" +
|
||||
"nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf\n" +
|
||||
"\n" +
|
||||
"here is a [link to somewhere in text](https://duckduckgo.com/) so as to see how it would render and here it is bare-bone https://duckduckgo.com/ as preview just to make sure it is nicely parsed.\n" +
|
||||
"\n ## END \n",
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 2,
|
||||
backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(background)
|
||||
},
|
||||
callbackUri = "nostr:something",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RenderContentAsMarkdownNotePreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
val nav: (String) -> Unit = {}
|
||||
|
||||
runBlocking {
|
||||
withContext(Dispatchers.IO) {
|
||||
val blogPost =
|
||||
LongTextNoteEvent(
|
||||
id = "e69fa72a221d0b7362f8e63fd6ab84cc6b5e7b505fe49cefe6823a47a6a4b583",
|
||||
pubKey = "88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079",
|
||||
createdAt = 1718785725,
|
||||
tags = arrayOf(arrayOf("d", "1718785686085"), arrayOf("title", "Test again"), arrayOf("summary", "Testing...."), arrayOf("t", "test"), arrayOf("t", "testing"), arrayOf("published_at", "1718785712"), arrayOf("alt", "This is a long form article, you can read it in https://habla.news/a/naddr1qvzqqqr4gupzpzxvzd935e04fm6g4nqa7dn9qc7nafzlqn4t3t6xgmjkr3dwnyreqqxnzde38qmnsdfk8qmrqwp405km0k"), arrayOf("p", "88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079"), arrayOf("e", "ab002ebdf49a06f9d94959d955c419964403339fde473ebd144321574a105811", "", "mention"), arrayOf("e", "466e2c6e5a59042b44799899885edd112074141e49cc50f9e2ead06f43bfc424", "", "mention"), arrayOf("e", "d6236a9108896ccf7882ce52cd8c53466d2c1018e7e85190f28310aa3dd5b554", "", "mention"), arrayOf("e", "c1896afdf528e2a62ab0fc15b8e0320ef58f10685363a9c6b2d549b79102f446", "", "mention")),
|
||||
content = "lets try this out nostr:note14vqza005ngr0nk2ft8v4t3qejezqxvulmerna0g5gvs4wjsstqgsmeupgy and nostr:note1gehzcmj6tyzzk3renzvcshkazys8g9q7f8x9p70zatgx7salcsjqax60pz or nostr:note16c3k4ygg39kv77yzeefvmrzngekjcyqcul59ry8jsvg250w4k42qk9d4dw or nostr:note1cxyk4l049r32v24sls2m3cpjpm6c7yrg2d36n34j64ym0ygz73rqzplrn7 and some users nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf and nostr:npub13rxpxjc6vh65aay2eswlxejsv0f7530sf64c4arydetpckhfjpustsjeaf",
|
||||
sig = "4c85e0eb0c46c5e3023431ad4ed8efa0abd66447ff757d246154e2349ac01ae0f88f213d02efa0a77f307f305d4a608c785ae1ca080c01cd3a9e7b8dffea6f9c",
|
||||
)
|
||||
|
||||
LocalCache.justConsume(blogPost, null)
|
||||
}
|
||||
}
|
||||
|
||||
ThemeComparisonRow {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
|
||||
RenderContentAsMarkdown(
|
||||
"here is a mention of another article nostr:naddr1qvzqqqr4gupzpzxvzd935e04fm6g4nqa7dn9qc7nafzlqn4t3t6xgmjkr3dwnyreqqxnzde38qmnsdfk8qmrqwp405km0k\n" +
|
||||
"\n" +
|
||||
"## Images\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"\n" +
|
||||
"and here is one in the middle of the text  that continues right after. Just to see how this will render\n" +
|
||||
"\n",
|
||||
tags = EmptyTagList,
|
||||
canPreview = true,
|
||||
quotesLeft = 2,
|
||||
backgroundColor =
|
||||
remember {
|
||||
mutableStateOf(background)
|
||||
},
|
||||
callbackUri = "nostr:something",
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.pm.ActivityInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.window.core.layout.WindowHeightSizeClass
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import androidx.window.core.layout.WindowWidthSizeClass
|
||||
|
||||
object DeviceUtils {
|
||||
/**
|
||||
* Tries to determine if the device is
|
||||
* in landscape mode, by using the [android.util.DisplayMetrics] API.
|
||||
*
|
||||
* Credits: NewPipe devs
|
||||
*/
|
||||
fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels
|
||||
|
||||
fun changeDeviceOrientation(
|
||||
isInLandscape: Boolean,
|
||||
currentActivity: Activity,
|
||||
) {
|
||||
val newOrientation =
|
||||
if (isInLandscape) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||
}
|
||||
currentActivity.requestedOrientation = newOrientation
|
||||
}
|
||||
|
||||
/**
|
||||
* This method looks at the window in which the app resides,
|
||||
* and determines if it is large, while making sure not to be affected
|
||||
* by configuration changes(such as screen rotation),
|
||||
* as the device display metrics can be affected as well.
|
||||
*
|
||||
* It could be used as an approximation of the type of device(as is the case here),
|
||||
* though one ought to be careful about multi-window situations.
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun windowIsLarge(
|
||||
isInLandscapeMode: Boolean,
|
||||
windowSize: WindowSizeClass,
|
||||
): Boolean =
|
||||
remember(windowSize) {
|
||||
if (isInLandscapeMode) {
|
||||
when (windowSize.windowHeightSizeClass) {
|
||||
WindowHeightSizeClass.COMPACT -> false
|
||||
WindowHeightSizeClass.MEDIUM -> true
|
||||
WindowHeightSizeClass.EXPANDED -> true
|
||||
else -> true
|
||||
}
|
||||
} else {
|
||||
when (windowSize.windowWidthSizeClass) {
|
||||
WindowWidthSizeClass.EXPANDED -> true
|
||||
WindowWidthSizeClass.MEDIUM -> true
|
||||
WindowWidthSizeClass.COMPACT -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.Card
|
||||
import com.vitorpamplona.quartz.events.Event
|
||||
|
||||
val DefaultFeedOrder: Comparator<Note> =
|
||||
@@ -37,3 +38,6 @@ val DefaultFeedOrder: Comparator<Note> =
|
||||
}
|
||||
}
|
||||
}.thenBy { it.idHex }
|
||||
|
||||
val DefaultFeedOrderCard: Comparator<Card> =
|
||||
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
|
||||
|
||||
@@ -32,12 +32,12 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
open class DiscoverChatFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
account.defaultDiscoveryFollowList.value ==
|
||||
account.settings.defaultDiscoveryFollowList.value ==
|
||||
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultDiscoveryFollowList.value ==
|
||||
account.settings.defaultDiscoveryFollowList.value ==
|
||||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
@@ -67,7 +67,7 @@ open class DiscoverChatFeedFilter(
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
selectedListName = account.settings.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -32,19 +32,19 @@ import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
open class DiscoverCommunityFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultDiscoveryFollowList.value
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
account.defaultDiscoveryFollowList.value ==
|
||||
account.settings.defaultDiscoveryFollowList.value ==
|
||||
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultDiscoveryFollowList.value ==
|
||||
account.settings.defaultDiscoveryFollowList.value ==
|
||||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
selectedListName = account.settings.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
@@ -73,7 +73,7 @@ open class DiscoverCommunityFeedFilter(
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
selectedListName = account.settings.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ open class DiscoverLiveFeedFilter(
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
|
||||
|
||||
open fun followList(): String = account.defaultDiscoveryFollowList.value
|
||||
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
@@ -57,7 +57,7 @@ open class DiscoverLiveFeedFilter(
|
||||
val filterParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultDiscoveryFollowList.value,
|
||||
selectedListName = account.settings.defaultDiscoveryFollowList.value,
|
||||
followLists = account.liveDiscoveryFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ open class DiscoverMarketplaceFeedFilter(
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
|
||||
|
||||
open fun followList(): String = account.defaultDiscoveryFollowList.value
|
||||
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
@@ -55,7 +55,7 @@ open class DiscoverMarketplaceFeedFilter(
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.userProfile().pubkeyHex,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.settings.defaultDiscoveryFollowList.value,
|
||||
account.liveDiscoveryFollowLists.value,
|
||||
account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -36,7 +36,7 @@ open class DiscoverNIP89FeedFilter(
|
||||
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList()
|
||||
|
||||
open fun followList(): String = account.defaultDiscoveryFollowList.value
|
||||
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
@@ -58,7 +58,7 @@ open class DiscoverNIP89FeedFilter(
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.userProfile().pubkeyHex,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.settings.defaultDiscoveryFollowList.value,
|
||||
account.liveDiscoveryFollowLists.value,
|
||||
account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -33,11 +33,11 @@ import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
class HomeConversationsFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val filterParams = buildFilterParams(account)
|
||||
@@ -54,7 +54,7 @@ class HomeConversationsFeedFilter(
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultHomeFollowList.value,
|
||||
selectedListName = account.settings.defaultHomeFollowList.value,
|
||||
followLists = account.liveHomeFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -39,16 +39,16 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
class HomeNewThreadFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultHomeFollowList.value
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultHomeFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
account.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
account.settings.defaultHomeFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.settings.defaultHomeFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultHomeFollowList.value,
|
||||
selectedListName = account.settings.defaultHomeFollowList.value,
|
||||
followLists = account.liveHomeFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ open class NIP90ContentDiscoveryResponseFilter(
|
||||
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + request
|
||||
|
||||
open fun followList(): String = account.defaultDiscoveryFollowList.value
|
||||
open fun followList(): String = account.settings.defaultDiscoveryFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
@@ -71,7 +71,7 @@ open class NIP90ContentDiscoveryResponseFilter(
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
account.userProfile().pubkeyHex,
|
||||
account.defaultDiscoveryFollowList.value,
|
||||
account.settings.defaultDiscoveryFollowList.value,
|
||||
account.liveDiscoveryFollowLists.value,
|
||||
account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -47,18 +47,18 @@ import com.vitorpamplona.quartz.events.RepostEvent
|
||||
class NotificationFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultNotificationFollowList.value
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultNotificationFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
account.defaultNotificationFollowList.value ==
|
||||
account.settings.defaultNotificationFollowList.value ==
|
||||
PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultNotificationFollowList.value ==
|
||||
account.settings.defaultNotificationFollowList.value ==
|
||||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultNotificationFollowList.value,
|
||||
selectedListName = account.settings.defaultNotificationFollowList.value,
|
||||
followLists = account.liveNotificationFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
|
||||
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
|
||||
class UserProfileConversationsFeedFilter(
|
||||
val user: User,
|
||||
@@ -59,7 +60,8 @@ class UserProfileConversationsFeedFilter(
|
||||
it.event is TextNoteEvent ||
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is ChannelMessageEvent ||
|
||||
it.event is LiveActivitiesChatMessageEvent
|
||||
it.event is LiveActivitiesChatMessageEvent ||
|
||||
it.event is TorrentCommentEvent
|
||||
) &&
|
||||
!it.isNewThread() &&
|
||||
account.isAcceptable(it)
|
||||
|
||||
@@ -35,8 +35,8 @@ class UserProfileGalleryFeedFilter(
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + "ProfileGallery"
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
@@ -74,7 +74,7 @@ class UserProfileGalleryFeedFilter(
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultStoriesFollowList.value,
|
||||
selectedListName = account.settings.defaultStoriesFollowList.value,
|
||||
followLists = account.liveStoriesFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.PollNoteEvent
|
||||
import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
|
||||
class UserProfileNewThreadFeedFilter(
|
||||
@@ -73,7 +74,8 @@ class UserProfileNewThreadFeedFilter(
|
||||
it.event is PollNoteEvent ||
|
||||
it.event is HighlightEvent ||
|
||||
it.event is AudioTrackEvent ||
|
||||
it.event is AudioHeaderEvent
|
||||
it.event is AudioHeaderEvent ||
|
||||
it.event is TorrentEvent
|
||||
) &&
|
||||
it.isNewThread() &&
|
||||
account.isAcceptable(it)
|
||||
|
||||
@@ -20,16 +20,30 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
import com.vitorpamplona.quartz.events.zaps.UserZaps
|
||||
import com.vitorpamplona.quartz.events.LnZapEventInterface
|
||||
|
||||
class UserProfileZapsFeedFilter(
|
||||
val user: User,
|
||||
) : FeedFilter<ZapReqResponse>() {
|
||||
override fun feedKey(): String = user.pubkeyHex
|
||||
|
||||
override fun feed(): List<ZapReqResponse> = UserZaps.forProfileFeed(user.zaps)
|
||||
override fun feed(): List<ZapReqResponse> = forProfileFeed(user.zaps)
|
||||
|
||||
override fun limit() = 400
|
||||
|
||||
companion object {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
|
||||
if (zaps == null) return emptyList()
|
||||
|
||||
return (
|
||||
zaps
|
||||
.mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } }
|
||||
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
|
||||
.reversed()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
class VideoFeedFilter(
|
||||
val account: Account,
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.defaultStoriesFollowList.value
|
||||
override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value
|
||||
|
||||
override fun showHiddenKey(): Boolean =
|
||||
account.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) ||
|
||||
account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex)
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
@@ -78,7 +78,7 @@ class VideoFeedFilter(
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
userHex = account.userProfile().pubkeyHex,
|
||||
selectedListName = account.defaultStoriesFollowList.value,
|
||||
selectedListName = account.settings.defaultStoriesFollowList.value,
|
||||
followLists = account.liveStoriesFollowLists.value,
|
||||
hiddenUsers = account.flowHiddenUsers.value,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.ammolite.relays.BundledInsert
|
||||
import com.vitorpamplona.ammolite.relays.BundledUpdate
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
class FeedContentState(
|
||||
val localFilter: FeedFilter<Note>,
|
||||
val viewModelScope: CoroutineScope,
|
||||
) : InvalidatableContent {
|
||||
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
// Simple counter that changes when it needs to invalidate everything
|
||||
private val _scrollToTop = MutableStateFlow<Int>(0)
|
||||
val scrollToTop = _scrollToTop.asStateFlow()
|
||||
var scrolltoTopPending = false
|
||||
|
||||
private var lastFeedKey: String? = null
|
||||
|
||||
fun sendToTop() {
|
||||
if (scrolltoTopPending) return
|
||||
|
||||
scrolltoTopPending = true
|
||||
viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) }
|
||||
}
|
||||
|
||||
suspend fun sentToTop() {
|
||||
scrolltoTopPending = false
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
viewModelScope.launch(Dispatchers.Default) { refreshSuspended() }
|
||||
}
|
||||
|
||||
fun refreshSuspended() {
|
||||
checkNotInMainThread()
|
||||
|
||||
lastFeedKey = localFilter.feedKey()
|
||||
val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
|
||||
|
||||
val oldNotesState = _feedContent.value
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
if (!equalImmutableLists(notes, oldNotesState.feed.value.list)) {
|
||||
updateFeed(notes)
|
||||
}
|
||||
} else {
|
||||
updateFeed(notes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFeed(notes: ImmutableList<Note>) {
|
||||
val currentState = _feedContent.value
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.tryEmit(FeedState.Empty)
|
||||
} else if (currentState is FeedState.Loaded) {
|
||||
currentState.feed.tryEmit(LoadedFeedState(notes, localFilter.showHiddenKey()))
|
||||
} else {
|
||||
_feedContent.tryEmit(
|
||||
FeedState.Loaded(MutableStateFlow(LoadedFeedState(notes, localFilter.showHiddenKey()))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshFromOldState(newItems: Set<Note>) {
|
||||
val oldNotesState = _feedContent.value
|
||||
if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) {
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
val deletionEvents: List<DeletionEvent> =
|
||||
newItems.mapNotNull {
|
||||
val noteEvent = it.event
|
||||
if (noteEvent is DeletionEvent) noteEvent else null
|
||||
}
|
||||
|
||||
val oldList =
|
||||
if (deletionEvents.isEmpty()) {
|
||||
oldNotesState.feed.value.list
|
||||
} else {
|
||||
val deletedEventIds = deletionEvents.flatMapTo(HashSet()) { it.deleteEvents() }
|
||||
val deletedEventAddresses = deletionEvents.flatMapTo(HashSet()) { it.deleteAddresses() }
|
||||
oldNotesState.feed.value.list
|
||||
.filter { !it.wasOrShouldBeDeletedBy(deletedEventIds, deletedEventAddresses) }
|
||||
.toImmutableList()
|
||||
}
|
||||
|
||||
val newList =
|
||||
localFilter
|
||||
.updateListWith(oldList, newItems)
|
||||
.distinctBy { it.idHex }
|
||||
.toImmutableList()
|
||||
if (!equalImmutableLists(newList, oldNotesState.feed.value.list)) {
|
||||
updateFeed(newList)
|
||||
}
|
||||
} else if (oldNotesState is FeedState.Empty) {
|
||||
val newList =
|
||||
localFilter
|
||||
.updateListWith(emptyList(), newItems)
|
||||
.distinctBy { it.idHex }
|
||||
.toImmutableList()
|
||||
if (newList.isNotEmpty()) {
|
||||
updateFeed(newList)
|
||||
}
|
||||
} else {
|
||||
// Refresh Everything
|
||||
refreshSuspended()
|
||||
}
|
||||
} else {
|
||||
// Refresh Everything
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO)
|
||||
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
|
||||
|
||||
override fun invalidateData(ignoreIfDoing: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
bundler.invalidate(ignoreIfDoing) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkKeysInvalidateDataAndSendToTop() {
|
||||
if (lastFeedKey != localFilter.feedKey()) {
|
||||
bundler.invalidate(false) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
sendToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateInsertData(newItems: Set<Note>) {
|
||||
bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) }
|
||||
}
|
||||
|
||||
fun updateFeedWith(newNotes: Set<Note>) {
|
||||
if (
|
||||
localFilter is AdditiveFeedFilter &&
|
||||
(_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty)
|
||||
) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
|
||||
bundlerInsert.cancel()
|
||||
bundler.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun RefresheableFeedContentStateView(
|
||||
feedContentState: FeedContentState,
|
||||
routeForLastRead: String?,
|
||||
enablePullRefresh: Boolean = true,
|
||||
scrollStateKey: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
RefresheableBox(feedContentState, enablePullRefresh) {
|
||||
SaveableFeedContentState(feedContentState, scrollStateKey) { listState ->
|
||||
RenderFeedContentState(feedContentState, accountViewModel, listState, nav, routeForLastRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SaveableFeedContentState(
|
||||
feedContentState: FeedContentState,
|
||||
scrollStateKey: String? = null,
|
||||
content: @Composable (LazyListState) -> Unit,
|
||||
) {
|
||||
val listState =
|
||||
if (scrollStateKey != null) {
|
||||
rememberForeverLazyListState(scrollStateKey)
|
||||
} else {
|
||||
rememberLazyListState()
|
||||
}
|
||||
|
||||
WatchScrollToTop(feedContentState, listState)
|
||||
|
||||
content(listState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SaveableGridFeedContentState(
|
||||
feedContentState: FeedContentState,
|
||||
scrollStateKey: String? = null,
|
||||
content: @Composable (LazyGridState) -> Unit,
|
||||
) {
|
||||
val gridState =
|
||||
if (scrollStateKey != null) {
|
||||
rememberForeverLazyGridState(scrollStateKey)
|
||||
} else {
|
||||
rememberLazyGridState()
|
||||
}
|
||||
|
||||
WatchScrollToTop(feedContentState, gridState)
|
||||
|
||||
content(gridState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderFeedContentState(
|
||||
feedContentState: FeedContentState,
|
||||
accountViewModel: AccountViewModel,
|
||||
listState: LazyListState,
|
||||
nav: (String) -> Unit,
|
||||
routeForLastRead: String?,
|
||||
onLoaded: @Composable (FeedState.Loaded) -> Unit = { FeedLoaded(it, listState, routeForLastRead, accountViewModel, nav) },
|
||||
onEmpty: @Composable () -> Unit = { FeedEmpty(feedContentState::invalidateData) },
|
||||
onError: @Composable (String) -> Unit = { FeedError(it, feedContentState::invalidateData) },
|
||||
onLoading: @Composable () -> Unit = { LoadingFeed() },
|
||||
) {
|
||||
val feedState by feedContentState.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
CrossfadeIfEnabled(
|
||||
targetState = feedState,
|
||||
animationSpec = tween(durationMillis = 100),
|
||||
accountViewModel = accountViewModel,
|
||||
) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> onEmpty()
|
||||
is FeedState.FeedError -> onError(state.errorMessage)
|
||||
is FeedState.Loaded -> onLoaded(state)
|
||||
is FeedState.Loading -> onLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun FeedEmpty(onRefresh: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(stringRes(R.string.feed_is_empty))
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
OutlinedButton(onClick = onRefresh) { Text(text = stringRes(R.string.refresh)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
|
||||
@Composable
|
||||
fun FeedError(
|
||||
errorMessage: String,
|
||||
onRefresh: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxHeight().fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("${stringRes(R.string.error_loading_replies)} $errorMessage")
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Button(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
onClick = onRefresh,
|
||||
) {
|
||||
Text(text = stringRes(R.string.try_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FeedLoaded(
|
||||
loaded: FeedState.Loaded,
|
||||
listState: LazyListState,
|
||||
routeForLastRead: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = FeedPadding,
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(items.list, key = { _, item -> item.idHex }) { _, item ->
|
||||
Row(Modifier.fillMaxWidth().animateItemPlacement()) {
|
||||
NoteCompose(
|
||||
item,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = false,
|
||||
isHiddenFeed = items.showHidden,
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = DividerThickness,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,18 +18,19 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Stable
|
||||
sealed class FeedState {
|
||||
object Loading : FeedState()
|
||||
|
||||
class Loaded(
|
||||
val feed: MutableState<ImmutableList<Note>>,
|
||||
val showHidden: MutableState<Boolean>,
|
||||
val feed: MutableStateFlow<LoadedFeedState<Note>>,
|
||||
) : FeedState()
|
||||
|
||||
object Empty : FeedState()
|
||||
@@ -38,3 +39,9 @@ sealed class FeedState {
|
||||
val errorMessage: String,
|
||||
) : FeedState()
|
||||
}
|
||||
|
||||
@Stable
|
||||
class LoadedFeedState<T>(
|
||||
val list: ImmutableList<T>,
|
||||
val showHidden: Boolean,
|
||||
)
|
||||
@@ -18,21 +18,8 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.events.zaps
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
import com.vitorpamplona.quartz.events.LnZapEventInterface
|
||||
|
||||
object UserZaps {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
|
||||
if (zaps == null) return emptyList()
|
||||
|
||||
return (
|
||||
zaps
|
||||
.mapNotNull { entry -> entry.value?.let { ZapReqResponse(entry.key, it) } }
|
||||
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
|
||||
.reversed()
|
||||
)
|
||||
}
|
||||
interface InvalidatableContent {
|
||||
fun invalidateData(ignoreIfDoing: Boolean = false)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun LoadingFeed() {
|
||||
Column(
|
||||
Modifier.fillMaxHeight().fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(stringRes(R.string.loading_feed))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material3.pullrefresh.pullRefresh
|
||||
import androidx.compose.material3.pullrefresh.rememberPullRefreshState
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun RefresheableBox(
|
||||
invalidateableContent: InvalidatableContent,
|
||||
enablePullRefresh: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
RefresheableBox(
|
||||
enablePullRefresh = enablePullRefresh,
|
||||
onRefresh = { invalidateableContent.invalidateData() },
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RefresheableBox(
|
||||
enablePullRefresh: Boolean = true,
|
||||
onRefresh: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = {
|
||||
refreshing = true
|
||||
onRefresh()
|
||||
refreshing = false
|
||||
}
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
val modifier =
|
||||
if (enablePullRefresh) {
|
||||
Modifier.fillMaxSize().pullRefresh(pullRefreshState)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
content()
|
||||
|
||||
if (enablePullRefresh) {
|
||||
PullRefreshIndicator(
|
||||
refreshing = refreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2024 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedContentState
|
||||
|
||||
@Composable
|
||||
fun WatchScrollToTop(
|
||||
feedContentState: FeedContentState,
|
||||
listState: LazyListState,
|
||||
) {
|
||||
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop > 0 && feedContentState.scrolltoTopPending) {
|
||||
listState.scrollToItem(index = 0)
|
||||
feedContentState.sentToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchScrollToTop(
|
||||
feedContentState: FeedContentState,
|
||||
listState: LazyGridState,
|
||||
) {
|
||||
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop > 0 && feedContentState.scrolltoTopPending) {
|
||||
listState.scrollToItem(index = 0)
|
||||
feedContentState.sentToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WatchScrollToTop(
|
||||
feedContent: CardFeedContentState,
|
||||
listState: LazyListState,
|
||||
) {
|
||||
val scrollToTop by feedContent.scrollToTop.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop > 0 && feedContent.scrolltoTopPending) {
|
||||
listState.scrollToItem(index = 0)
|
||||
feedContent.sentToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun WatchScrollToTop(
|
||||
videoFeedContentState: FeedContentState,
|
||||
pagerState: PagerState,
|
||||
) {
|
||||
val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop > 0 && videoFeedContentState.scrolltoTopPending) {
|
||||
pagerState.scrollToPage(page = 0)
|
||||
videoFeedContentState.sentToTop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,21 +28,21 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NewItemsBubble
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
|
||||
@@ -108,31 +108,29 @@ fun ChatHeaderLayout(
|
||||
secondRow: @Composable RowScope.() -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(modifier = remember { Modifier.clickable(onClick = onClick) }) {
|
||||
Row(
|
||||
modifier = ChatHeadlineBorders,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onClick).padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Size55Modifier) { channelPicture() }
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Box(Size55Modifier) { channelPicture() }
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
firstRow()
|
||||
}
|
||||
firstRow()
|
||||
}
|
||||
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
Spacer(modifier = Height4dpModifier)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
secondRow()
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
secondRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ 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.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -87,7 +88,12 @@ fun AccountSwitchBottomSheet(
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
) {
|
||||
val accounts = LocalPreferences.allSavedAccounts()
|
||||
val accounts by
|
||||
produceState(initialValue = LocalPreferences.cachedAccounts()) {
|
||||
if (value == null) {
|
||||
value = LocalPreferences.allSavedAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -103,7 +109,7 @@ fun AccountSwitchBottomSheet(
|
||||
) {
|
||||
Text(stringRes(R.string.account_switch_select_account), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
accounts.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) }
|
||||
accounts?.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) }
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
|
||||
@@ -144,27 +144,35 @@ private fun RenderBottomMenu(
|
||||
)
|
||||
NavigationBar(tonalElevation = Size0dp) {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
HasNewItemsIcon(item, accountViewModel, navEntryState, nav)
|
||||
ObserveSelection(item, navEntryState) { selected ->
|
||||
HasNewItemsIcon(selected, item, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ObserveSelection(
|
||||
route: Route,
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
content: @Composable (Boolean) -> Unit,
|
||||
) {
|
||||
content(
|
||||
navEntryState.value
|
||||
?.destination
|
||||
?.route
|
||||
?.startsWith(route.base) ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.HasNewItemsIcon(
|
||||
selected: Boolean,
|
||||
route: Route,
|
||||
accountViewModel: AccountViewModel,
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
nav: (Route, Boolean) -> Unit,
|
||||
) {
|
||||
val selected =
|
||||
(
|
||||
navEntryState.value
|
||||
?.destination
|
||||
?.route
|
||||
?.indexOf(route.base) ?: -1
|
||||
) > -1
|
||||
|
||||
NavigationBarItem(
|
||||
alwaysShowLabel = false,
|
||||
icon = {
|
||||
|
||||
@@ -41,40 +41,28 @@ import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverChatFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverCommunityFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverLiveFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverMarketplaceFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrDiscoverNIP89FeedViewModel
|
||||
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.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
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreenByAuthor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NIP90ContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreenByAuthor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.CommunityScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.NIP90ContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
|
||||
import com.vitorpamplona.amethyst.ui.uriToRoute
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -82,18 +70,6 @@ import java.net.URLDecoder
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
videoFeedViewModel: NostrVideoFeedViewModel,
|
||||
discoverMarketplaceFeedViewModel: NostrDiscoverMarketplaceFeedViewModel,
|
||||
discoverNip89FeedViewModel: NostrDiscoverNIP89FeedViewModel,
|
||||
discoveryLiveFeedViewModel: NostrDiscoverLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel: NostrDiscoverCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel: NostrDiscoverChatFeedViewModel,
|
||||
notifFeedViewModel: NotificationViewModel,
|
||||
userReactionsStatsModel: UserReactionsViewModel,
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel,
|
||||
sharedPreferencesViewModel: SharedPreferencesViewModel,
|
||||
@@ -125,8 +101,8 @@ fun AppNavigation(
|
||||
val nip47 = it.arguments?.getString("nip47")
|
||||
|
||||
HomeScreen(
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
repliesFeedViewModel = repliesFeedViewModel,
|
||||
newThreadsFeedState = accountViewModel.feedStates.homeNewThreads,
|
||||
repliesFeedState = accountViewModel.feedStates.homeReplies,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
nip47 = nip47,
|
||||
@@ -148,8 +124,8 @@ fun AppNavigation(
|
||||
Route.Message.route,
|
||||
content = {
|
||||
ChatroomListScreen(
|
||||
knownFeedViewModel,
|
||||
newFeedViewModel,
|
||||
accountViewModel.feedStates.dmKnown,
|
||||
accountViewModel.feedStates.dmNew,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
@@ -162,7 +138,7 @@ fun AppNavigation(
|
||||
route.arguments,
|
||||
content = {
|
||||
VideoScreen(
|
||||
videoFeedView = videoFeedViewModel,
|
||||
videoFeedContentState = accountViewModel.feedStates.videoFeed,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -176,11 +152,11 @@ fun AppNavigation(
|
||||
route.arguments,
|
||||
content = {
|
||||
DiscoverScreen(
|
||||
discoveryContentNIP89FeedViewModel = discoverNip89FeedViewModel,
|
||||
discoveryMarketplaceFeedViewModel = discoverMarketplaceFeedViewModel,
|
||||
discoveryLiveFeedViewModel = discoveryLiveFeedViewModel,
|
||||
discoveryCommunityFeedViewModel = discoveryCommunityFeedViewModel,
|
||||
discoveryChatFeedViewModel = discoveryChatFeedViewModel,
|
||||
discoveryContentNIP89FeedContentState = accountViewModel.feedStates.discoverDVMs,
|
||||
discoveryMarketplaceFeedContentState = accountViewModel.feedStates.discoverMarketplace,
|
||||
discoveryLiveFeedContentState = accountViewModel.feedStates.discoverLive,
|
||||
discoveryCommunityFeedContentState = accountViewModel.feedStates.discoverCommunities,
|
||||
discoveryChatFeedContentState = accountViewModel.feedStates.discoverPublicChats,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
@@ -207,8 +183,8 @@ fun AppNavigation(
|
||||
route.arguments,
|
||||
content = {
|
||||
NotificationScreen(
|
||||
notifFeedViewModel = notifFeedViewModel,
|
||||
userReactionsStatsModel = userReactionsStatsModel,
|
||||
notifFeedContentState = accountViewModel.feedStates.notifications,
|
||||
notifSummaryState = accountViewModel.feedStates.notificationSummary,
|
||||
sharedPreferencesViewModel = sharedPreferencesViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
|
||||
@@ -51,8 +51,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -69,20 +67,14 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.getSystemService
|
||||
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 coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
@@ -100,7 +92,6 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
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.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.AmethystIcon
|
||||
@@ -109,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.SearchIcon
|
||||
@@ -117,18 +107,28 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.LongCommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ShortCommunityHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.CodeName
|
||||
import com.vitorpamplona.amethyst.ui.screen.CodeNameType
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FollowListState
|
||||
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
|
||||
import com.vitorpamplona.amethyst.ui.screen.HashtagName
|
||||
import com.vitorpamplona.amethyst.ui.screen.Name
|
||||
import com.vitorpamplona.amethyst.ui.screen.PeopleListName
|
||||
import com.vitorpamplona.amethyst.ui.screen.ResourceName
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DislayGeoTagHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashActionOptions
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagActionOptions
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRoom
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRoomByAuthor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LongChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LongRoomHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.RoomNameOnlyDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShortChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadRoom
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadRoomByAuthor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LongChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LongRoomHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.RoomNameOnlyDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ShortChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
@@ -142,27 +142,11 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.relays.RelayPool
|
||||
import com.vitorpamplona.quartz.events.ChatroomKey
|
||||
import com.vitorpamplona.quartz.events.ContactListEvent
|
||||
import com.vitorpamplona.quartz.events.DeletionEvent
|
||||
import com.vitorpamplona.quartz.events.MuteListEvent
|
||||
import com.vitorpamplona.quartz.events.PeopleListEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
@Composable
|
||||
fun AppTopBar(
|
||||
followLists: FollowListViewModel,
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
openDrawer: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -184,14 +168,14 @@ fun AppTopBar(
|
||||
derivedStateOf { navEntryState.value?.arguments?.getString("id") }
|
||||
}
|
||||
|
||||
RenderTopRouteBar(currentRoute, id, followLists, openDrawer, accountViewModel, nav, navPopBack)
|
||||
RenderTopRouteBar(currentRoute, id, accountViewModel.feedStates.feedListOptions, openDrawer, accountViewModel, nav, navPopBack)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderTopRouteBar(
|
||||
currentRoute: String?,
|
||||
id: String?,
|
||||
followLists: FollowListViewModel,
|
||||
followLists: FollowListState,
|
||||
openDrawer: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
@@ -458,32 +442,34 @@ private fun ChannelTopBar(
|
||||
|
||||
@Composable
|
||||
fun StoriesTopBar(
|
||||
followLists: FollowListViewModel,
|
||||
followLists: FollowListState,
|
||||
openDrawer: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
GenericMainTopBar(openDrawer, accountViewModel, nav) {
|
||||
val list by accountViewModel.account.defaultStoriesFollowList.collectAsStateWithLifecycle()
|
||||
val list by accountViewModel.account.settings.defaultStoriesFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithRoutes(
|
||||
followListsModel = followLists,
|
||||
listName = list,
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultStoriesFollowList(listName.code)
|
||||
accountViewModel.account.settings.changeDefaultStoriesFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(
|
||||
followLists: FollowListViewModel,
|
||||
followLists: FollowListState,
|
||||
openDrawer: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
GenericMainTopBar(openDrawer, accountViewModel, nav) {
|
||||
val list by accountViewModel.account.defaultHomeFollowList.collectAsStateWithLifecycle()
|
||||
val list by accountViewModel.account.settings.defaultHomeFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithRoutes(
|
||||
followListsModel = followLists,
|
||||
@@ -492,7 +478,7 @@ fun HomeTopBar(
|
||||
if (listName.type == CodeNameType.ROUTE) {
|
||||
nav(listName.code)
|
||||
} else {
|
||||
accountViewModel.account.changeDefaultHomeFollowList(listName.code)
|
||||
accountViewModel.account.settings.changeDefaultHomeFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -500,38 +486,40 @@ fun HomeTopBar(
|
||||
|
||||
@Composable
|
||||
fun NotificationTopBar(
|
||||
followLists: FollowListViewModel,
|
||||
followLists: FollowListState,
|
||||
openDrawer: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
GenericMainTopBar(openDrawer, accountViewModel, nav) {
|
||||
val list by accountViewModel.account.defaultNotificationFollowList.collectAsStateWithLifecycle()
|
||||
val list by accountViewModel.account.settings.defaultNotificationFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithoutRoutes(
|
||||
followListsModel = followLists,
|
||||
listName = list,
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultNotificationFollowList(listName.code)
|
||||
accountViewModel.account.settings.changeDefaultNotificationFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DiscoveryTopBar(
|
||||
followLists: FollowListViewModel,
|
||||
followLists: FollowListState,
|
||||
openDrawer: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
GenericMainTopBar(openDrawer, accountViewModel, nav) {
|
||||
val list by accountViewModel.account.defaultDiscoveryFollowList.collectAsStateWithLifecycle()
|
||||
val list by accountViewModel.account.settings.defaultDiscoveryFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
FollowListWithoutRoutes(
|
||||
followListsModel = followLists,
|
||||
listName = list,
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code)
|
||||
accountViewModel.account.settings.changeDefaultDiscoveryFollowList(listName.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,7 +597,7 @@ private fun LoggedInUserPictureDrawer(
|
||||
|
||||
@Composable
|
||||
fun FollowListWithRoutes(
|
||||
followListsModel: FollowListViewModel,
|
||||
followListsModel: FollowListState,
|
||||
listName: String,
|
||||
onChange: (CodeName) -> Unit,
|
||||
) {
|
||||
@@ -624,7 +612,7 @@ fun FollowListWithRoutes(
|
||||
|
||||
@Composable
|
||||
fun FollowListWithoutRoutes(
|
||||
followListsModel: FollowListViewModel,
|
||||
followListsModel: FollowListState,
|
||||
listName: String,
|
||||
onChange: (CodeName) -> Unit,
|
||||
) {
|
||||
@@ -637,191 +625,6 @@ fun FollowListWithoutRoutes(
|
||||
)
|
||||
}
|
||||
|
||||
enum class CodeNameType {
|
||||
HARDCODED,
|
||||
PEOPLE_LIST,
|
||||
ROUTE,
|
||||
}
|
||||
|
||||
abstract class Name {
|
||||
abstract fun name(): String
|
||||
|
||||
open fun name(context: Context) = name()
|
||||
}
|
||||
|
||||
class GeoHashName(
|
||||
val geoHashTag: String,
|
||||
) : Name() {
|
||||
override fun name() = "/g/$geoHashTag"
|
||||
}
|
||||
|
||||
class HashtagName(
|
||||
val hashTag: String,
|
||||
) : Name() {
|
||||
override fun name() = "#$hashTag"
|
||||
}
|
||||
|
||||
class ResourceName(
|
||||
val resourceId: Int,
|
||||
) : Name() {
|
||||
override fun name() = " $resourceId " // Space to make sure it goes first
|
||||
|
||||
override fun name(context: Context) = stringRes(context, resourceId)
|
||||
}
|
||||
|
||||
class PeopleListName(
|
||||
val note: AddressableNote,
|
||||
) : Name() {
|
||||
override fun name() = (note.event as? PeopleListEvent)?.nameOrTitle() ?: note.dTag() ?: ""
|
||||
}
|
||||
|
||||
class CommunityName(
|
||||
val note: AddressableNote,
|
||||
) : Name() {
|
||||
override fun name() = "/n/${(note.dTag() ?: "")}"
|
||||
}
|
||||
|
||||
@Immutable data class CodeName(
|
||||
val code: String,
|
||||
val name: Name,
|
||||
val type: CodeNameType,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class FollowListViewModel(
|
||||
val account: Account,
|
||||
) : ViewModel() {
|
||||
val kind3Follow =
|
||||
CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows), CodeNameType.HARDCODED)
|
||||
val globalFollow =
|
||||
CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global), CodeNameType.HARDCODED)
|
||||
val muteListFollow =
|
||||
CodeName(
|
||||
MuteListEvent.blockListFor(account.userProfile().pubkeyHex),
|
||||
ResourceName(R.string.follow_list_mute_list),
|
||||
CodeNameType.HARDCODED,
|
||||
)
|
||||
val defaultLists = persistentListOf(kind3Follow, globalFollow, muteListFollow)
|
||||
|
||||
val livePeopleListsFlow: Flow<List<CodeName>> by lazy {
|
||||
flow {
|
||||
checkNotInMainThread()
|
||||
|
||||
emit(getPeopleLists())
|
||||
emitAll(livePeopleListsFlowObservable)
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeopleLists(): List<CodeName> =
|
||||
LocalCache.addressables
|
||||
.mapNotNull { _, addressableNote ->
|
||||
val event = (addressableNote.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (
|
||||
event != null &&
|
||||
event.pubKey == account.userProfile().pubkeyHex &&
|
||||
(event.hasAnyTaggedUser() || event.publicAndPrivateUserCache?.isNotEmpty() == true)
|
||||
) {
|
||||
CodeName(event.address().toTag(), PeopleListName(addressableNote), CodeNameType.PEOPLE_LIST)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.name.name() }
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val livePeopleListsFlowObservable: Flow<List<CodeName>> =
|
||||
LocalCache.live.newEventBundles.transformLatest { newNotes ->
|
||||
val hasNewList =
|
||||
newNotes.any {
|
||||
val noteEvent = it.event
|
||||
|
||||
noteEvent?.pubKey() == account.userProfile().pubkeyHex &&
|
||||
(
|
||||
(
|
||||
noteEvent is PeopleListEvent ||
|
||||
noteEvent is MuteListEvent ||
|
||||
noteEvent is ContactListEvent
|
||||
) ||
|
||||
(
|
||||
noteEvent is DeletionEvent &&
|
||||
(
|
||||
noteEvent.deleteEvents().any {
|
||||
LocalCache.getNoteIfExists(it)?.event is PeopleListEvent
|
||||
} ||
|
||||
noteEvent.deleteAddresses().any {
|
||||
it.kind == PeopleListEvent.KIND
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (hasNewList) {
|
||||
emit(getPeopleLists())
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveKind3FollowsFlow: Flow<List<CodeName>> =
|
||||
account.liveKind3FollowsFlow.transformLatest {
|
||||
checkNotInMainThread()
|
||||
|
||||
val communities =
|
||||
it.communities.mapNotNull {
|
||||
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
|
||||
CodeName(
|
||||
"Community/${communityNote.idHex}",
|
||||
CommunityName(communityNote),
|
||||
CodeNameType.ROUTE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val hashtags =
|
||||
it.hashtags.map {
|
||||
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
val geotags =
|
||||
it.geotags.map {
|
||||
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
|
||||
}
|
||||
|
||||
emit(
|
||||
(communities + hashtags + geotags).sortedBy { it.name.name() },
|
||||
)
|
||||
}
|
||||
|
||||
private val _kind3GlobalPeopleRoutes =
|
||||
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, myLiveKind3FollowsFlow, listOf(muteListFollow))
|
||||
.flatten()
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
|
||||
|
||||
private val _kind3GlobalPeople =
|
||||
combineTransform(livePeopleListsFlow, liveKind3FollowsFlow) { myLivePeopleListsFlow, myLiveKind3FollowsFlow ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(listOf(kind3Follow, globalFollow), myLivePeopleListsFlow, listOf(muteListFollow))
|
||||
.flatten()
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
val kind3GlobalPeople = _kind3GlobalPeople.flowOn(Dispatchers.IO).stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists)
|
||||
|
||||
class Factory(
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <FollowListViewModel : ViewModel> create(modelClass: Class<FollowListViewModel>): FollowListViewModel = FollowListViewModel(account) as FollowListViewModel
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTextSpinner(
|
||||
placeholderCode: String,
|
||||
@@ -1110,8 +913,7 @@ fun debugState(context: Context) {
|
||||
Log.d(
|
||||
"STATE DUMP",
|
||||
"Spam: " +
|
||||
LocalCache.antiSpam.recentMessages.size() +
|
||||
" / " + LocalCache.antiSpam.spamMessages.size(),
|
||||
LocalCache.antiSpam.spamMessages.size() + " / " + LocalCache.antiSpam.recentMessages.size(),
|
||||
)
|
||||
|
||||
Log.d(
|
||||
|
||||
@@ -446,10 +446,17 @@ fun ListContent(
|
||||
var editMediaServers by remember { mutableStateOf(false) }
|
||||
|
||||
var backupDialogOpen by remember { mutableStateOf(false) }
|
||||
var checked by remember { mutableStateOf(accountViewModel.account.proxy != null) }
|
||||
var checked by remember { mutableStateOf(accountViewModel.account.settings.proxy != null) }
|
||||
var disconnectTorDialog by remember { mutableStateOf(false) }
|
||||
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) }
|
||||
val proxyPort =
|
||||
remember {
|
||||
mutableStateOf(
|
||||
accountViewModel.account.settings.proxyPort
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(modifier) {
|
||||
@@ -507,7 +514,7 @@ fun ListContent(
|
||||
route = Route.BlockedUsers.route,
|
||||
)
|
||||
|
||||
accountViewModel.account.keyPair.privKey?.let {
|
||||
accountViewModel.account.settings.keyPair.privKey?.let {
|
||||
IconRow(
|
||||
title = stringRes(R.string.backup_keys),
|
||||
icon = R.drawable.ic_key,
|
||||
|
||||
@@ -46,8 +46,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
|
||||
import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
|
||||
|
||||
@@ -71,16 +71,16 @@ import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.EndedFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
|
||||
|
||||
@@ -108,11 +108,11 @@ fun FollowingIcon(modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArrowBackIcon() {
|
||||
fun ArrowBackIcon(tint: Color = MaterialTheme.colorScheme.grayText) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringRes(R.string.back),
|
||||
tint = MaterialTheme.colorScheme.grayText,
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.CachedGeoLocations
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -75,7 +75,11 @@ fun LoadDecryptedContentOrNull(
|
||||
) {
|
||||
val decryptedContent by
|
||||
produceState(initialValue = accountViewModel.cachedDecrypt(note), key1 = note.event?.id()) {
|
||||
accountViewModel.decrypt(note) { value = it }
|
||||
accountViewModel.decrypt(note) {
|
||||
if (value != it) {
|
||||
value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner(decryptedContent)
|
||||
@@ -177,7 +181,8 @@ fun LoadOts(
|
||||
(earliestDate as? GenericLoadable.Loaded)?.let {
|
||||
whenConfirmed(it.loaded)
|
||||
} ?: run {
|
||||
val pendingAttestations by accountViewModel.account.pendingAttestations.collectAsStateWithLifecycle()
|
||||
val pendingAttestations by accountViewModel.account.settings.pendingAttestations
|
||||
.collectAsStateWithLifecycle()
|
||||
val id = note.event?.id() ?: note.idHex
|
||||
|
||||
if (pendingAttestations[id] != null) {
|
||||
|
||||
@@ -39,8 +39,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
|
||||
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.MessageSetCard
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
||||
@@ -68,9 +68,9 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.authorRouteFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
|
||||
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
|
||||
@@ -141,8 +141,8 @@ fun MultiSetCompose(
|
||||
|
||||
NoteCompose(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = null,
|
||||
modifier = HalfTopPadding,
|
||||
routeForLastRead = null,
|
||||
isBoostedNote = true,
|
||||
isHiddenFeed = showHidden,
|
||||
quotesLeft = 1,
|
||||
|
||||
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList
|
||||
import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet
|
||||
import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EmptyState
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.JustVideoDisplay
|
||||
@@ -111,10 +112,12 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderReport
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.RenderChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.RenderChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
@@ -181,6 +184,8 @@ import com.vitorpamplona.quartz.events.RepostEvent
|
||||
import com.vitorpamplona.quartz.events.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentCommentEvent
|
||||
import com.vitorpamplona.quartz.events.TorrentEvent
|
||||
import com.vitorpamplona.quartz.events.VideoEvent
|
||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||
@@ -189,8 +194,8 @@ import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||
@Composable
|
||||
fun NoteCompose(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
routeForLastRead: String? = null,
|
||||
isBoostedNote: Boolean = false,
|
||||
isQuotedNote: Boolean = false,
|
||||
unPackReply: Boolean = true,
|
||||
@@ -216,8 +221,8 @@ fun NoteCompose(
|
||||
) { canPreview ->
|
||||
AcceptableNote(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = modifier,
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = isBoostedNote,
|
||||
isQuotedNote = isQuotedNote,
|
||||
unPackReply = unPackReply,
|
||||
@@ -235,8 +240,8 @@ fun NoteCompose(
|
||||
@Composable
|
||||
fun AcceptableNote(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
routeForLastRead: String? = null,
|
||||
isBoostedNote: Boolean = false,
|
||||
isQuotedNote: Boolean = false,
|
||||
unPackReply: Boolean = true,
|
||||
@@ -272,8 +277,8 @@ fun AcceptableNote(
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = modifier,
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = isBoostedNote,
|
||||
isQuotedNote = isQuotedNote,
|
||||
unPackReply = unPackReply,
|
||||
@@ -315,8 +320,8 @@ fun AcceptableNote(
|
||||
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
|
||||
CheckNewAndRenderNote(
|
||||
baseNote = baseNote,
|
||||
routeForLastRead = routeForLastRead,
|
||||
modifier = modifier,
|
||||
routeForLastRead = routeForLastRead,
|
||||
isBoostedNote = isBoostedNote,
|
||||
isQuotedNote = isQuotedNote,
|
||||
unPackReply = unPackReply,
|
||||
@@ -343,7 +348,7 @@ fun calculateBackgroundColor(
|
||||
val defaultBackgroundColor = MaterialTheme.colorScheme.background
|
||||
val newItemColor = MaterialTheme.colorScheme.newItemBackgroundColor
|
||||
return remember(createdAt) {
|
||||
mutableStateOf<Color>(
|
||||
mutableStateOf(
|
||||
if (routeForLastRead != null) {
|
||||
val isNew = accountViewModel.loadAndMarkAsRead(routeForLastRead, createdAt)
|
||||
|
||||
@@ -366,8 +371,8 @@ fun calculateBackgroundColor(
|
||||
@Composable
|
||||
private fun CheckNewAndRenderNote(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
routeForLastRead: String? = null,
|
||||
isBoostedNote: Boolean = false,
|
||||
isQuotedNote: Boolean = false,
|
||||
unPackReply: Boolean = true,
|
||||
@@ -504,10 +509,7 @@ fun InnerNoteWithReactions(
|
||||
|
||||
if (isNotRepost) {
|
||||
if (makeItShort) {
|
||||
if (isBoostedNote) {
|
||||
} else {
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
} else {
|
||||
ReactionsRow(
|
||||
baseNote = baseNote,
|
||||
@@ -594,8 +596,7 @@ private fun RenderNoteRow(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit,
|
||||
) {
|
||||
val noteEvent = baseNote.event
|
||||
when (noteEvent) {
|
||||
when (val noteEvent = baseNote.event) {
|
||||
is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav)
|
||||
is AudioTrackEvent -> RenderAudioTrack(baseNote, false, accountViewModel, nav)
|
||||
is AudioHeaderEvent -> RenderAudioHeader(baseNote, false, accountViewModel, nav)
|
||||
@@ -757,6 +758,27 @@ private fun RenderNoteRow(
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
is TorrentEvent ->
|
||||
RenderTorrent(
|
||||
baseNote,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
is TorrentCommentEvent ->
|
||||
RenderTorrentComment(
|
||||
baseNote,
|
||||
makeItShort,
|
||||
canPreview,
|
||||
quotesLeft,
|
||||
unPackReply,
|
||||
backgroundColor,
|
||||
editState,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
else -> {
|
||||
RenderTextEvent(
|
||||
baseNote,
|
||||
@@ -862,11 +884,11 @@ fun ReplyNoteComposition(
|
||||
) {
|
||||
NoteCompose(
|
||||
baseNote = replyingDirectlyTo,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = 0,
|
||||
modifier = MaterialTheme.colorScheme.replyModifier,
|
||||
isQuotedNote = true,
|
||||
unPackReply = false,
|
||||
makeItShort = true,
|
||||
quotesLeft = 0,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
@@ -921,12 +943,12 @@ fun DisplayOtsIfInOriginal(
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val editState = (editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.modificationToShow?.value
|
||||
val editNote = (editState.value as? GenericLoadable.Loaded<EditState>)?.loaded?.modificationToShow?.value
|
||||
|
||||
if (editState == null) {
|
||||
if (editNote == null) {
|
||||
DisplayOts(note = note, accountViewModel = accountViewModel)
|
||||
} else {
|
||||
DisplayOts(note = editState, accountViewModel = accountViewModel)
|
||||
DisplayOts(note = editNote, accountViewModel = accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,7 +1040,7 @@ fun observeEdits(
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<GenericLoadable<EditState>> {
|
||||
if (baseNote.event !is TextNoteEvent) {
|
||||
return remember { mutableStateOf(GenericLoadable.Empty<EditState>()) }
|
||||
return EmptyState
|
||||
}
|
||||
|
||||
val editState =
|
||||
@@ -1027,14 +1049,14 @@ fun observeEdits(
|
||||
mutableStateOf(
|
||||
if (cached != null) {
|
||||
if (cached.isEmpty()) {
|
||||
GenericLoadable.Empty<EditState>()
|
||||
GenericLoadable.Empty()
|
||||
} else {
|
||||
val state = EditState()
|
||||
state.updateModifications(cached)
|
||||
GenericLoadable.Loaded<EditState>(state)
|
||||
GenericLoadable.Loaded(state)
|
||||
}
|
||||
} else {
|
||||
GenericLoadable.Loading<EditState>()
|
||||
GenericLoadable.Loading()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1046,7 +1068,7 @@ fun observeEdits(
|
||||
accountViewModel.findModificationEventsForNote(it) { newModifications ->
|
||||
if (newModifications.isEmpty()) {
|
||||
if (editState.value !is GenericLoadable.Empty) {
|
||||
editState.value = GenericLoadable.Empty<EditState>()
|
||||
editState.value = GenericLoadable.Empty()
|
||||
}
|
||||
} else {
|
||||
if (editState.value is GenericLoadable.Loaded) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user