Compare commits

...

140 Commits

Author SHA1 Message Date
Vitor Pamplona
0b2a9e67c0 v0.90.3 2024-08-27 17:44:44 -04:00
Vitor Pamplona
931081c5e7 Exposes decryption crashing because it is used by Notification Services to check which account can decrypt a GiftWrap. 2024-08-27 17:43:12 -04:00
Vitor Pamplona
f7237b5666 Improves logs on new Notifications 2024-08-27 17:42:18 -04:00
Vitor Pamplona
d851637ea2 Fixes byte to string decoder for UnifiedPush 2024-08-27 17:41:56 -04:00
Vitor Pamplona
2d39b4edfe Only register for endpoints if it has changed from the one in memory 2024-08-27 17:41:37 -04:00
Vitor Pamplona
5001c5bef8 There is no need to extend onReceive 2024-08-27 17:40:53 -04:00
Vitor Pamplona
6ac1db9fc8 correctly passes the endpoint to remove it 2024-08-27 17:40:35 -04:00
Vitor Pamplona
52f33d79e0 Correctly registering the distributor at startup 2024-08-27 17:38:14 -04:00
Vitor Pamplona
1a9d91cdc0 Fixes correct saving of UnifiedPush endpoints 2024-08-27 17:37:53 -04:00
Vitor Pamplona
2f444e6ec5 Moves ChatMessage components to the chatroom package 2024-08-27 16:03:15 -04:00
Vitor Pamplona
4665c488d5 Adds a small border between the author and the message of a chat bubble 2024-08-27 15:57:56 -04:00
Vitor Pamplona
42ab826d02 v0.90.2 2024-08-27 15:01:50 -04:00
Vitor Pamplona
80a3f7424b fixes conversation tab 2024-08-27 14:52:22 -04:00
Vitor Pamplona
1bd3e347b3 Updates media 3 dependencies. 2024-08-27 14:51:30 -04:00
Vitor Pamplona
a332126e71 Quick fix for to not show error messages on Zap the Devs 2024-08-27 14:33:57 -04:00
Vitor Pamplona
d97abe6af9 v0.90.0 2024-08-27 13:48:36 -04:00
Vitor Pamplona
62d341bd8a Publishing new user's events 2024-08-27 11:49:43 -04:00
Vitor Pamplona
a95d2e903e Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeDrawer.kt
2024-08-27 11:04:36 -04:00
Vitor Pamplona
2b5e62aaf7 - Improves rendering of the QR Code elements
- Makes the QR bigger
- Moves QR Code to rounder corners
- Moves QR Screen to use the arrow icon instead of the close button
2024-08-27 11:04:13 -04:00
Vitor Pamplona
1c3e82913f Merge pull request #1031 from greenart7c3/main
Use white background for qrcodes
2024-08-27 11:02:33 -04:00
Vitor Pamplona
ceaa20fabf Removes galleries live data 2024-08-27 08:34:25 -04:00
Vitor Pamplona
1bbabb747e Deleting Bundled updates for Account. 2024-08-26 19:12:39 -04:00
Vitor Pamplona
914c0a2926 Making sure scopes are cancelled for accountViewModel notification dot updates. 2024-08-26 19:12:28 -04:00
Vitor Pamplona
4b249156f9 Makes sure the accountViewModel is deleted when the user switches the screen, logs off or kills the activity. 2024-08-26 18:22:34 -04:00
Vitor Pamplona
7b8c7a1a78 Simplifies flow collection for new events. 2024-08-26 18:22:12 -04:00
Vitor Pamplona
66ed26dcea Finishes the migration of the service manager to the Application class 2024-08-26 18:21:53 -04:00
Vitor Pamplona
356740479c Moves to hold the feed itself in a stateflow 2024-08-26 17:48:27 -04:00
Vitor Pamplona
c88dded01b Improves stability of composables 2024-08-26 16:45:07 -04:00
Vitor Pamplona
7c974bc8ef adds a directory for metrics as well 2024-08-26 15:27:04 -04:00
Vitor Pamplona
efbc3e3e78 removes unecessary logs 2024-08-26 14:57:16 -04:00
Vitor Pamplona
fac803e208 Merge pull request #1032 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-26 14:56:04 -04:00
Crowdin Bot
484a3d6b32 New Crowdin translations by GitHub Action 2024-08-26 18:44:35 +00:00
Vitor Pamplona
4e3b6d0299 - Adds a sync Signer to facilitate library
- Separates Account actions from Account state in two objects
- Changes Startup procedures to start with Account state and not the full account object
- Moves scope for flows in Account from an Application-wide scope to ViewModel scope
- Removes all LiveData objects from Account in favor of flows from the state object
- Migrates settings saving logic to flows
- Migrates PushNotification services to work without Account and only Account Settings.
- Migrates the spam filter from LiveData to Flows
- Adds Default lists for NIP-65 inbox and outbox relays
- Adds Default lists for Search relays
- Adds local backup for UserMetadata objects
- Adds local backup for Mute lists
- Adds local backup for NIP-65 relays
- Adds local backup for DM Relays
- Adds local backup for private home relays
- Rewrites state flows initializers to avoid inconsistent startups
2024-08-26 14:42:55 -04:00
Vitor Pamplona
f3161ada8d Solves crash when opening the Relay screen with empty urls as relays. 2024-08-26 11:13:10 -04:00
greenart7c3
5c9b84476b Use white background for qrcodes 2024-08-26 07:21:19 -03:00
Vitor Pamplona
24ea0cabda Updates benchmark, composeBOM and firebaseBOM to the latest versions 2024-08-23 08:35:44 -04:00
Vitor Pamplona
8b617fbd60 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-22 17:27:04 -04:00
Vitor Pamplona
34149e6ce2 Merge pull request #1030 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-22 17:13:16 -04:00
Vitor Pamplona
315a662c10 Improve horizontal padding of the chat messages 2024-08-22 17:12:46 -04:00
Crowdin Bot
c373843e0f New Crowdin translations by GitHub Action 2024-08-22 21:03:22 +00:00
Vitor Pamplona
448ea796ef Improves caching of encrypted DMs:
- Migrates caching of decrypted value outside of the Event class
- Removes encrypted parts of NIP-17 from the cache
- Removes old NIP-04 messages from the cache
- Avoids deleting new NIP-17 plain text chats from memory
2024-08-22 17:01:37 -04:00
Vitor Pamplona
71b111ce8b Fixes the download of 1000s of NIP-65 relay lists because some relays consider empty lists as null and return everything. 2024-08-22 12:24:45 -04:00
Vitor Pamplona
ae686671ba Minimizing the number of filters for group chat metadata 2024-08-22 09:55:28 -04:00
Vitor Pamplona
6c45bb1ca1 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-21 20:32:06 -04:00
Vitor Pamplona
a05ad6e686 Adds support for NIP-35 torrents and their comments 2024-08-21 20:31:59 -04:00
Vitor Pamplona
485f7becd4 Merge pull request #1029 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 19:18:29 -04:00
Vitor Pamplona
83f1e523ea Creates links to njump when events can't be found on Amethyst 2024-08-21 17:05:14 -04:00
Vitor Pamplona
a8a3c94115 clear flows before removing notes from the local cache 2024-08-21 16:32:11 -04:00
Vitor Pamplona
4d21857adf moves the processing of nip19 links to the default thread 2024-08-21 16:31:57 -04:00
Crowdin Bot
0e7f41efe8 New Crowdin translations by GitHub Action 2024-08-21 20:09:17 +00:00
Vitor Pamplona
8d9bf60dba Merge pull request #1028 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 16:08:51 -04:00
Vitor Pamplona
7c67924914 fixes imports 2024-08-21 16:07:42 -04:00
Crowdin Bot
7626839037 New Crowdin translations by GitHub Action 2024-08-21 19:59:14 +00:00
Vitor Pamplona
23825ceed5 Avoids bottom nav recompositions 2024-08-21 15:57:45 -04:00
Vitor Pamplona
8d8016e01b Simplifies nav bar selected calculations 2024-08-21 15:51:01 -04:00
Vitor Pamplona
6668e5a178 Moves accountViewModelMock to a more appropriate file 2024-08-21 15:14:28 -04:00
Vitor Pamplona
7d6bef9576 Moves heavy computations away from IO 2024-08-21 15:00:13 -04:00
Vitor Pamplona
1aea7fbd03 Avoids remembering edit lists that will never exist. 2024-08-21 14:58:01 -04:00
Vitor Pamplona
ef96d296f8 avoids double update to the viewmodel 2024-08-21 14:53:35 -04:00
Vitor Pamplona
d4da58e14a Improves logs when the UserMetadata cannot be parsed 2024-08-21 14:38:17 -04:00
Vitor Pamplona
f646866192 moves markasread from io to default 2024-08-21 09:20:43 -04:00
Vitor Pamplona
e0849b12e7 Merge pull request #1027 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-21 07:55:58 -04:00
Crowdin Bot
1271343582 New Crowdin translations by GitHub Action 2024-08-21 11:52:51 +00:00
Vitor Pamplona
d61d14ea75 Merge pull request #1026 from davotoula/chage-twitter-to-x
Change twitter proof to use x.com
2024-08-21 07:51:04 -04:00
David Kaspar
668d604aa9 use original twitter colour in x.xml because I can't get the theme switching working 2024-08-21 11:56:45 +01:00
Vitor Pamplona
6df2c3a0cf improving speed of chatlist rendering 2024-08-20 15:23:15 -04:00
Vitor Pamplona
b0c33808e6 Adds support for MOD reports 2024-08-20 15:07:28 -04:00
Vitor Pamplona
7bd3e0beab Improved padding and spacing for chat messages 2024-08-20 13:05:44 -04:00
Vitor Pamplona
3a70cfb9e2 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-20 09:14:56 -04:00
Vitor Pamplona
17c758f8f8 Displays PoW of commitment if present. 2024-08-20 09:14:47 -04:00
Vitor Pamplona
7cb03b939f Merge pull request #1022 from KotlinGeekDev/fix-relay-set
Fix relay set event.
2024-08-20 08:57:34 -04:00
KotlinGeekDev
481ef91cb1 Change relay set kind to be NIP-51 consistent. 2024-08-20 12:13:42 +01:00
Vitor Pamplona
1183aa8d11 Changes fdroid push registration to re-register even if the saved distributor was already selected. 2024-08-19 17:58:50 -04:00
Vitor Pamplona
4023261400 Merge pull request #1020 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-19 12:07:35 -04:00
Crowdin Bot
f1433504e9 New Crowdin translations by GitHub Action 2024-08-19 16:00:50 +00:00
Vitor Pamplona
989cbb35d3 Disabling landscape rotation on full screen due to portrait videos. 2024-08-19 11:58:50 -04:00
Vitor Pamplona
9262b90096 Merge pull request #1019 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-19 11:40:22 -04:00
Crowdin Bot
ab3b7685cf New Crowdin translations by GitHub Action 2024-08-19 15:40:02 +00:00
Vitor Pamplona
40865ac97d Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-19 11:36:39 -04:00
Vitor Pamplona
ded67964b0 Merge pull request #1018 from KotlinGeekDev/fullscreen-video-behavior
Change tap to fullscreen video behavior for livestreams.
2024-08-19 11:34:47 -04:00
Vitor Pamplona
e991707ddf updates runner 2024-08-19 11:32:04 -04:00
KotlinGeekDev
1b429475aa Remove the manual testing/debugging code. Remove the LocalConfiguration API in favour of DeviceUtils.isLandscapeMetric. 2024-08-17 18:51:07 +01:00
KotlinGeekDev
9613afb7ea Fix ZoomableImageDialog. isFoldableOrLarge already takes into account the orientation. 2024-08-17 17:07:43 +01:00
KotlinGeekDev
26a12f2dbe Remove the external config method and use the built-in Compose API.
Add some documentation.
2024-08-17 17:03:07 +01:00
KotlinGeekDev
23da02f932 Merge remote-tracking branch 'origin/fullscreen-video-behavior' into fullscreen-video-behavior 2024-08-17 15:19:47 +01:00
KotlinGeekDev
59ff9007a7 Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere).
Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works.
2024-08-17 14:18:48 +00:00
KotlinGeekDev
0f817dbf00 Put device in landscape mode when fullscreen button is pressed. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
7ab6322886 Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
223c0c1263 Initial implementation for auto-fullscreen on rotation to landscape mode. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
d3a639a2ee Consistent naming and behavior for the video player controls. 2024-08-17 14:18:48 +00:00
KotlinGeekDev
2fb1cbe11f Move DeviceUtils closer to call-site. Copy code from Material3-Adaptive library(to avoid experimental annotation propagation everywhere).
Implement windowIsLarge to take care of device type handling(sort-of). Add some funky debug methods to make sure windowIsLarge works.
2024-08-17 15:14:24 +01:00
Vitor Pamplona
29c5b45764 Moves the default zap type to a state flow and avoids passing on to the screen when using the default value. 2024-08-16 17:45:53 -04:00
Vitor Pamplona
ec83ad9d20 Removing unecessary livedata objects for translation services 2024-08-16 17:10:02 -04:00
Vitor Pamplona
239f973e89 Moves lastread routes to mutableStateFlow 2024-08-16 16:42:00 -04:00
Vitor Pamplona
6c81fbbcf9 Fixes crash when the relay url of a user is duplicated. 2024-08-16 16:00:03 -04:00
Vitor Pamplona
75e45f37b2 Migrating livedata to flow for contact list updates of the user. 2024-08-16 15:59:43 -04:00
Vitor Pamplona
0bff3462b1 Migrates the pubkeyHex from keypair to the signer. 2024-08-16 15:58:23 -04:00
Vitor Pamplona
990db68a4d Fixes spacing of short quotes 2024-08-16 13:12:05 -04:00
Vitor Pamplona
4343c744db Displaying stacks in error messages if they are present 2024-08-16 11:14:34 -04:00
Vitor Pamplona
5cb6f97178 linting cleanup 2024-08-16 10:48:30 -04:00
KotlinGeekDev
a8efc9d348 Put device in landscape mode when fullscreen button is pressed. 2024-08-16 14:53:40 +01:00
Vitor Pamplona
e0b667fd2d Fixes order for the notification feed after refactoring 2024-08-16 09:41:38 -04:00
Vitor Pamplona
a8ad2437fe refactors the Watch feed to scroll to the top 2024-08-16 09:39:15 -04:00
KotlinGeekDev
83018e621e Introduce DeviceUtils, and add functions for changing orientation, and checking for Landscape mode. 2024-08-16 14:19:55 +01:00
Vitor Pamplona
68b15ba483 Refactors old user zap object. 2024-08-15 17:08:15 -04:00
Vitor Pamplona
7513036d47 Refactors sorting of Notifications 2024-08-15 16:16:36 -04:00
Vitor Pamplona
c82c594926 Fixes slow down when the last message on chat is a base64 image. 2024-08-15 16:02:28 -04:00
Vitor Pamplona
bd18d4b4b2 Adjusts Outbox filter as soon as a new NIP-65 relay list shows up 2024-08-15 15:17:41 -04:00
Vitor Pamplona
2f5b99a1c7 Merge pull request #1016 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-15 15:06:50 -04:00
Crowdin Bot
1be6e0ec0f New Crowdin translations by GitHub Action 2024-08-15 18:58:39 +00:00
Vitor Pamplona
f43c0e5cf5 Moves error messages from uploading procedures to the full error dialog instead of just a toast. 2024-08-15 14:55:25 -04:00
Vitor Pamplona
d6d8719c8d Fixes the centralization of the "and more" part of relay recommendations 2024-08-14 18:50:26 -04:00
Vitor Pamplona
7478899388 Fixes miscaching flows of the relay lists from follows. 2024-08-14 18:03:25 -04:00
Vitor Pamplona
6b193894bd Fixes miscache of hashtag following button 2024-08-14 17:16:02 -04:00
Vitor Pamplona
69f5371ab8 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-08-14 17:15:36 -04:00
Vitor Pamplona
526e3db3c1 Adds more information to when error messages are not available in the relay stats. 2024-08-14 15:50:05 -04:00
Vitor Pamplona
3c9e8854eb Merge pull request #1015 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-08-14 15:14:42 -04:00
Crowdin Bot
7ee0aa47a9 New Crowdin translations by GitHub Action 2024-08-14 19:07:04 +00:00
Vitor Pamplona
5fbf0462fa No need to check for the MainThread in every updateFeedWith 2024-08-14 15:04:51 -04:00
Vitor Pamplona
eda5bc50e6 Adds a destroy method to FollowList state for consistency 2024-08-14 10:45:57 -04:00
David Kaspar
d6d26d466b clean up code in MetadataEvent.kt
update x drawable
2024-08-14 08:11:28 +01:00
Vitor Pamplona
6acd3ca091 Moves follow list states to the AccountViewModel 2024-08-13 19:35:04 -04:00
Vitor Pamplona
c64d179f7f Migrates Notification Summary to the new state model 2024-08-13 18:56:53 -04:00
Vitor Pamplona
32bc8fe667 Fixes f-droid flavor after refactoring 2024-08-13 18:35:31 -04:00
Vitor Pamplona
0a8c792d53 Moves the notification screen to the new state model instead of viewModels 2024-08-13 18:32:20 -04:00
Vitor Pamplona
c32dc61ea1 Refactoring Moving feed status from ViewModel to State objects 2024-08-13 17:32:15 -04:00
Vitor Pamplona
3af2cb52cc adds a prune for giftwrapped messages 2024-08-13 15:11:15 -04:00
Vitor Pamplona
ddb1d7ff82 Adds context to highlight events 2024-08-13 15:08:32 -04:00
KotlinGeekDev
fd0523085d Initial implementation for auto-fullscreen on rotation to landscape mode. 2024-08-13 10:42:49 +01:00
Vitor Pamplona
1f9fbb0c86 Revers letter spacing in inline code on Markdow 2024-08-12 15:58:17 -04:00
Vitor Pamplona
5b95a1c177 Improved logs when clearing the cache for DMs 2024-08-12 15:55:25 -04:00
Vitor Pamplona
2e144ebdf8 Adds previews to test markdown rendering 2024-08-12 15:55:01 -04:00
Vitor Pamplona
ac5f550ff3 Fixes heading sizes on markdown 2024-08-12 15:54:20 -04:00
Vitor Pamplona
9af123b709 Improves the look of inlinde code in markdown 2024-08-12 15:53:45 -04:00
Vitor Pamplona
75efd2b372 Improves badge display 2024-08-12 11:18:22 -04:00
Vitor Pamplona
2cface9ac9 Inverts spam logs 2024-08-12 11:11:23 -04:00
Vitor Pamplona
58491a97ac Merge pull request #1012 from greenart7c3/main
show only 3 users in the recommended relays section
2024-08-12 09:07:21 -04:00
greenart7c3
7a86d78c50 show only 3 users in the recommended relays section 2024-08-12 09:05:08 -03:00
David Kaspar
97a00ff83c First pass updating Twitter verification to X 2024-08-12 11:07:48 +02:00
KotlinGeekDev
8adddd0bdb Consistent naming and behavior for the video player controls. 2024-08-09 15:26:42 +01:00
234 changed files with 8016 additions and 4569 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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)
}
}
}

View File

@@ -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 }

View File

@@ -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)"
}

View File

@@ -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()

View File

@@ -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
}
}
}

View File

@@ -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 =

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()
}
}

View File

@@ -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?,

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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),

View File

@@ -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(),

View File

@@ -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,
)

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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)

View File

@@ -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()

View File

@@ -78,7 +78,7 @@ class MainActivity : AppCompatActivity() {
setContent {
val sharedPreferencesViewModel = prepareSharedViewModel(act = this)
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel, serviceManager = Amethyst.instance.serviceManager)
AppScreen(sharedPreferencesViewModel = sharedPreferencesViewModel)
}
}

View File

@@ -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 = {

View File

@@ -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))
},
)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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))
},
)
}

View File

@@ -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,

View File

@@ -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))
},
)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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,
)
}
}
}
}

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,
)
}

View File

@@ -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 =

View File

@@ -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)
}
},
)

View File

@@ -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 {

View File

@@ -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) }

View File

@@ -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(

View File

@@ -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,
)
}
}

View File

@@ -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)
}
}

View File

@@ -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" +
"![landscape](https://primal.b-cdn.net/media-cache?s=o&a=1&u=https%3A%2F%2Fm.primal.net%2FHUmC.jpg)\n" +
"\n" +
"and here is one in the middle of the text ![rose](https://primal.b-cdn.net/media-cache?s=o&a=1&u=https%3A%2F%2Fm.primal.net%2FHUmM.jpg) 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,
)
}
}

View File

@@ -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
}
}
}
}

View File

@@ -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() }

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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()
)
}
}
}

View File

@@ -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,
)

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)) }
}
}

View File

@@ -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))
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)

View File

@@ -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)
}

View File

@@ -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))
}
}

View File

@@ -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),
)
}
}
}

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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,

View File

@@ -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