Compare commits

...

123 Commits

Author SHA1 Message Date
Vitor Pamplona
a38087a9b9 v0.14.2 2023-02-10 09:13:45 -05:00
Vitor Pamplona
29dc677148 Allows the user to leave and join their own groups 2023-02-10 09:13:01 -05:00
Vitor Pamplona
50bc55e84b More Performant UpdateFollows 2023-02-09 17:52:18 -05:00
Vitor Pamplona
ae8d39df92 Download only metadata in the Search for authors 2023-02-09 16:42:30 -05:00
Vitor Pamplona
09919e99b9 Fixes: https://github.com/vitorpamplona/amethyst/issues/91 2023-02-09 15:25:32 -05:00
Vitor Pamplona
5069c30a4a Fixes https://github.com/vitorpamplona/amethyst/issues/103 Doesn't do anything if the content in the field is not a URI. 2023-02-09 15:11:20 -05:00
Vitor Pamplona
4097263c12 Activating Proguard 2023-02-09 15:02:13 -05:00
Vitor Pamplona
88e91e37fa Fixing the height of the nav bar icon 2023-02-09 14:56:54 -05:00
Vitor Pamplona
a003e111eb Fixing QR Code screen for smaller screens. 2023-02-09 14:45:00 -05:00
Vitor Pamplona
4cde2fe8e6 Refactoring of RelaySetupInfo away from ModelView 2023-02-09 14:25:58 -05:00
Vitor Pamplona
ffe5e79474 Making sure once proguard is used, it doesn't break json serialization 2023-02-09 14:24:56 -05:00
Vitor Pamplona
19a9e7a949 language-id seems necessary for some phones. unsure why 2023-02-09 14:24:28 -05:00
Vitor Pamplona
757e566774 Fixes permissions issue in Image Upload 2023-02-09 11:22:49 -05:00
Vitor Pamplona
7fff535b74 v0.14.1:
- Save image
- LNURL edit in the profile
- Bugfix crashing the Lightning Tips with non numeric values in the amount.
2023-02-09 10:42:08 -05:00
Vitor Pamplona
65ef34511a Adding LNURL setup in the User Metadata 2023-02-09 10:36:43 -05:00
Vitor Pamplona
644c6b4d76 Merge remote-tracking branch 'origin/main' 2023-02-09 09:47:34 -05:00
Vitor Pamplona
dec17e162e Avoids crashing on a non-numeric amount in Lightning Tips 2023-02-09 09:47:23 -05:00
Vitor Pamplona
df49c63936 Merge pull request #99 from Radiokot/feature/save_images
Add Save option to the image view screen
2023-02-09 09:22:46 -05:00
Vitor Pamplona
f0d4ad38a5 Merge pull request #98 from bleetube/offchain.pub
Public relay name change
2023-02-09 09:22:27 -05:00
Vitor Pamplona
7cb146bb50 v0.14.0 2023-02-08 18:27:23 -05:00
Vitor Pamplona
5195ddf63e Sending Lightning Tips 2023-02-08 18:26:49 -05:00
Oleg Koretsky
3fde6b4b1f Add Save option to the image view screen 2023-02-08 22:06:12 +02:00
Brian Lee
5102a15532 Public relay name change 2023-02-08 12:05:56 -08:00
Vitor Pamplona
00981ef15c Refactoring RichTextViewer file 2023-02-08 14:02:42 -05:00
Vitor Pamplona
f2badce3b8 v0.13.3 2023-02-08 13:55:25 -05:00
Vitor Pamplona
e9f0fb82e9 New options to choose which language to translate to and which languages to block translations from 2023-02-08 11:57:36 -05:00
Vitor Pamplona
abf217b71d Fixes: https://github.com/vitorpamplona/amethyst/issues/95 2023-02-07 18:03:48 -05:00
Vitor Pamplona
9faf6a2fbc Merge pull request #94 from Radiokot/fix/ui_issues
Fix some UI issues
2023-02-07 15:59:26 -05:00
Vitor Pamplona
f61b894ddf Merge branch 'main' into fix/ui_issues 2023-02-07 15:59:19 -05:00
Vitor Pamplona
b0dda37023 Merge pull request #90 from greenart7c3/main
fix dropDown color on light theme
2023-02-07 15:56:17 -05:00
Oleg Koretsky
d600d1c3e5 Fix invisible user profile menu button (white on white) 2023-02-07 12:01:23 +02:00
Oleg Koretsky
79947524cc Fix login screen buttons crop when the keyboard is opened 2023-02-07 11:53:46 +02:00
Oleg Koretsky
2e49bbc606 Make post translation label wrap words properly, fix #86 2023-02-07 11:20:45 +02:00
Vitor Pamplona
c3f0f7c59c Bugfix 2023-02-06 18:53:43 -05:00
Vitor Pamplona
387c5cdffa v0.13.2 2023-02-06 18:39:45 -05:00
Vitor Pamplona
660f11cfc4 Adjustments to video playback 2023-02-06 18:38:59 -05:00
Vitor Pamplona
2a6a6f459c Using the explore button to present information about the relay 2023-02-06 18:15:54 -05:00
Vitor Pamplona
12c330f8f6 Correctly handling Atomic Boolean 2023-02-06 18:15:37 -05:00
Vitor Pamplona
d252297ba4 List relays in Private DMs 2023-02-06 18:15:18 -05:00
Vitor Pamplona
ba5eca648f Adds individual relay information per chat message. 2023-02-06 17:51:55 -05:00
Vitor Pamplona
ca3ed88b78 Tracks relays used per Note 2023-02-06 17:16:27 -05:00
Vitor Pamplona
433881fa21 Adjusts final relays for release 2023-02-06 17:16:07 -05:00
greenart7c3
3a7228712c fix dropDown color on light theme 2023-02-06 17:30:35 -03:00
Vitor Pamplona
7b3a23825f Updating Semisol's relay. 2023-02-06 14:50:59 -05:00
Vitor Pamplona
2819c7efcf Shipping Amethyst with Paid relays for Global and Public Chats. 2023-02-06 14:34:44 -05:00
Vitor Pamplona
e645029ddf Crops Posts in 350 chars. 2023-02-06 14:34:21 -05:00
Vitor Pamplona
f160a024aa Since refresh is mostly a CPU/Memory process and not really IO, Default is recommended. 2023-02-06 12:29:17 -05:00
Vitor Pamplona
074cac3021 Removes unnecessary elvis operator 2023-02-06 12:28:27 -05:00
Vitor Pamplona
e7c6292cdc Fixing test case after https://github.com/vitorpamplona/amethyst/pull/87 2023-02-06 12:18:52 -05:00
Vitor Pamplona
6daeb487d3 Fixing unassignable variable from PR https://github.com/vitorpamplona/amethyst/pull/88 2023-02-06 11:30:57 -05:00
Vitor Pamplona
2e2e54d642 Merge pull request #87 from greenart7c3/main
add debug configuration
2023-02-06 11:19:25 -05:00
Vitor Pamplona
d2ce108bf3 Merge pull request #88 from middlingphys/remove_trailing_slash
remove trailing slash from relay URI
2023-02-06 11:18:48 -05:00
Vitor Pamplona
a6dcf64f04 Refactoring Channel -> Subscription 2023-02-06 10:56:10 -05:00
middlingphys
a2f85dd060 remove trailing slash from addedWSS 2023-02-06 23:25:45 +09:00
greenart7c3
7c8c79819a add debug configuration 2023-02-06 11:15:18 -03:00
Vitor Pamplona
a5f156ec49 Merge remote-tracking branch 'origin/main' 2023-02-06 08:38:48 -05:00
Vitor Pamplona
43a6fefa17 Merge pull request #84 from Radiokot/fix/83
Fix #83
2023-02-06 08:38:10 -05:00
Vitor Pamplona
de3431e374 Fixing the order of url text and link 2023-02-06 08:32:35 -05:00
Oleg Koretsky
09f3a440d3 Fix switching tabs when changing the pager height 2023-02-06 14:46:54 +02:00
Oleg Koretsky
49c93c11e6 Make the profile header scrollable a, keep the bs â fixed 2023-02-06 13:47:48 +02:00
Vitor Pamplona
8ce96257d2 v0.13.1 2023-02-05 20:26:47 -05:00
Vitor Pamplona
94b525dd1f Fixing add button color. 2023-02-05 18:55:09 -05:00
Vitor Pamplona
e04843fe96 Using Main to update screen seems needed otherwise I get a 1 minute delay in the loading page before the screen updates. It looks like it waits for all the Pictures to load. Not sure why. 2023-02-05 18:39:54 -05:00
Vitor Pamplona
bb50099021 Merge pull request #75 from clackbib/habib/threading-perf-fixes
Scroll Perf Improvements.
2023-02-05 18:18:29 -05:00
Vitor Pamplona
459c195113 Merge pull request #76 from clackbib/habib/search-perf
A couple proposed improvements to search.
2023-02-05 18:16:58 -05:00
Vitor Pamplona
933fa9bf4d Merge pull request #77 from clackbib/habib/scrollabletabs
Tiny change to profile page:
2023-02-05 18:14:59 -05:00
Vitor Pamplona
bd94544c9b Breaking filters down by feed type: Home, DMs, Public Chats and Global. 2023-02-05 18:14:41 -05:00
Vitor Pamplona
72aad26c03 Fixing a bug where the last message is remembered by the Compose when forcing index = 0 on the first element. 2023-02-05 18:13:08 -05:00
Vitor Pamplona
ff087e3dea Correctly addressing Preview of images when posts have been reported. 2023-02-05 18:12:11 -05:00
Vitor Pamplona
eb41f22bae Correctly addresses a report to a note (and not to a user) 2023-02-05 18:10:32 -05:00
Vitor Pamplona
f1a37afaf1 Testing to see if the use of PlayServices dependencies uses less cache from the app. 2023-02-05 18:09:49 -05:00
Vitor Pamplona
9035fda17a Simplifying dependencies 2023-02-05 12:59:35 -05:00
Vitor Pamplona
c16f175ebc Not display any previews if the note has a report. 2023-02-05 12:59:15 -05:00
Vitor Pamplona
5c5aa222c5 Quick refactoring 2023-02-05 12:58:26 -05:00
Habib Okanla
14bda01a70 Tiny change to profile page:
Use scrollable tabs instead of fixed tab to avoid bad text wrap.
2023-02-05 02:05:54 -05:00
Habib Okanla
cd9f359add A couple proposed improvements to search.
The UX and performance of search is relatively poor because each keystroke triggers a search & update. With this changes:

1. Search queries are moved into an IO thread so that typing is smooth and data sends happend in the background.

2. Text changed are debounced by 300 millis, meaning search will not be fired off on every keystroke, but rather only when we the user stops typing for 300 millis.
2023-02-05 01:56:16 -05:00
Habib Okanla
f40060bb36 There are very visible perf issues during app usage and scrolling that clearly indicates that the app is doing too much on the main thread. After digging for instances where Dispatchers.Main is used, it's an easy fix to switch to Dispatchers.IO, which visibility improve perf.
A few thoughts about perf considerations

1. There is no need to force Dispatchers.Main for data that is consumed as state by compose, since flows consumed as state will always flow on main, so we can use a background thread to guarantee best performance.

2. Using Dispatchers.IO is appropriate for disk/network operations to have a device-constrained thread pool that will avoid draining IO-related device resources. Using Dispatchers.Default is more appropriate for computational tasks (bitmap manipulation, delays, etc..)

3. There are a few instances of methods creating coroutine scopes in their body just to launch something that will delay. This is creating a lot of loose scopes, and you can avoid this by just moving scope creation to a class-level field and reusing it, or better yet, make your method suspending so that scope is controlled by the caller.
2023-02-05 00:41:37 -05:00
Vitor Pamplona
c5aa57d91b v0.13.0 2023-02-04 19:44:47 -05:00
Vitor Pamplona
229f15ee7f Adds automatic translation to feed and chat. 2023-02-04 19:43:31 -05:00
Vitor Pamplona
d168a6c861 Changes to Logging 2023-02-04 19:43:08 -05:00
Vitor Pamplona
39d58452a0 Allows redirection on relay URIs 2023-02-04 19:42:08 -05:00
Vitor Pamplona
b334c7105c Adds a relay filter to download all reports by the user 2023-02-04 19:41:47 -05:00
Vitor Pamplona
ea9ff08d8a Adds hidden notes to chat. 2023-02-04 19:41:19 -05:00
Vitor Pamplona
e3d7c23218 Moves QR code classes to another package 2023-02-04 19:40:45 -05:00
Vitor Pamplona
67b030f068 Matches target API with build file 2023-02-04 15:10:16 -05:00
Vitor Pamplona
1940303130 Increases timeout period and avoid double websocket connections. 2023-02-04 13:32:09 -05:00
Vitor Pamplona
80bc09df92 refactoring 2023-02-04 13:30:39 -05:00
Vitor Pamplona
ea59568b6f removes mutable list in Client. 2023-02-04 13:30:15 -05:00
Vitor Pamplona
3e08256347 Keeping subscription names small to please some relays. 2023-02-04 13:29:53 -05:00
Vitor Pamplona
2641dda7bc Fixes Follow/Unfollow button update after pressing. 2023-02-04 10:48:54 -05:00
Vitor Pamplona
40deb46eab Bugfix: consider flags of the referenced post in case of Boost events. 2023-02-04 10:42:32 -05:00
Vitor Pamplona
2a5f8595d0 Increasing Notification stack to 200 2023-02-04 10:41:58 -05:00
Vitor Pamplona
6c27fe0828 Adding more info into flagged posts. 2023-02-04 10:41:43 -05:00
Vitor Pamplona
2992d9daf0 Trying to fix NPE around these lines. 2023-02-04 10:41:01 -05:00
Vitor Pamplona
bbfd060328 v0.12.1 2023-02-03 17:46:24 -05:00
Vitor Pamplona
9e894642ec Correctly aligns chat field. 2023-02-03 17:45:44 -05:00
Vitor Pamplona
22daf1a6a8 Removes Channels from the Messages Notification Dot. 2023-02-03 17:45:28 -05:00
Vitor Pamplona
1a3b92a727 Abolishes the use of Mutable collections inside data models. 2023-02-03 17:23:47 -05:00
Vitor Pamplona
ee7120d803 Adding a way to visualize view count stats 2023-02-03 11:26:49 -05:00
Vitor Pamplona
a45a8789e7 Faster animations 2023-02-03 11:26:28 -05:00
Vitor Pamplona
bb3e38d079 Merge pull request #63 from clackbib/disable_backup
Disable android backup as this app uses EncryptedSharedPrefs. Usage o…
2023-02-03 09:46:54 -05:00
Vitor Pamplona
1568cad693 Fixing test cases 2023-02-03 09:08:06 -05:00
Vitor Pamplona
10e5c43be9 v0.12.0 2023-02-02 20:40:03 -05:00
Vitor Pamplona
e3aa573b8c Removing logs 2023-02-02 20:39:54 -05:00
Vitor Pamplona
bd77c43d40 Not reloading reactions to an event. 2023-02-02 20:18:06 -05:00
Vitor Pamplona
8e02f67052 Reducing the chance of a bug when loading chatroom without a user 2023-02-02 20:17:53 -05:00
Vitor Pamplona
299cf1aabb Keeping the report filter. 2023-02-02 20:16:35 -05:00
Vitor Pamplona
343521ff58 Cut off of 200 messages for each channel 2023-02-02 20:16:07 -05:00
Vitor Pamplona
a82f260142 Capturing EOSE events and keeping messages within relay limits (10 subscriptions and 10 REQ events at a time) 2023-02-02 16:57:03 -05:00
Vitor Pamplona
fb73308995 Moving observable classes to the leaf nodes. 2023-02-02 14:34:34 -05:00
Vitor Pamplona
b3ce10c2de Allowing HTTP downloads 2023-02-02 14:30:58 -05:00
Vitor Pamplona
4fa6873180 Forcing Notifications with Direct citations only removes too much. 2023-02-02 14:29:39 -05:00
Habib Okanla
a34e88b4fc Disable android backup as this app uses EncryptedSharedPrefs. Usage of EncryptedSharedPrefs can cause crashes if it's not properly excluded from backup.
https://stackoverflow.com/questions/61751264/autobackup-with-encryptedsharedpreferences-failing-to-restore
2023-02-01 19:33:46 -05:00
Vitor Pamplona
912ca72d68 Presenting a Drop Down menu to copy keys and channel IDs. 2023-02-01 14:05:59 -03:00
Vitor Pamplona
c3c19ebb49 Only showing notifications if it directly cites the account holder 2023-02-01 13:51:03 -03:00
Vitor Pamplona
0f968ca5e5 Removing channel create and change from Notifications Feed 2023-02-01 13:17:21 -03:00
Vitor Pamplona
2e17535a60 Keeping chatroom list scroll up top if it was up top. 2023-02-01 12:40:31 -03:00
Vitor Pamplona
663eb9c604 Solves https://github.com/vitorpamplona/amethyst/issues/58 2023-02-01 10:52:54 -03:00
Vitor Pamplona
4b894c5b33 Fixes bug where default relays would come back after leaving the two groups and not adding any other group. 2023-01-31 23:42:14 -03:00
Vitor Pamplona
814fb1845f Moving Picture and Display Name Observables to leaf UI nodes. 2023-01-31 22:12:24 -03:00
Vitor Pamplona
94a4bfc2f3 Avoiding calculations in the UI thread 2023-01-31 22:11:54 -03:00
Vitor Pamplona
4303e6040c Fixes Channel @tag 2023-01-31 20:50:56 -03:00
Vitor Pamplona
75cb313886 Uses Hex to idex Users 2023-01-31 20:50:38 -03:00
102 changed files with 3555 additions and 1705 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 33
versionName "0.11.7"
versionCode 42
versionName "0.14.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,8 +22,14 @@ android {
buildTypes {
release {
//minifyEnabled false
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
resValue "string", "app_name", "@string/app_name_release"
}
debug {
applicationIdSuffix '.debug'
versionNameSuffix '-DEBUG'
resValue "string", "app_name", "@string/app_name_debug"
}
}
compileOptions {
@@ -85,7 +91,7 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
// Json Serialization
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2'
// link preview
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
@@ -111,13 +117,22 @@ dependencies {
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
// For QR generation
implementation "com.google.zxing:core:3.5.0"
implementation 'com.google.zxing:core:3.5.1'
implementation "androidx.camera:camera-camera2:1.2.1"
implementation 'androidx.camera:camera-lifecycle:1.2.1'
implementation 'androidx.camera:camera-view:1.2.1'
// For QR Scanning
implementation 'com.google.mlkit:vision-common:17.3.0'
// Local Barcode Scanning model
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
// Use this dependency to use the translate text
implementation 'com.google.mlkit:translate:17.0.1'
implementation 'com.google.mlkit:language-id:17.0.4'
implementation 'com.google.android.gms:play-services-mlkit-language-id:17.0.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'

View File

@@ -26,6 +26,10 @@
# For the NostrPostr library
-keep class nostr.postr.** { *; }
-keep class nostr.postr.events.** { *; }
# Json parsing
-keep class com.google.gson.reflect.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken
-keep public class * implements java.lang.reflect.Type
-keep class com.vitorpamplona.amethyst.model.** { *; }
-keep class com.vitorpamplona.amethyst.service.** { *; }

View File

@@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import org.junit.Assert.*
import org.junit.Test
@@ -22,12 +23,12 @@ class EUrlUserTagTransformationTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
assertEquals("com.vitorpamplona.amethyst", appContext.packageName.removeSuffix(".debug"))
}
@Test
fun transformationText() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(
@@ -61,7 +62,7 @@ class EUrlUserTagTransformationTest {
@Test
fun transformationTextTwoKeys() {
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z").toHexKey())
user.info.displayName = "Vitor Pamplona"
var transformedText = buildAnnotatedStringWithUrlHighlighting(

View File

@@ -0,0 +1,4 @@
<resources>
<string name="app_name_release">Amethyst</string>
<string name="app_name_debug">Amethyst Debug</string>
</resources>

View File

@@ -4,10 +4,15 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<!-- Used for SDK < 29 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<application
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/amethyst"
@@ -16,9 +21,10 @@
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
tools:targetApi="31">
android:usesCleartextTraffic="true"
tools:targetApi="33">
<activity
android:label="Amethyst"
android:label="@string/app_name"
android:name=".ui.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"

View File

@@ -1,17 +1,18 @@
package com.vitorpamplona.amethyst
import android.content.Context
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import java.util.Locale
import nostr.postr.Persona
import nostr.postr.toHex
class LocalPreferences(context: Context) {
val encryptedPreferences = EncryptedStorage().preferences(context)
val gson = GsonBuilder().create()
fun clearEncryptedStorage() {
encryptedPreferences.edit().apply {
@@ -19,6 +20,9 @@ class LocalPreferences(context: Context) {
remove("nostr_pubkey")
remove("following_channels")
remove("hidden_users")
remove("relays")
remove("dontTranslateFrom")
remove("translateTo")
}.apply()
}
@@ -28,6 +32,9 @@ class LocalPreferences(context: Context) {
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
account.followingChannels.let { putStringSet("following_channels", it) }
account.hiddenUsers.let { putStringSet("hidden_users", it) }
account.localRelays.let { putString("relays", gson.toJson(it)) }
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.translateTo.let { putString("translateTo", it) }
}.apply()
}
@@ -35,14 +42,24 @@ class LocalPreferences(context: Context) {
encryptedPreferences.apply {
val privKey = getString("nostr_privkey", null)
val pubKey = getString("nostr_pubkey", null)
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
val followingChannels = getStringSet("following_channels", null) ?: setOf()
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
val localRelays = gson.fromJson(
getString("relays", "[]"),
object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
if (pubKey != null) {
return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
followingChannels,
hiddenUsers
hiddenUsers,
localRelays,
dontTranslateFrom,
translateTo
)
} else {
return null

View File

@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
@@ -29,7 +29,7 @@ object ServiceManager {
val myAccount = account
if (myAccount != null) {
Client.connect(myAccount.activeRelays() ?: Constants.defaultRelays)
Client.connect(myAccount.convertLocalRelays())
// start services
NostrAccountDataSource.account = myAccount
@@ -55,7 +55,7 @@ object ServiceManager {
NostrChatroomListDataSource.start()
} else {
// if not logged in yet, start a basic service wit default relays
Client.connect()
Client.connect(Constants.convertDefaultRelays())
}
}

View File

@@ -0,0 +1,106 @@
package com.vitorpamplona.amethyst.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kotlin.math.ln
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class LightningAddressResolver {
val client = OkHttpClient.Builder().build()
fun assembleUrl(lnaddress: String): String? {
val parts = lnaddress.split("@")
if (parts.size != 2) {
return null
}
return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}"
}
fun fetchLightningAddressJson(lnaddress: String, onSucess: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningAddressJsonSuspend(lnaddress, onSucess)
}
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSucess: (String) -> Unit) {
val url = assembleUrl(lnaddress) ?: return
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSucess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
e.printStackTrace()
}
})
}
}
fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchLightningInvoiceSuspend(lnCallback, milliSats, message, onSucess)
}
}
private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
val urlBinder = if (lnCallback.contains("?")) "&" else "?"
val url = "$lnCallback${urlBinder}amount=$milliSats&comment=$message"
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
onSucess(response.body.string())
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
e.printStackTrace()
}
})
}
}
fun lnAddressToLnUrl(lnaddress: String, onSucess: (String) -> Unit) {
fetchLightningAddressJson(lnaddress) {
onSucess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32))
}
}
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, onSucess: (String) -> Unit) {
val mapper = jacksonObjectMapper()
fetchLightningAddressJson(lnaddress) {
mapper.readTree(it)?.get("callback")?.asText()?.let { callback ->
fetchLightningInvoice(callback, milliSats, message,
onSucess = {
mapper.readTree(it)?.get("pr")?.asText()?.let { pr ->
onSucess(pr)
}
}
)
}
}
}
}

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
@@ -9,9 +11,12 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -32,14 +37,25 @@ val DefaultChannels = setOf(
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
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
}
class Account(
val loggedIn: Persona,
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
val hiddenUsers: MutableSet<String> = mutableSetOf()
var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language
) {
fun userProfile(): User {
return LocalCache.getOrCreateUser(loggedIn.pubKey)
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
}
fun followingChannels(): List<Channel> {
@@ -47,7 +63,7 @@ class Account(
}
fun hiddenUsers(): List<User> {
return hiddenUsers.map { LocalCache.getOrCreateUser(it.toByteArray()) }
return hiddenUsers.map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
@@ -91,7 +107,7 @@ class Account(
fun reactTo(note: Note) {
if (!isWriteable()) return
if (note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "+" } != null) {
if (note.hasReacted(userProfile(), "+")) {
// has already liked this note
return
}
@@ -106,9 +122,7 @@ class Account(
fun report(note: Note, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (
note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "⚠️"} != null
) {
if (note.hasReacted(userProfile(), "⚠️")) {
// has already liked this note
return
}
@@ -129,9 +143,7 @@ class Account(
fun report(user: User, type: ReportEvent.ReportType) {
if (!isWriteable()) return
if (
user.reports.firstOrNull { it.author == userProfile() && it.event is ReportEvent && (it.event as ReportEvent).reportType.contains(type) } != null
) {
if (user.hasReport(userProfile(), type)) {
// has already reported this note
return
}
@@ -144,11 +156,7 @@ class Account(
fun boost(note: Note) {
if (!isWriteable()) return
val currentTime = Date().time / 1000
if (
note.boosts.firstOrNull { it.author == userProfile() && (it?.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
) {
if (note.hasBoosted(userProfile())) {
// has already bosted in the past 5mins
return
}
@@ -232,7 +240,7 @@ class Account(
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
@@ -247,7 +255,7 @@ class Account(
advertiseNip18 = false
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
}
fun sendCreateNewChannel(name: String, about: String, picture: String) {
@@ -269,23 +277,23 @@ class Account(
}
fun joinChannel(idHex: String) {
followingChannels.add(idHex)
invalidateData()
followingChannels = followingChannels + idHex
invalidateData(live)
}
fun leaveChannel(idHex: String) {
followingChannels.remove(idHex)
invalidateData()
followingChannels = followingChannels - idHex
invalidateData(live)
}
fun hideUser(pubkeyHex: String) {
hiddenUsers.add(pubkeyHex)
invalidateData()
hiddenUsers = hiddenUsers + pubkeyHex
invalidateData(live)
}
fun showUser(pubkeyHex: String) {
hiddenUsers.remove(pubkeyHex)
invalidateData()
hiddenUsers = hiddenUsers - pubkeyHex
invalidateData(live)
}
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -304,7 +312,7 @@ class Account(
Client.send(event)
LocalCache.consume(event)
followingChannels.add(event.id.toHex())
joinChannel(event.id.toHex())
}
fun decryptContent(note: Note): String? {
@@ -337,15 +345,34 @@ class Account(
}
}
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
invalidateData(liveLanguages)
}
fun updateTranslateTo(languageCode: String) {
translateTo = languageCode
invalidateData(liveLanguages)
}
fun activeRelays(): Array<Relay>? {
return userProfile().relays?.map { Relay(it.key, it.value.read, it.value.write) }?.toTypedArray()
return userProfile().relays?.map {
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
}?.toTypedArray()
}
fun convertLocalRelays(): Array<Relay> {
return localRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
init {
userProfile().subscribe(object: User.Listener() {
override fun onRelayChange() {
Client.disconnect()
Client.connect(activeRelays() ?: Constants.defaultRelays)
Client.connect(activeRelays() ?: convertLocalRelays())
RelayPool.requestAndWatch()
}
})
@@ -353,23 +380,24 @@ class Account(
// Observers line up here.
val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this)
var handlerWaiting = AtomicBoolean()
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
private fun invalidateData(live: AccountLiveData) {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
handlerWaiting.set(false)
}
}
fun isHidden(user: User) = user !in hiddenUsers()
fun isHidden(user: User) = user in hiddenUsers()
fun isAcceptable(user: User): Boolean {
return user !in hiddenUsers() // if user hasn't hided this author
@@ -386,10 +414,28 @@ class Account(
return note.author?.let { isAcceptable(it) } ?: true // if user hasn't hided this author
&& isAcceptableDirect(note)
&& (note.event !is RepostEvent
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(note) } != null)
|| (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null)
) // is not a reaction about a blocked post
}
fun getRelevantReports(note: Note): Set<Note> {
val followsPlusMe = userProfile().follows + userProfile()
val innerReports = if (note.event is RepostEvent) {
note.replyTo?.map { getRelevantReports(it) }?.flatten() ?: emptyList()
} else {
emptyList()
}
return (note.reportsBy(followsPlusMe) +
(note.author?.reportsBy(followsPlusMe) ?: emptyList()) +
innerReports).toSet()
}
fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
}
}
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {

View File

@@ -17,6 +17,10 @@ class Channel(val id: ByteArray) {
val notes = ConcurrentHashMap<HexKey, Note>()
fun toBestDisplayName(): String {
return info.name ?: idDisplayHex
}
@Synchronized
fun addNote(note: Note) {
notes[note.idHex] = note

View File

@@ -11,6 +11,17 @@ import nostr.postr.toHex
/** Makes the distinction between String and Hex **/
typealias HexKey = String
typealias NPubKey = String
typealias NoteId = String
fun NPubKey.toDisplayKey(): String {
return this.toShortenHex()
}
fun NoteId.toDisplayId(): String {
return this.toShortenHex()
}
fun ByteArray.toNote() = Bech32.encodeBytes(hrp = "note", this, Bech32.Encoding.Bech32)
fun ByteArray.toHexKey(): HexKey {

View File

@@ -41,10 +41,9 @@ object LocalCache {
val channels = ConcurrentHashMap<HexKey, Channel>()
@Synchronized
fun getOrCreateUser(pubkey: ByteArray): User {
val key = pubkey.toHexKey()
fun getOrCreateUser(key: HexKey): User {
return users[key] ?: run {
val answer = User(pubkey)
val answer = User(key)
users.put(key, answer)
answer
}
@@ -71,10 +70,8 @@ object LocalCache {
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
val oldUser = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldUser.updatedMetadataAt) {
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
val newUser = try {
metadataParser.readValue<UserMetadata>(ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)), UserMetadata::class.java)
} catch (e: Exception) {
@@ -85,6 +82,8 @@ object LocalCache {
oldUser.updateUserInfo(newUser, event.createdAt)
oldUser.latestMetadata = event
//Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
} else {
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
}
@@ -98,23 +97,25 @@ object LocalCache {
fun consume(event: TextNoteEvent, relay: Relay? = null) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null)
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList())
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, replyTo)
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content?.take(100)} ${formattedDateTime(event.createdAt)}")
// Prepares user's profile view.
author.notes.add(note)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
@@ -137,7 +138,7 @@ object LocalCache {
}
fun consume(event: ContactListEvent) {
val user = getOrCreateUser(event.pubKey)
val user = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > user.updatedFollowsAt) {
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
@@ -145,7 +146,7 @@ object LocalCache {
event.follows.map {
try {
val pubKey = decodePublicKey(it.pubKeyHex)
getOrCreateUser(pubKey)
getOrCreateUser(pubKey.toHexKey())
} catch (e: Exception) {
println("Could not parse Hex key: ${it.pubKeyHex}")
println("UpdateFollows: " + event.toJson())
@@ -163,6 +164,7 @@ object LocalCache {
event.content,
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
)
user.updateRelays(relays)
}
} catch (e: Exception) {
@@ -173,19 +175,24 @@ object LocalCache {
}
}
fun consume(event: PrivateDmEvent) {
fun consume(event: PrivateDmEvent, relay: Relay?) {
val note = getOrCreateNote(event.id.toHex())
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val recipient = event.recipientPubKey?.let { getOrCreateUser(it) }
val recipient = event.recipientPubKey?.let { getOrCreateUser(it.toHexKey()) }
//Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }.toMutableList()
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(decodePublicKey(it)) }
val repliesTo = event.tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateNote(it) }
val mentions = event.tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }.map { getOrCreateUser(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -209,14 +216,14 @@ object LocalCache {
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor.map { getOrCreateUser(decodePublicKey(it)) }.toList()
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }.toMutableList()
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val repliesTo = event.boostedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
// Prepares user's profile view.
author.notes.add(note)
author.addNote(note)
// Adds notifications to users.
mentions.forEach {
@@ -235,14 +242,14 @@ object LocalCache {
}
fun consume(event: ReactionEvent) {
val note = getOrCreateNote(event.id.toHex())
val note = getOrCreateNote(event.id.toHexKey())
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.originalAuthor.map { getOrCreateUser(decodePublicKey(it)) }
val repliesTo = event.originalPost.map { getOrCreateNote(it) }.toMutableList()
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.originalAuthor.map { getOrCreateUser(it) }
val repliesTo = event.originalPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
@@ -285,16 +292,18 @@ object LocalCache {
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = event.reportedAuthor.map { getOrCreateUser(decodePublicKey(it)) }
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }.toMutableList()
val author = getOrCreateUser(event.pubKey.toHexKey())
val mentions = event.reportedAuthor.map { getOrCreateUser(it) }
val repliesTo = event.reportedPost.map { getOrCreateNote(it) }
note.loadEvent(event, author, mentions, repliesTo)
//Log.d("RP", "New Report ${event.content} by ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
// Adds notifications to users.
mentions.forEach {
it.addReport(note)
if (repliesTo.isEmpty()) {
mentions.forEach {
it.addReport(note)
}
}
repliesTo.forEach {
it.addReport(note)
@@ -305,7 +314,7 @@ object LocalCache {
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
// new event
val oldChannel = getOrCreateChannel(event.id.toHex())
val author = getOrCreateUser(event.pubKey)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
@@ -313,7 +322,7 @@ object LocalCache {
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
}
@@ -327,7 +336,7 @@ object LocalCache {
// new event
val oldChannel = getOrCreateChannel(event.channel)
val author = getOrCreateUser(event.pubKey)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo, event.createdAt)
@@ -335,7 +344,7 @@ object LocalCache {
val note = getOrCreateNote(event.id.toHex())
oldChannel.addNote(note)
note.channel = oldChannel
note.loadEvent(event, author, emptyList(), mutableListOf())
note.loadEvent(event, author, emptyList(), emptyList())
refreshObservers()
}
@@ -344,7 +353,7 @@ object LocalCache {
}
}
fun consume(event: ChannelMessageEvent) {
fun consume(event: ChannelMessageEvent, relay: Relay?) {
if (event.channel.isNullOrBlank()) return
val channel = getOrCreateChannel(event.channel)
@@ -352,17 +361,20 @@ object LocalCache {
val note = getOrCreateNote(event.id.toHex())
channel.addNote(note)
val author = getOrCreateUser(event.pubKey.toHexKey())
if (relay != null) {
author.addRelay(relay, event.createdAt)
note.addRelay(relay)
}
// Already processed this event.
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
val replyTo = Collections.synchronizedList(
event.replyTos
.map { getOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
.toMutableList()
)
val mentions = event.mentions.map { getOrCreateUser(it) }
val replyTo = event.replyTos
.map { getOrCreateNote(it) }
.filter { it.event !is ChannelCreateEvent }
note.channel = channel
note.loadEvent(event, author, mentions, replyTo)

View File

@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.time.Instant
@@ -14,8 +16,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import java.util.regex.Matcher
import java.util.regex.Pattern
import nostr.postr.events.Event
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
class Note(val idHex: String) {
// These fields are always available.
// They are immutable
@@ -27,23 +35,32 @@ class Note(val idHex: String) {
var event: Event? = null
var author: User? = null
var mentions: List<User>? = null
var replyTo: MutableList<Note>? = null
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
val replies = Collections.synchronizedSet(mutableSetOf<Note>())
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
var replies = setOf<Note>()
private set
var reactions = setOf<Note>()
private set
var boosts = setOf<Note>()
private set
var reports = setOf<Note>()
private set
var relays = setOf<String>()
private set
var channel: Channel? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
var lastReactionsDownloadTime: Long? = null
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: List<Note>) {
this.event = event
this.author = author
this.mentions = mentions
this.replyTo = replyTo
invalidateData(live)
live.invalidateData()
}
fun formattedDateTime(timestamp: Long): String {
@@ -74,47 +91,96 @@ class Note(val idHex: String) {
}
fun addReply(note: Note) {
if (replies.add(note))
invalidateData(liveReplies)
if (note !in replies) {
replies = replies + note
liveReplies.invalidateData()
}
}
fun addBoost(note: Note) {
if (boosts.add(note))
invalidateData(liveBoosts)
if (note !in boosts) {
boosts = boosts + note
liveBoosts.invalidateData()
}
}
fun addReaction(note: Note) {
if (reactions.add(note))
invalidateData(liveReactions)
if (note !in reactions) {
reactions = reactions + note
liveReactions.invalidateData()
}
}
fun addReport(note: Note) {
if (reports.add(note))
invalidateData(liveReports)
if (note !in reports) {
reports = reports + note
liveReports.invalidateData()
}
}
fun addRelay(relay: Relay) {
if (relay.url !in relays) {
relays = relays + relay.url
liveRelays.invalidateData()
}
}
fun isReactedBy(user: User): Boolean {
return synchronized(reactions) {
reactions.any { it.author == user }
}
return reactions.any { it.author == user }
}
fun isBoostedBy(user: User): Boolean {
return synchronized(boosts) {
boosts.any { it.author == user }
}
return boosts.any { it.author == user }
}
fun reportsBy(user: User): List<Note> {
return synchronized(reports) {
reports.filter { it.author == user }
}
return reports.filter { it.author == user }
}
fun reportsBy(users: Set<User>): List<Note> {
return synchronized(reports) {
reports.filter { it.author in users }
return reports.filter { it.author in users }
}
fun hasAnyReports(): Boolean {
val dayAgo = Date().time / 1000 - 24*60*60
return author?.reports?.filter { it.event?.createdAt ?: 0 > dayAgo }?.isNotEmpty() ?: false
|| reports.isNotEmpty()
}
fun directlyCiteUsers(): Set<User> {
val matcher = tagSearch.matcher(event?.content ?: "")
val returningList = mutableSetOf<User>()
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { event?.tags?.get(it.toInt()) }
if (tag != null && tag[0] == "p") {
returningList.add(LocalCache.getOrCreateUser(tag[1]))
}
} catch (e: Exception) {
}
}
return returningList
}
fun directlyCites(userProfile: User): Boolean {
return author == userProfile
|| (userProfile in directlyCiteUsers())
|| (event is ReactionEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
|| (event is RepostEvent && replyTo?.lastOrNull()?.directlyCites(userProfile) == true)
}
fun isNewThread(): Boolean {
return event is RepostEvent || replyTo == null || replyTo?.size == 0
}
fun hasReacted(loggedIn: User, content: String): Boolean {
return reactions.firstOrNull { it.author == loggedIn && it.event?.content == content } != null
}
fun hasBoosted(loggedIn: User): Boolean {
val currentTime = Date().time / 1000
return boosts.firstOrNull { it.author == loggedIn && (it.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
}
// Observers line up here.
@@ -124,24 +190,27 @@ class Note(val idHex: String) {
val liveBoosts: NoteLiveData = NoteLiveData(this)
val liveReplies: NoteLiveData = NoteLiveData(this)
val liveReports: NoteLiveData = NoteLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData(live: NoteLiveData) {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
val liveRelays: NoteLiveData = NoteLiveData(this)
}
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}
fun refresh() {
postValue(NoteState(note))
}

View File

@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.relays.FeedType
data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)

View File

@@ -14,8 +14,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object UrlCachedPreviewer {
val cache = ConcurrentHashMap<String, UrlInfoItem>()
val failures = ConcurrentHashMap<String, Throwable>()
var cache = mapOf<String, UrlInfoItem>()
private set
var failures = mapOf<String, Throwable>()
private set
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
cache[url]?.let {
@@ -32,12 +34,12 @@ object UrlCachedPreviewer {
scope.launch {
BahaUrlPreview(url, object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache.put(url, urlInfo)
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
}
override fun onFailed(throwable: Throwable) {
failures.put(url, throwable)
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
}
}).fetchUrlPreview()

View File

@@ -2,11 +2,17 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import fr.acinq.secp256k1.Hex
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -17,8 +23,8 @@ import nostr.postr.events.Event
import nostr.postr.events.MetadataEvent
import nostr.postr.toNpub
class User(val pubkey: ByteArray) {
val pubkeyHex = pubkey.toHexKey()
class User(val pubkeyHex: String) {
val pubkey = Hex.decode(pubkeyHex)
val pubkeyDisplayHex = pubkey.toNpub().toShortenHex()
var info = UserMetadata()
@@ -29,18 +35,30 @@ class User(val pubkey: ByteArray) {
var latestContactList: ContactListEvent? = null
var latestMetadata: MetadataEvent? = null
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
val follows = Collections.synchronizedSet(mutableSetOf<User>())
var follows = setOf<User>()
private set
var followers = setOf<User>()
private set
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
var notes = setOf<Note>()
private set
var taggedPosts = setOf<Note>()
private set
var reports = setOf<Note>()
private set
var relays: Map<String, ContactListEvent.ReadWrite>? = null
private set
val followers = Collections.synchronizedSet(mutableSetOf<User>())
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
var relaysBeingUsed = mapOf<String, RelayInfo>()
private set
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
val relaysBeingUsed = mutableMapOf<String, RelayInfo>()
var messages = mapOf<User, Set<Note>>()
private set
var latestMetadataRequestEOSE: Long? = null
var latestReportRequestEOSE: Long? = null
fun toBestDisplayName(): String {
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
@@ -60,65 +78,111 @@ class User(val pubkey: ByteArray) {
}
fun follow(user: User, followedAt: Long) {
follows.add(user)
user.followers.add(this)
follows = follows + user
user.followers = user.followers + this
invalidateData()
user.invalidateData()
liveFollows.invalidateData()
user.liveFollows.invalidateData()
listeners.forEach {
updateSubscribers {
it.onFollowsChange()
}
user.updateSubscribers {
it.onFollowsChange()
}
}
fun unfollow(user: User) {
follows.remove(user)
user.followers.remove(this)
follows = follows - user
user.followers = user.followers - this
invalidateData()
user.invalidateData()
liveFollows.invalidateData()
user.liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
user.updateSubscribers {
it.onFollowsChange()
}
}
fun follow(users: Set<User>, followedAt: Long) {
follows = follows + users
users.forEach {
it.followers = it.followers + this
it.liveFollows.invalidateData()
it.updateSubscribers {
it.onFollowsChange()
}
}
liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun unfollow(users: Set<User>) {
follows = follows - users
users.forEach {
it.followers = it.followers - this
it.liveFollows.invalidateData()
it.updateSubscribers {
it.onFollowsChange()
}
}
liveFollows.invalidateData()
updateSubscribers {
it.onFollowsChange()
}
}
fun addTaggedPost(note: Note) {
taggedPosts.add(note)
updateSubscribers { it.onNewPosts() }
if (note !in taggedPosts) {
taggedPosts = taggedPosts + note
updateSubscribers { it.onNewTaggedPosts() }
}
}
fun addNote(note: Note) {
if (note !in notes) {
notes = notes + note
updateSubscribers { it.onNewNotes() }
}
}
fun addReport(note: Note) {
if (reports.add(note)) {
updateSubscribers { it.onNewReports() }
invalidateData()
if (note !in reports) {
reports = reports + note
liveReports.invalidateData()
}
}
fun reportsBy(user: User): List<Note> {
return synchronized(reports) {
reports.filter { it.author == user }
}
return reports.filter { it.author == user }
}
fun reportsBy(users: Set<User>): List<Note> {
return synchronized(reports) {
reports.filter { it.author in users }
}
return reports.filter { it.author in users }
}
@Synchronized
fun getOrCreateChannel(user: User): MutableSet<Note> {
fun getOrCreateChannel(user: User): Set<Note> {
return messages[user] ?: run {
val channel = Collections.synchronizedSet(mutableSetOf<Note>())
messages[user] = channel
val channel = setOf<Note>()
messages = messages + Pair(user, channel)
channel
}
}
fun addMessage(user: User, msg: Note) {
getOrCreateChannel(user).add(msg)
invalidateData()
updateSubscribers { it.onNewMessage() }
val channel = getOrCreateChannel(user)
if (msg !in channel) {
messages = messages + Pair(user, channel + msg)
liveMessages.invalidateData()
updateSubscribers { it.onNewMessage() }
}
}
data class RelayInfo (
@@ -128,9 +192,9 @@ class User(val pubkey: ByteArray) {
)
fun addRelay(relay: Relay, eventTime: Long) {
val here = relaysBeingUsed.get(relay.url)
val here = relaysBeingUsed[relay.url]
if (here == null) {
relaysBeingUsed.put(relay.url, RelayInfo(relay.url, eventTime, 1) )
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
} else {
if (eventTime > here.lastEvent) {
here.lastEvent = eventTime
@@ -139,25 +203,21 @@ class User(val pubkey: ByteArray) {
}
updateSubscribers { it.onNewRelayInfo() }
liveRelayInfo.invalidateData()
}
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
val toBeAdded = synchronized(follows) {
newFollows - follows
}
val toBeRemoved = synchronized(follows) {
follows - newFollows
}
toBeAdded.forEach {
follow(it, updateAt)
}
toBeRemoved.forEach {
unfollow(it)
}
val toBeAdded = newFollows - follows
val toBeRemoved = follows - newFollows
follow(toBeAdded, updateAt)
unfollow(toBeRemoved)
updatedFollowsAt = updateAt
}
data class RelayMetadata(val read: Boolean, val write: Boolean, val activeTypes: Set<FeedType>)
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
if (relays != relayUse) {
relays = relayUse
@@ -166,36 +226,32 @@ class User(val pubkey: ByteArray) {
}
}
invalidateData()
liveRelays.invalidateData()
}
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
info = newUserInfo
updatedMetadataAt = updateAt
if (info.picture?.startsWith( "http://") == true) {
info.picture = info.picture?.replaceFirst("http://", "https://")
}
if (info.banner?.startsWith( "http://") == true) {
info.banner = info.banner?.replaceFirst("http://", "https://")
}
invalidateData()
liveMetadata.invalidateData()
}
fun isFollowing(user: User): Boolean {
return synchronized(follows) {
follows.contains(user)
}
return follows.contains(user)
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = messages[user] ?: return false
return synchronized(messagesToUser) {
messagesToUser.firstOrNull { this == it.author } != null
}
return messagesToUser.firstOrNull { this == it.author } != null
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
return reports.firstOrNull {
it.author == loggedIn
&& it.event is ReportEvent
&& (it.event as ReportEvent).reportType.contains(type)
} != null
}
// Model Observers
@@ -212,46 +268,38 @@ class User(val pubkey: ByteArray) {
abstract class Listener {
open fun onRelayChange() = Unit
open fun onFollowsChange() = Unit
open fun onNewPosts() = Unit
open fun onNewTaggedPosts() = Unit
open fun onNewNotes() = Unit
open fun onNewMessage() = Unit
open fun onNewRelayInfo() = Unit
open fun onNewReports() = Unit
}
// Refreshes observers in batches.
var modelHandlerWaiting = false
var modelHandlerWaiting = AtomicBoolean()
@Synchronized
fun updateSubscribers(on: (Listener) -> Unit) {
if (modelHandlerWaiting) return
if (modelHandlerWaiting.getAndSet(true)) return
modelHandlerWaiting = true
modelHandlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
listeners.forEach {
on(it)
}
modelHandlerWaiting = false
modelHandlerWaiting.set(false)
}
}
// UI Observers line up here.
val live: UserLiveData = UserLiveData(this)
// Refreshes observers in batches.
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
live.refresh()
handlerWaiting = false
}
}
val liveFollows: UserLiveData = UserLiveData(this)
val liveReports: UserLiveData = UserLiveData(this)
val liveMessages: UserLiveData = UserLiveData(this)
val liveRelays: UserLiveData = UserLiveData(this)
val liveRelayInfo: UserLiveData = UserLiveData(this)
val liveMetadata: UserLiveData = UserLiveData(this)
}
class UserMetadata {
@@ -280,7 +328,25 @@ class UserMetadata {
}
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
fun refresh() {
// Refreshes observers in batches.
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (!hasActiveObservers()) return
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}
private fun refresh() {
postValue(UserState(user))
}

View File

@@ -1,10 +0,0 @@
package com.vitorpamplona.amethyst.service
import java.util.UUID
import nostr.postr.JsonFilter
data class Channel (
val id: String = UUID.randomUUID().toString().substring(0,4)
) {
var filter: List<JsonFilter>? = null // Inactive when null
}

View File

@@ -1,25 +0,0 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.relays.Relay
object Constants {
val defaultRelays = arrayOf(
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
Relay("wss://relay.nostr.bg", read = true, write = true),
//Relay("wss://brb.io", read = true, write = true),
Relay("wss://relay.snort.social", read = true, write = true),
Relay("wss://nostr.rocks", read = true, write = true),
Relay("wss://relay.damus.io", read = true, write = true),
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
Relay("wss://nostr.oxtr.dev", read = true, write = true),
Relay("wss://eden.nostr.land", read = true, write = true),
//Relay("wss://nostr-2.zebedee.cloud", read = true, write = true),
Relay("wss://nostr-pub.wellorder.net", read = true, write = true),
Relay("wss://nostr.mom", read = true, write = true),
Relay("wss://nostr.orangepill.dev", read = true, write = true),
Relay("wss://nostr-pub.semisol.dev", read = true, write = true),
Relay("wss://nostr.onsats.org", read = true, write = true),
Relay("wss://nostr.sandwich.farm", read = true, write = true),
Relay("wss://relay.nostr.ch", read = true, write = true)
)
}

View File

@@ -43,7 +43,7 @@ class Nip19 {
return null
}
fun parseTLV(data: ByteArray): Map<Byte, MutableList<ByteArray>> {
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
var result = mutableMapOf<Byte, MutableList<ByteArray>>()
var rest = data
while (rest.isNotEmpty()) {

View File

@@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
@@ -13,46 +15,64 @@ import nostr.postr.events.TextNoteEvent
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
lateinit var account: Account
fun createAccountContactListFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5
fun createAccountContactListFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
fun createAccountMetadataFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 5
fun createAccountMetadataFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
limit = 1
)
)
}
val accountMetadataChannel = requestNewChannel()
val accountContactListChannel = requestNewChannel()
fun createAccountReportsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
}
fun createNotificationFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 200
)
)
val accountChannel = requestNewChannel()
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
// gets everthing about the user logged in
val newAccountMetadataFilter = createAccountMetadataFilter()
accountMetadataChannel.filter = listOf(newAccountMetadataFilter).ifEmpty { null }
val newAccountContactListEvent = createAccountContactListFilter()
accountContactListChannel.filter = listOf(newAccountContactListEvent).ifEmpty { null }
accountChannel.typedFilters = listOf(
createAccountMetadataFilter(),
createAccountContactListFilter(),
createNotificationFilter(),
createAccountReportsFilter()
).ifEmpty { null }
}
}

View File

@@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
@@ -11,16 +13,19 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
var channel: com.vitorpamplona.amethyst.model.Channel? = null
fun loadMessagesBetween(channelId: String) {
channel = LocalCache.channels[channelId]
channel = LocalCache.getOrCreateChannel(channelId)
resetFilters()
}
fun createMessagesToChannelFilter(): JsonFilter? {
fun createMessagesToChannelFilter(): TypedFilter? {
if (channel != null) {
return JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
limit = 200
)
)
}
return null
@@ -34,6 +39,6 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
}
override fun updateChannelFilters() {
messagesChannel.filter = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
}
}

View File

@@ -4,6 +4,8 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
@@ -16,34 +18,50 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
withUser = LocalCache.users[userId]
}
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = withUser?.let { listOf(it.pubkeyHex) },
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
fun createMessagesToMeFilter(): TypedFilter? {
val myPeer = withUser
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(myPeer.pubkeyHex) ,
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
} else {
null
}
}
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = withUser?.let { mapOf("p" to listOf(it.pubkeyHex)) }
)
fun createMessagesFromMeFilter(): TypedFilter? {
val myPeer = withUser
return if (myPeer != null) {
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
tags = mapOf("p" to listOf(myPeer.pubkeyHex))
)
)
} else {
null
}
}
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
val inandoutChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages[withUser] ?: return emptyList()
val filteredMessages = synchronized(messages) {
messages.filter { account.isAcceptable(it) }
}
return filteredMessages.sortedBy { it.event?.createdAt }.reversed()
return messages.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.reversed()
}
override fun updateChannelFilters() {
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
inandoutChannel.typedFilters = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
}
}

View File

@@ -1,80 +1,85 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.PrivateDmEvent
object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
lateinit var account: Account
fun createMessagesToMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
fun createMessagesToMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex))
)
)
fun createMessagesFromMeFilter() = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
fun createMessagesFromMeFilter() = TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter = JsonFilter(
kinds = listOf(PrivateDmEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
fun createChannelsCreatedbyMeFilter() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
fun createChannelsCreatedbyMeFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
authors = listOf(account.userProfile().pubkeyHex)
)
)
fun createMyChannelsFilter() = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
fun createMyChannelsFilter() = TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = account.followingChannels.toList()
)
)
fun createLastChannelInfoFilter(): List<JsonFilter> {
fun createLastChannelInfoFilter(): List<TypedFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
)
}
}
fun createLastMessageOfEachChannelFilter(): List<JsonFilter> {
fun createLastMessageOfEachChannelFilter(): List<TypedFilter> {
return account.followingChannels.map {
JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMessageEvent.kind),
tags = mapOf("e" to listOf(it)),
limit = 1
)
)
}
}
val incomingChannel = requestNewChannel()
val outgoingChannel = requestNewChannel()
val myChannelsChannel = requestNewChannel()
val myChannelsInfoChannel = requestNewChannel()
val myChannelsMessagesChannel = requestNewChannel()
val myChannelsCreatedbyMeChannel = requestNewChannel()
val chatroomListChannel = requestNewChannel()
// returns the last Note of each user.
override fun feed(): List<Note> {
val messages = account.userProfile().messages
val messagingWith = messages.keys().toList().filter { account.isAcceptable(it) }
val messagingWith = messages.keys.filter { account.isAcceptable(it) }
val privateMessages = messagingWith.mapNotNull {
val conversation = messages[it]
if (conversation != null) {
synchronized(conversation) {
conversation.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
}
} else {
null
}
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
}
val publicChannels = account.followingChannels().map {
@@ -85,12 +90,16 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
}
override fun updateChannelFilters() {
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
myChannelsChannel.filter = listOf(createMyChannelsFilter()).ifEmpty { null }
myChannelsInfoChannel.filter = createLastChannelInfoFilter().ifEmpty { null }
myChannelsMessagesChannel.filter = createLastMessageOfEachChannelFilter().ifEmpty { null }
val list = listOf(
createMessagesToMeFilter(),
createMessagesFromMeFilter(),
createMyChannelsFilter()
)
//myChannelsCreatedbyMeChannel.filter = listOf(createChannelsCreatedbyMeFilter()).ifEmpty { null }
chatroomListChannel.typedFilters = listOfNotNull(
list,
createLastChannelInfoFilter(),
createLastMessageOfEachChannelFilter()
).flatten().ifEmpty { null }
}
}

View File

@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.service
import android.os.Handler
import android.os.Looper
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@@ -15,10 +13,9 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.Subscription
import java.util.Date
import java.util.UUID
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -33,10 +30,8 @@ import nostr.postr.events.RecommendRelayEvent
import nostr.postr.events.TextNoteEvent
abstract class NostrDataSource<T>(val debugName: String) {
private val channels = Collections.synchronizedSet(mutableSetOf<Channel>())
private val channelIds = Collections.synchronizedSet(mutableSetOf<String>())
private val eventCounter = mutableMapOf<String, Int>()
private var subscriptions = mapOf<String, Subscription>()
private var eventCounter = mapOf<String, Int>()
fun printCounter() {
eventCounter.forEach {
@@ -46,12 +41,13 @@ abstract class NostrDataSource<T>(val debugName: String) {
private val clientListener = object : Client.Listener() {
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
if (subscriptionId in channelIds) {
if (subscriptionId in subscriptions.keys) {
val key = "${debugName} ${subscriptionId} ${event.kind}"
if (eventCounter.contains(key)) {
eventCounter.put(key, eventCounter.get(key)!! + 1)
val keyValue = eventCounter.get(key)
if (keyValue != null) {
eventCounter = eventCounter + Pair(key, keyValue + 1)
} else {
eventCounter.put(key, 1)
eventCounter = eventCounter + Pair(key, 1)
}
try {
@@ -60,7 +56,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
is TextNoteEvent -> LocalCache.consume(event, relay)
is RecommendRelayEvent -> LocalCache.consume(event)
is ContactListEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event)
is PrivateDmEvent -> LocalCache.consume(event, relay)
is DeletionEvent -> LocalCache.consume(event)
else -> when (event.kind) {
RepostEvent.kind -> {
@@ -74,7 +70,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
ChannelCreateEvent.kind -> LocalCache.consume(ChannelCreateEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMetadataEvent.kind -> LocalCache.consume(ChannelMetadataEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMessageEvent.kind -> LocalCache.consume(ChannelMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig), relay)
ChannelHideMessageEvent.kind -> LocalCache.consume(ChannelHideMessageEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
ChannelMuteUserEvent.kind -> LocalCache.consume(ChannelMuteUserEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
}
@@ -90,7 +86,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
//Log.e("ERROR", "Relay ${relay.url}: ${error.message}")
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
//Log.d("RELAY", "Relay ${relay.url} ${when (type) {
// Relay.Type.CONNECT -> "connected."
// Relay.Type.DISCONNECT -> "disconnected."
@@ -98,11 +94,10 @@ abstract class NostrDataSource<T>(val debugName: String) {
// Relay.Type.EOSE -> "sent all events it had stored."
//}}")
/*
if (type == Relay.Type.EOSE) {
// One everything is loaded, if new users are found, update filters
resetFilters()
}*/
if (type == Relay.Type.EOSE && channel != null) {
// updates a per subscripton since date
subscriptions[channel]?.updateEOSE(Date().time / 1000)
}
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
@@ -119,8 +114,8 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
open fun stop() {
channels.forEach { channel ->
if (channel.filter != null) // if it is active, close
subscriptions.values.forEach { channel ->
if (channel.typedFilters != null) // if it is active, close
Client.close(channel.id)
}
}
@@ -145,17 +140,15 @@ abstract class NostrDataSource<T>(val debugName: String) {
}
}
fun requestNewChannel(): Channel {
val newChannel = Channel(debugName+UUID.randomUUID().toString().substring(0,4))
channels.add(newChannel)
channelIds.add(newChannel.id)
return newChannel
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0,4), onEOSE)
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
return newSubscription
}
fun dismissChannel(channel: Channel) {
Client.close(channel.id)
channels.remove(channel)
channelIds.remove(channel.id)
fun dismissChannel(subscription: Subscription) {
Client.close(subscription.id)
subscriptions = subscriptions.minus(subscription.id)
}
var handlerWaiting = false
@@ -181,37 +174,37 @@ abstract class NostrDataSource<T>(val debugName: String) {
fun resetFiltersSuspend() {
// saves the channels that are currently active
val activeChannels = channels.filter { it.filter != null }
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
// saves the current content to only update if it changes
val currentFilter = activeChannels.associate { it.id to it.filter!!.joinToString("|") { it.toJson() } }
val currentFilters = activeSubscriptions.associate { it.id to it.toJson() }
updateChannelFilters()
// Makes sure to only send an updated filter when it actually changes.
channels.forEach { channel ->
val channelsNewFilter = channel.filter
subscriptions.values.forEach { updatedSubscription ->
val updatedSubscriotionNewFilters = updatedSubscription.typedFilters
if (channel in activeChannels) {
if (channelsNewFilter == null) {
if (updatedSubscription.id in currentFilters.keys) {
if (updatedSubscriotionNewFilters == null) {
// was active and is not active anymore, just close.
Client.close(channel.id)
Client.close(updatedSubscription.id)
} else {
// was active and is still active, check if it has changed.
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
Client.close(channel.id)
Client.sendFilter(channel.id, channelsNewFilter)
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.close(updatedSubscription.id)
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
} else {
// hasn't changed, does nothing.
Client.sendFilterOnlyIfDisconnected(channel.id, channelsNewFilter)
Client.sendFilterOnlyIfDisconnected(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
} else {
if (channelsNewFilter == null) {
if (updatedSubscriotionNewFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the filter.
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
Client.sendFilter(channel.id, channelsNewFilter)
if (updatedSubscription.toJson() != currentFilters[updatedSubscription.id]) {
Client.sendFilter(updatedSubscription.id, updatedSubscriotionNewFilters)
}
}
}

View File

@@ -4,14 +4,19 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
lateinit var account: Account
fun createGlobalFilter() = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
limit = 200
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
limit = 200
)
)
val globalFeedChannel = requestNewChannel()
@@ -26,6 +31,6 @@ object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
.reversed()
override fun updateChannelFilters() {
globalFeedChannel.filter = listOf(createGlobalFilter()).ifEmpty { null }
globalFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}

View File

@@ -4,11 +4,9 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
@@ -34,64 +32,38 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
account.userProfile().unsubscribe(cacheListener)
}
fun createFollowAccountsFilter(): JsonFilter {
val follows = account.userProfile().follows ?: emptySet()
fun createFollowAccountsFilter(): TypedFilter {
val follows = account.userProfile().follows
val followKeys = synchronized(follows) {
follows.map {
it.pubkey.toHex().substring(0, 6)
}
val followKeys = follows.map {
it.pubkey.toHex().substring(0, 6)
}
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = followSet,
limit = 400
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
authors = followSet,
limit = 400
)
)
}
val followAccountChannel = requestNewChannel()
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return list1.size == list2.size && list1.toSet() == list2.toSet()
}
fun equalAuthors(list1:JsonFilter?, list2:JsonFilter?): Boolean {
if (list1 == null && list2 == null) return true
if (list1 == null) return false
if (list2 == null) return false
return equalsIgnoreOrder(list1.authors, list2.authors)
}
override fun feed(): List<Note> {
val user = account.userProfile()
val follows = user.follows
val followKeys = synchronized(follows) {
follows.map { it.pubkeyHex }
}
val allowSet = followKeys.plus(user.pubkeyHex).toSet()
return LocalCache.notes.values
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
.filter { account.isAcceptable(it) }
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
val newFollowAccountsFilter = createFollowAccountsFilter()
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter?.firstOrNull())) {
followAccountChannel.filter = listOf(newFollowAccountsFilter).ifEmpty { null }
}
followAccountChannel.typedFilters = listOf(createFollowAccountsFilter()).ifEmpty { null }
}
}

View File

@@ -3,28 +3,26 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import nostr.postr.JsonFilter
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
lateinit var account: Account
fun createNotificationFilter() = JsonFilter(
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 100
)
val notificationChannel = requestNewChannel()
override fun feed(): List<Note> {
val set = account.userProfile().taggedPosts
val filtered = synchronized(set) {
set.filter { it.event != null }.filter { account.isAcceptable(it) }
}
return filtered.sortedBy { it.event?.createdAt }.reversed()
return account.userProfile().taggedPosts
.filter { it.event != null }
.filter { account.isAcceptable(it) }
.filter {
it.event !is ChannelCreateEvent
&& it.event !is ChannelMetadataEvent
}
.sortedBy { it.event?.createdAt }
.reversed()
}
override fun updateChannelFilters() {
notificationChannel.filter = listOf(createNotificationFilter()).ifEmpty { null }
}
override fun updateChannelFilters() {}
}

View File

@@ -1,31 +1,38 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.bechToBytes
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
import nostr.postr.toHex
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var hexToWatch: String? = null
private fun createAnythingWithIDFilter(): List<JsonFilter>? {
private fun createAnythingWithIDFilter(): List<TypedFilter>? {
if (hexToWatch == null) {
return null
}
// downloads all the reactions to a given event.
return listOf(
JsonFilter(
ids = listOfNotNull(hexToWatch)
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = listOfNotNull(hexToWatch)
)
),
JsonFilter(
authors = listOfNotNull(hexToWatch)
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOfNotNull(hexToWatch)
)
)
)
}
@@ -37,7 +44,7 @@ object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed"
}
override fun updateChannelFilters() {
searchChannel.filter = createAnythingWithIDFilter()
searchChannel.typedFilters = createAnythingWithIDFilter()
}
fun search(eventId: String) {

View File

@@ -3,18 +3,15 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed") {
private var channelsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
private fun createRepliesAndReactionsFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
@@ -22,13 +19,16 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
}
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to reactionsToWatch)
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter(
kinds = listOf(ChannelMetadataEvent.kind),
tags = mapOf("e" to reactionsToWatch)
)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch
.map { LocalCache.getOrCreateChannel(it) }
.filter { it.notes.isEmpty() }
@@ -40,14 +40,16 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
}
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ChannelCreateEvent.kind),
ids = interestedEvents.toList()
)
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
val singleChannelChannel = requestNewChannel()
override fun feed(): List<Note> {
return emptyList()
@@ -57,8 +59,7 @@ object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed")
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
singleChannelChannel.typedFilters = listOfNotNull(reactions, missing).ifEmpty { null }
}
fun add(eventId: String) {

View File

@@ -8,30 +8,42 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import java.util.Date
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
private var eventsToWatch = setOf<String>()
private fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it }
private fun createRepliesAndReactionsFilter(): List<TypedFilter>? {
val reactionsToWatch = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
if (reactionsToWatch.isEmpty()) {
return null
}
// downloads all the reactions to a given event.
return JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
),
tags = mapOf("e" to reactionsToWatch)
)
val now = Date().time / 1000
return reactionsToWatch.filter {
val lastTime = it.lastReactionsDownloadTime;
lastTime == null || lastTime < (now - 10)
}.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
)
)
}
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
fun createLoadEventsIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = eventsToWatch
.map { LocalCache.getOrCreateNote(it) }
.filter { it.event == null }
@@ -51,17 +63,28 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
}
// downloads linked events to this event.
return JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
return listOf(
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind
),
ids = interestedEvents.toList()
)
)
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
val singleEventChannel = requestNewChannel() { time ->
eventsToWatch.forEach {
LocalCache.getOrCreateNote(it).lastReactionsDownloadTime = time
}
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun feed(): List<Note> {
return synchronized(eventsToWatch) {
@@ -75,8 +98,7 @@ object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
val reactions = createRepliesAndReactionsFilter()
val missing = createLoadEventsIfNotLoadedFilter()
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
singleEventChannel.typedFilters = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
}
fun add(eventId: String) {

View File

@@ -1,41 +1,50 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.MetadataEvent
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
var usersToWatch = setOf<String>()
fun createUserFilter(): List<JsonFilter>? {
fun createUserFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it),
limit = 1
return usersToWatch.filter { LocalCache.getOrCreateUser(it).latestMetadata == null }.map {
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(it),
limit = 1
)
)
}
}
fun createUserReportFilter(): List<JsonFilter>? {
fun createUserReportFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null
return usersToWatch.map {
JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it))
)
)
}
}
val userChannel = requestNewChannel()
val userReportChannel = requestNewChannel()
val userChannel = requestNewChannel(){
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
}
override fun feed(): List<User> {
return synchronized(usersToWatch) {
@@ -46,8 +55,7 @@ object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
}
override fun updateChannelFilters() {
userChannel.filter = createUserFilter()
userReportChannel.filter = createUserReportFilter()
userChannel.typedFilters = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
}
fun add(userId: String) {

View File

@@ -4,33 +4,32 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.Collections
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.TextNoteEvent
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
private var eventsToWatch = setOf<String>()
fun createRepliesAndReactionsFilter(): JsonFilter? {
val reactionsToWatch = eventsToWatch.map { it }
if (reactionsToWatch.isEmpty()) {
fun createRepliesAndReactionsFilter(): TypedFilter? {
if (eventsToWatch.isEmpty()) {
return null
}
return JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to reactionsToWatch)
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
tags = mapOf("e" to eventsToWatch.toList())
)
)
}
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
val nodes = synchronized(eventsToWatch) {
eventsToWatch.map { LocalCache.notes[it] }
}
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val nodes = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
val eventsToLoad = nodes
.filterNotNull()
.filter { it.event == null }
.map { it.idHex.substring(0, 8) }
@@ -38,25 +37,27 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
return null
}
return JsonFilter(
ids = eventsToLoad
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
ids = eventsToLoad
)
)
}
val repliesAndReactionsChannel = requestNewChannel()
val loadEventsChannel = requestNewChannel()
override fun feed(): List<Note> {
return synchronized(eventsToWatch) {
eventsToWatch.map {
LocalCache.notes[it]
}.filterNotNull()
}
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
return eventsToWatch.map {
LocalCache.getOrCreateNote(it)
}.sortedWith(order)
}
override fun updateChannelFilters() {
repliesAndReactionsChannel.filter = listOfNotNull(createRepliesAndReactionsFilter()).ifEmpty { null }
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
loadEventsChannel.typedFilters = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
}
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
@@ -86,9 +87,9 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
}
fun loadThread(noteId: String) {
val note = LocalCache.notes[noteId]
val note = LocalCache.getOrCreateNote(noteId)
if (note != null) {
if (note.event != null) {
val thread = mutableListOf<Note>()
val threadSet = mutableSetOf<Note>()
@@ -96,17 +97,12 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
loadDown(threadRoot, thread, threadSet)
// Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature() }
eventsToWatch.clear()
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
eventsToWatch = thread.map { it.idHex }.toSet()
} else {
eventsToWatch.clear()
eventsToWatch.add(noteId)
eventsToWatch = setOf(noteId)
}
resetFilters()
invalidateFilters()
}
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
@@ -114,9 +110,7 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
thread.add(note)
threadSet.add(note)
synchronized(note.replies) {
note.replies.toList()
}.forEach {
note.replies.forEach {
loadDown(it, thread, threadSet)
}
}

View File

@@ -4,8 +4,10 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import nostr.postr.JsonFilter
import nostr.postr.events.ContactListEvent
import nostr.postr.events.MetadataEvent
import nostr.postr.events.TextNoteEvent
@@ -14,39 +16,67 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
var user: User? = null
fun loadUserProfile(userId: String) {
user = LocalCache.getOrCreateUser(userId.toByteArray())
user = LocalCache.getOrCreateUser(userId)
resetFilters()
}
fun createUserInfoFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
fun createUserInfoFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
)
}
fun createUserPostsFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 100
fun createUserPostsFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 100
)
)
}
fun createFollowFilter(): TypedFilter {
return TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
)
}
fun createFollowersFilter() = TypedFilter(
types = FeedType.values().toSet(),
filter = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
)
val userInfoChannel = requestNewChannel()
val notesChannel = requestNewChannel()
override fun feed(): List<Note> {
val notes = user?.notes ?: return emptyList()
val sortedNotes = synchronized(notes) {
notes.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }
}
return sortedNotes.reversed()
return user?.notes
?.filter { account.isAcceptable(it) }
?.sortedBy { it.event?.createdAt }
?.reversed()
?: emptyList()
}
override fun updateChannelFilters() {
userInfoChannel.filter = listOf(createUserInfoFilter()).ifEmpty { null }
notesChannel.filter = listOf(createUserPostsFilter()).ifEmpty { null }
userInfoChannel.typedFilters = listOf(
createUserInfoFilter(),
createUserPostsFilter(),
createFollowFilter(),
createFollowersFilter()
).ifEmpty { null }
}
}

View File

@@ -15,22 +15,9 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
resetFilters()
}
fun createFollowersFilter() = JsonFilter(
kinds = listOf(ContactListEvent.kind),
tags = mapOf("p" to listOf(user!!.pubkeyHex))
)
val followerChannel = requestNewChannel()
override fun feed(): List<User> {
val followers = user?.followers ?: emptyList()
return synchronized(followers) {
followers.filter { account.isAcceptable(it) }.toList()
}
return user?.followers?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {
followerChannel.filter = listOf(createFollowersFilter()).ifEmpty { null }
}
override fun updateChannelFilters() {}
}

View File

@@ -15,25 +15,9 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
resetFilters()
}
fun createFollowFilter(): JsonFilter {
return JsonFilter(
kinds = listOf(ContactListEvent.kind),
authors = listOf(user!!.pubkeyHex),
limit = 1
)
}
val followChannel = requestNewChannel()
override fun feed(): List<User> {
val follows = user?.follows ?: emptyList()
return synchronized(follows) {
follows.filter { account.isAcceptable(it) }.toList()
}
return user?.follows?.filter { account.isAcceptable(it) } ?: emptyList()
}
override fun updateChannelFilters() {
followChannel.filter = listOf(createFollowFilter()).ifEmpty { null }
}
override fun updateChannelFilters() {}
}

View File

@@ -0,0 +1,84 @@
package com.vitorpamplona.amethyst.service.lang
import android.util.LruCache
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.Translator
import com.google.mlkit.nl.translate.TranslatorOptions
import java.util.ArrayList
class ResultOrError(
var result: String?,
var sourceLang: String?,
var targetLang: String?,
var error: Exception?
)
object LanguageTranslatorService {
private val languageIdentification = LanguageIdentification.getClient()
private val translators =
object : LruCache<TranslatorOptions, Translator>(10) {
override fun create(options: TranslatorOptions): Translator {
return Translation.getClient(options)
}
override fun entryRemoved(
evicted: Boolean,
key: TranslatorOptions,
oldValue: Translator,
newValue: Translator?
) {
oldValue.close()
}
}
fun identifyLanguage(text: String): Task<String> {
return languageIdentification.identifyLanguage(text)
}
fun translate(text: String, source: String, target: String): Task<ResultOrError> {
val sourceLangCode = TranslateLanguage.fromLanguageTag(source)
val targetLangCode = TranslateLanguage.fromLanguageTag(target)
if (sourceLangCode == null || targetLangCode == null) {
return Tasks.forCanceled()
}
val options = TranslatorOptions.Builder()
.setSourceLanguage(sourceLangCode)
.setTargetLanguage(targetLangCode)
.build()
val translator = translators[options]
return translator.downloadModelIfNeeded().onSuccessTask {
val tasks = mutableListOf<Task<String>>()
for (paragraph in text.split("\n")) {
tasks.add(translator.translate(paragraph))
}
Tasks.whenAll(tasks).continueWith {
val results: MutableList<String> = ArrayList()
for (task in tasks) {
results.add(task.result)
}
ResultOrError(results.joinToString("\n"), source, target, null)
}
}
}
fun autoTranslate(text: String, dontTranslateFrom: Set<String>, translateTo: String): Task<ResultOrError> {
return identifyLanguage(text).onSuccessTask {
if (it == translateTo) {
Tasks.forCanceled()
} else if (it != "und" && !dontTranslateFrom.contains(it)) {
translate(text, it, translateTo)
} else {
Tasks.forCanceled()
}
}
}
}

View File

@@ -1,10 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import nostr.postr.JsonFilter
import nostr.postr.events.Event
/**
@@ -24,10 +20,10 @@ object Client: RelayPool.Listener {
**/
var lenient: Boolean = false
private var listeners = setOf<Listener>()
private var relays = Constants.defaultRelays
private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
private var relays = Constants.convertDefaultRelays()
private var subscriptions = mapOf<String, List<TypedFilter>>()
fun connect(relays: Array<Relay> = Constants.defaultRelays) {
fun connect(relays: Array<Relay>) {
RelayPool.register(this)
RelayPool.unloadRelays()
RelayPool.loadRelays(relays.toList())
@@ -36,17 +32,17 @@ object Client: RelayPool.Listener {
fun sendFilter(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<JsonFilter> = listOf(JsonFilter())
filters: List<TypedFilter> = listOf()
) {
subscriptions[subscriptionId] = filters
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilter(subscriptionId)
}
fun sendFilterOnlyIfDisconnected(
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
filters: List<JsonFilter> = listOf(JsonFilter())
filters: List<TypedFilter> = listOf()
) {
subscriptions[subscriptionId] = filters
subscriptions = subscriptions + Pair(subscriptionId, filters)
RelayPool.sendFilterOnlyIfDisconnected()
}
@@ -72,8 +68,8 @@ object Client: RelayPool.Listener {
listeners.forEach { it.onError(error, subscriptionId, relay) }
}
override fun onRelayStateChange(type: Relay.Type, relay: Relay) {
listeners.forEach { it.onRelayStateChange(type, relay) }
override fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
}
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
@@ -89,12 +85,10 @@ object Client: RelayPool.Listener {
}
fun allSubscriptions(): List<String> {
return synchronized(subscriptions) {
subscriptions.keys.toList()
}
return subscriptions.keys.toList()
}
fun getSubscriptionFilters(subId: String): List<JsonFilter> {
fun getSubscriptionFilters(subId: String): List<TypedFilter> {
return subscriptions[subId] ?: emptyList()
}
@@ -112,7 +106,7 @@ object Client: RelayPool.Listener {
/**
* Connected to or disconnected from a relay
*/
open fun onRelayStateChange(type: Relay.Type, relay: Relay) = Unit
open fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) = Unit
/**
* When an relay saves or rejects a new event.

View File

@@ -0,0 +1,48 @@
package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
fun convertDefaultRelays(): Array<Relay> {
return defaultRelays.map {
Relay(it.url, it.read, it.write, it.feedTypes)
}.toTypedArray()
}
val defaultRelays = arrayOf(
// Free relays
RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
// Less Reliable
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.onsats.org", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.sandwich.farm", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://relay.nostr.ch", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.zebedee.cloud", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.rocks", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://nostr.fmt.wiz.biz", read = true, write = true, feedTypes = activeTypes),
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays
RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
)
}

View File

@@ -2,29 +2,41 @@ package com.vitorpamplona.amethyst.service.relays
import android.util.Log
import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.model.LocalCache
import java.util.Date
import nostr.postr.events.ContactListEvent
import java.util.concurrent.TimeUnit
import nostr.postr.events.Event
import nostr.postr.toNpub
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
enum class FeedType {
FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL
}
class Relay(
var url: String,
var read: Boolean = true,
var write: Boolean = true
var url: String,
var read: Boolean = true,
var write: Boolean = true,
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
) {
private val httpClient = OkHttpClient()
private val httpClient = OkHttpClient.Builder()
.connectTimeout(100, TimeUnit.SECONDS)
.readTimeout(100, TimeUnit.SECONDS)
.callTimeout(100, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build();
private var listeners = setOf<Listener>()
private var socket: WebSocket? = null
private var isReady: Boolean = false
var eventDownloadCounter = 0
var eventUploadCounter = 0
var errorCounter = 0
var ping: Long? = null
var closingTime = 0L
@@ -40,17 +52,24 @@ class Relay(
return socket != null
}
@Synchronized
fun requestAndWatch() {
if (socket != null) return
try {
val request = Request.Builder().url(url.trim()).build()
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
}
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT) }
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -60,21 +79,26 @@ class Relay(
val channel = msg[1].asString
when (type) {
"EVENT" -> {
//Log.w("Relay", "Relay onEVENT $url, $channel")
eventDownloadCounter++
val event = Event.fromJson(msg[2], Client.lenient)
listeners.forEach { it.onEvent(this@Relay, channel, event) }
}
"EOSE" -> listeners.forEach {
it.onRelayStateChange(this@Relay, Type.EOSE)
//Log.w("Relay", "Relay onEOSE $url, $channel")
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
//Log.w("Relay", "Relay onNotice $url, $channel")
// "channel" being the second string in the string array ...
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
}
"OK" -> listeners.forEach {
//Log.w("Relay", "Relay onOK $url, $channel")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
else -> listeners.forEach {
//Log.w("Relay", "Relay something else $url, $channel")
it.onError(
this@Relay,
channel,
@@ -91,13 +115,18 @@ class Relay(
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECTING) }
listeners.forEach { it.onRelayStateChange(
this@Relay,
Type.DISCONNECTING,
null
) }
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
socket = null
isReady = false
closingTime = Date().time / 1000
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT) }
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
@@ -106,10 +135,11 @@ class Relay(
socket?.close(1000, "Normal close")
// Failures disconnect the relay.
socket = null
isReady = false
closingTime = Date().time / 1000
Log.w("Relay", "Relay onFailure ${url}, ${response?.message}")
//t.printStackTrace()
Log.w("Relay", "Relay onFailure $url, ${response?.message} ${response}")
t.printStackTrace()
listeners.forEach {
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
}
@@ -119,7 +149,7 @@ class Relay(
socket = httpClient.newWebSocket(request, listener)
} catch (e: Exception) {
closingTime = Date().time / 1000
Log.e("Relay", "Relay Invalid ${url}")
Log.e("Relay", "Relay Invalid $url")
e.printStackTrace()
}
}
@@ -129,22 +159,26 @@ class Relay(
closingTime = Date().time / 1000
socket?.close(1000, "Normal close")
socket = null
isReady = false
}
fun sendFilter(requestId: String) {
if (read) {
if (socket == null) {
// waits 10 seconds
if (Date().time / 1000 > closingTime + 10) {
requestAndWatch()
if (isConnected()) {
if (isReady) {
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
//println("FILTERSSENT ${url} ${request}")
socket?.send(request)
}
}
} else {
val filters = Client.getSubscriptionFilters(requestId)
if (filters.isNotEmpty()) {
val request =
"""["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
//println("FILTERSSENT ${url} " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
socket?.send(request)
// waits 10 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 10) {
// sends all filters after connection is successful.
requestAndWatch()
}
}
}
@@ -152,6 +186,7 @@ class Relay(
fun sendFilterOnlyIfDisconnected() {
if (socket == null) {
//println("sendfilter Only if Disconnected ${url} ")
requestAndWatch()
}
}
@@ -192,6 +227,6 @@ class Relay(
*
* @param type is 0 for disconnect and 1 for connect
*/
fun onRelayStateChange(relay: Relay, type: Type)
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
}
}

View File

@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.Constants
import java.util.Collections
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -13,6 +11,9 @@ import nostr.postr.events.Event
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
*/
object RelayPool: Relay.Listener {
val scope = CoroutineScope(Job() + Dispatchers.IO)
private var relays = listOf<Relay>()
private var listeners = setOf<Listener>()
@@ -28,11 +29,11 @@ object RelayPool: Relay.Listener {
return relays.firstOrNull() { it.url == url }
}
fun loadRelays(relayList: List<Relay>? = null){
fun loadRelays(relayList: List<Relay>){
if (!relayList.isNullOrEmpty()){
relayList.forEach { addRelay(it) }
} else {
Constants.defaultRelays.forEach { addRelay(it) }
Constants.convertDefaultRelays().forEach { addRelay(it) }
}
}
@@ -88,7 +89,7 @@ object RelayPool: Relay.Listener {
fun onError(error: Error, subscriptionId: String, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay)
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
}
@@ -103,8 +104,8 @@ object RelayPool: Relay.Listener {
refreshObservers()
}
override fun onRelayStateChange(relay: Relay, type: Relay.Type) {
listeners.forEach { it.onRelayStateChange(type, relay) }
override fun onRelayStateChange(relay: Relay, type: Relay.Type, channel: String?) {
listeners.forEach { it.onRelayStateChange(type, relay, channel) }
refreshObservers()
}
@@ -116,7 +117,6 @@ object RelayPool: Relay.Listener {
val live: RelayPoolLiveData = RelayPoolLiveData(this)
private fun refreshObservers() {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
live.refresh()
}

View File

@@ -0,0 +1,30 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import java.util.UUID
data class Subscription (
val id: String = UUID.randomUUID().toString().substring(0,4),
val onEOSE: ((Long) -> Unit)? = null
) {
var typedFilters: List<TypedFilter>? = null // Inactive when null
fun updateEOSE(l: Long) {
onEOSE?.let { it(l) }
}
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.addProperty("id", id)
typedFilters?.run {
jsonObject.add("typedFilters", JsonArray().apply { typedFilters?.forEach { add(it.toJsonObject()) } })
}
return jsonObject
}
}

View File

@@ -0,0 +1,56 @@
package com.vitorpamplona.amethyst.service.relays
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import nostr.postr.JsonFilter
class TypedFilter(
val types: Set<FeedType>,
val filter: JsonFilter
) {
fun toJson(): String {
return GsonBuilder().create().toJson(toJsonObject())
}
fun toJsonObject(): JsonObject {
val jsonObject = JsonObject()
jsonObject.add("types", typesToJson(types))
jsonObject.add("filter", filterToJson(filter))
return jsonObject
}
fun typesToJson(types: Set<FeedType>): JsonArray {
return JsonArray().apply { types.forEach { add(it.name.toLowerCase()) } }
}
fun filterToJson(filter: JsonFilter): JsonObject {
val jsonObject = JsonObject()
filter.ids?.run {
jsonObject.add("ids", JsonArray().apply { filter.ids?.forEach { add(it) } })
}
filter.authors?.run {
jsonObject.add("authors", JsonArray().apply { filter.authors?.forEach { add(it) } })
}
filter.kinds?.run {
jsonObject.add("kinds", JsonArray().apply { filter.kinds?.forEach { add(it) } })
}
filter.tags?.run {
entries.forEach { kv ->
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
}
}
filter.since?.run {
jsonObject.addProperty("since", filter.since)
}
filter.until?.run {
jsonObject.addProperty("until", filter.until)
}
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
return jsonObject
}
}

View File

@@ -0,0 +1,147 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import okhttp3.*
import okio.BufferedSource
import okio.IOException
import okio.sink
import java.io.File
object ImageSaver {
/**
* Saves the image to the gallery.
* May require a storage permission.
*
* @see PICTURES_SUBDIRECTORY
*/
fun saveImage(
url: String,
context: Context,
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
val client = OkHttpClient.Builder().build()
val request = Request.Builder()
.get()
.url(url)
.build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
onError(e)
}
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentType = response.header("Content-Type")
checkNotNull(contentType) {
"Can't find out the content type"
}
saveContentQ(
displayName = File(url).nameWithoutExtension,
contentType = contentType,
contentSource = response.body.source(),
contentResolver = context.contentResolver,
)
} else {
saveContentDefault(
fileName = File(url).name,
contentSource = response.body.source(),
context = context,
)
}
onSuccess()
} catch (e: Exception) {
e.printStackTrace()
onError(e)
}
}
})
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
contentType: String,
contentSource: BufferedSource,
contentResolver: ContentResolver,
) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, contentType)
put(
MediaStore.MediaColumns.RELATIVE_PATH,
Environment.DIRECTORY_PICTURES + File.separatorChar + PICTURES_SUBDIRECTORY
)
}
val uri =
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
try {
val outputStream = contentResolver.openOutputStream(uri)
checkNotNull(outputStream) {
"Can't open the content output stream"
}
outputStream.use {
contentSource.readAll(it.sink())
}
} catch (e: Exception) {
contentResolver.delete(uri, null, null)
throw e
}
}
private fun saveContentDefault(
fileName: String,
contentSource: BufferedSource,
context: Context,
) {
val subdirectory = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
PICTURES_SUBDIRECTORY
)
if (!subdirectory.exists()) {
subdirectory.mkdirs()
}
val outputFile = File(subdirectory, fileName)
outputFile
.outputStream()
.use {
contentSource.readAll(it.sink())
}
// Call the media scanner manually, so the image
// appears in the gallery faster.
context.sendBroadcast(
Intent(
Intent.ACTION_MEDIA_SCANNER_SCAN_FILE,
outputFile.toUri()
)
)
}
private const val PICTURES_SUBDIRECTORY = "Amethyst"
}

View File

@@ -43,9 +43,13 @@ object ImageUploader {
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
val tree = jacksonObjectMapper().readTree(response.body!!.string())
val url = tree.get("data").get("link").asText()
onSuccess(url)
val body = response.body
if (body != null) {
val tree = jacksonObjectMapper().readTree(body.string())
val url = tree?.get("data")?.get("link")?.asText()
if (url != null)
onSuccess(url)
}
}
}

View File

@@ -16,6 +16,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import nostr.postr.toNpub
@@ -25,7 +26,7 @@ class NewPostViewModel: ViewModel() {
private var originalNote: Note? = null
var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<MutableList<Note>?>(null)
var replyTos by mutableStateOf<List<Note>?>(null)
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
@@ -36,7 +37,7 @@ class NewPostViewModel: ViewModel() {
fun load(account: Account, replyingTo: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
this.replyTos = (replyNote.replyTo ?: mutableListOf()).plus(replyNote).toMutableList()
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
replyNote.author?.let { replyUser ->
val currentMentions = replyNote.mentions ?: emptyList()
if (currentMentions.contains(replyUser)) {
@@ -78,7 +79,7 @@ class NewPostViewModel: ViewModel() {
val restOfWord = word.substring(64)
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key)
val user = LocalCache.getOrCreateUser(key.toHexKey())
val index = addUserToMentionsIfNotInAndReturnIndex(user)

View File

@@ -1,25 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Colors
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
@@ -28,15 +22,12 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -45,28 +36,32 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel()
val ctx = LocalContext.current.applicationContext
val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) {
postViewModel.load(account)
postViewModel.load(account, ctx)
}
Dialog(
@@ -82,19 +77,18 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
modifier = Modifier.padding(10.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
postViewModel.clear()
postViewModel.clear(ctx)
onClose()
})
PostButton(
onPost = {
postViewModel.create()
postViewModel.create(ctx)
onClose()
},
true
@@ -103,7 +97,7 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
Spacer(modifier = Modifier.height(10.dp))
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
@@ -114,15 +108,15 @@ fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String =
if (index == 0)
ServerConfigHeader()
ServerConfig(item,
onToggleDownload = {
postViewModel.toggleDownload(it)
},
onToggleUpload = {
postViewModel.toggleUpload(it)
},
onDelete = {
postViewModel.deleteRelay(it)
}
onToggleDownload = { postViewModel.toggleDownload(it) },
onToggleUpload = { postViewModel.toggleUpload(it) },
onToggleFollows = { postViewModel.toggleFollows(it) },
onTogglePrivateDMs = { postViewModel.toggleMessages(it) },
onTogglePublicChats = { postViewModel.togglePublicChats(it) },
onToggleGlobal = { postViewModel.toggleGlobal(it) },
onDelete = { postViewModel.deleteRelay(it) }
)
}
}
@@ -156,7 +150,7 @@ fun ServerConfigHeader() {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(25.dp))
Text(
text = "Posts",
@@ -186,7 +180,7 @@ fun ServerConfigHeader() {
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Spacer(modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.size(5.dp))
}
}
}
@@ -199,12 +193,35 @@ fun ServerConfigHeader() {
@Composable
fun ServerConfig(
item: NewRelayListViewModel.Relay,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
item: RelaySetupInfo,
onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (RelaySetupInfo) -> Unit,
onDelete: (RelaySetupInfo) -> Unit) {
Column(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp)
) {
Column() {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(end = 5.dp).size(15.dp),
tint = Color.Red
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -214,75 +231,126 @@ fun ServerConfig(
overflow = TextOverflow.Ellipsis
)
}
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.read) Color.Green else Color.Red
)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleFollows(item) }
) {
Icon(
painterResource(R.drawable.ic_home),
"Home Feed",
modifier = Modifier.padding(end = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.FOLLOWS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePrivateDMs(item) }
) {
Icon(
painterResource(R.drawable.ic_dm),
"Private Message Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PRIVATE_DMS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onTogglePublicChats(item) }
) {
Icon(
imageVector = Icons.Default.Groups,
"Public Chat Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.PUBLIC_CHATS)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleGlobal(item) }
) {
Icon(
imageVector = Icons.Default.Public,
"Global Feed",
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.feedTypes.contains(FeedType.GLOBAL)) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
}
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleDownload(item) }
) {
Icon(
imageVector = Icons.Default.Download,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.read) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.write) Color.Green else Color.Red
)
}
Text(
text = "${countToHumanReadable(item.downloadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Text(
text = "${countToHumanReadable(item.uploadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onToggleUpload(item) }
) {
Icon(
imageVector = Icons.Default.Upload,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.write) Color.Green else MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
)
}
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
Text(
text = "${countToHumanReadable(item.uploadCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
Icon(
imageVector = Icons.Default.SyncProblem,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = if (item.errorCount > 0) Color.Yellow else Color.Green
)
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) }
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier = Modifier.padding(horizontal = 5.dp).size(15.dp),
tint = Color.Red
)
Text(
text = "${countToHumanReadable(item.errorCount)}",
maxLines = 1,
fontSize = 14.sp,
modifier = Modifier.weight(1f),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}
@@ -295,7 +363,7 @@ fun ServerConfig(
}
@Composable
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) }
@@ -323,7 +391,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (read) Color.Green else Color.Red
tint = if (read) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@@ -334,15 +402,16 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
modifier = Modifier
.size(35.dp)
.padding(horizontal = 5.dp),
tint = if (write) Color.Green else Color.Red
tint = if (write) Color.Green else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Button(
onClick = {
if (url.isNotBlank()) {
val addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write))
if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://") && !url.startsWith("ws://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = ""
write = true
read = true
@@ -351,7 +420,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else Color.Gray
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Text(text = "Add", color = Color.White)

View File

@@ -1,94 +1,121 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Constants
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import nostr.postr.events.ContactListEvent
class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account
data class Relay(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val relays = _relays.asStateFlow()
fun load(account: Account) {
fun load(account: Account, ctx: Context) {
this.account = account
clear()
clear(ctx)
}
fun create() {
fun create(ctx: Context) {
relays.let {
account.sendNewRelayList(it.value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
account.saveRelayList(it.value)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
clear()
clear(ctx)
}
fun clear() {
fun clear(ctx: Context) {
_relays.update {
val relayFile = account.userProfile().relays
if (relayFile != null)
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter)
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed()
else
Constants.defaultRelays.map {
account.localRelays.map {
val liveRelay = RelayPool.getRelay(it.url)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter)
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed()
}
}
fun addRelay(relay: Relay) {
if (relays.value.filter { it.url == relay.url }.isNotEmpty()) return
fun addRelay(relay: RelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return
_relays.update {
it.plus(relay)
}
}
fun deleteRelay(relay: Relay) {
fun deleteRelay(relay: RelaySetupInfo) {
_relays.update {
it.minus(relay)
}
}
fun toggleDownload(relay: Relay) {
fun toggleDownload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(read = !relay.read))
}
}
fun toggleUpload(relay: Relay) {
fun toggleUpload(relay: RelaySetupInfo) {
_relays.update {
it.updated(relay, relay.copy(write = !relay.write))
}
}
fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes))
}
}
fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes ))
}
}
}
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
fun <T> togglePresenceInSet(set: Set<T>, item: T): Set<T> {
return if (set.contains(item)) set.minus(item) else set.plus(item)
}

View File

@@ -205,6 +205,22 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
singleLine = true
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = "LN URL (outdated)") },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.lnURL.value,
onValueChange = { postViewModel.lnURL.value = it },
placeholder = {
Text(
text = "LNURL...",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
singleLine = true
)
}
}
}

View File

@@ -21,6 +21,7 @@ class NewUserMetadataViewModel: ViewModel() {
val website = mutableStateOf("")
val nip05 = mutableStateOf("")
val lnAddress = mutableStateOf("")
val lnURL = mutableStateOf("")
fun load(account: Account) {
this.account = account
@@ -34,6 +35,7 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = it.info.website ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
}
}
@@ -57,6 +59,7 @@ class NewUserMetadataViewModel: ViewModel() {
currentJson.put("about", about.value)
currentJson.put("nip05", nip05.value)
currentJson.put("lud16", lnAddress.value)
currentJson.put("lud06", lnURL.value)
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
@@ -75,5 +78,6 @@ class NewUserMetadataViewModel: ViewModel() {
website.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
}
}

View File

@@ -0,0 +1,83 @@
package com.vitorpamplona.amethyst.ui.actions
import android.Manifest
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.launch
/**
* A button to save the remote image to the gallery.
* May require a storage permission.
*
* @param url URL of the image
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SaveToGallery(url: String) {
val localContext = LocalContext.current
val scope = rememberCoroutineScope()
fun saveImage() {
ImageSaver.saveImage(
context = localContext,
url = url,
onSuccess = {
scope.launch {
Toast.makeText(
localContext,
"Image saved to the gallery",
Toast.LENGTH_SHORT
)
.show()
}
},
onError = {
scope.launch {
Toast.makeText(
localContext,
"Failed to save the image",
Toast.LENGTH_SHORT
)
.show()
}
}
)
}
val writeStoragePermissionState = rememberPermissionState(
Manifest.permission.WRITE_EXTERNAL_STORAGE
) { isGranted ->
if (isGranted) {
saveImage()
}
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
saveImage()
} else {
writeStoragePermissionState.launchPermissionRequest()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = Color.Gray
)
) {
Text(text = "Save", color = Color.White)
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
@@ -24,9 +25,14 @@ import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.READ_MEDIA_IMAGES
)
val cameraPermissionState =
rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
android.Manifest.permission.READ_MEDIA_IMAGES
} else {
android.Manifest.permission.READ_EXTERNAL_STORAGE
}
)
if (cameraPermissionState.status.isGranted) {
var showGallerySelect by remember { mutableStateOf(false) }

View File

@@ -12,6 +12,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import kotlin.math.roundToInt
data class RangesChanges(val original: TextRange, val modified: TextRange)
@@ -43,7 +44,7 @@ fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color)
val endIndex = startIndex + keyB32.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key)
val user = LocalCache.getOrCreateUser(key.toHexKey())
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length

View File

@@ -23,8 +23,9 @@ fun ClickableRoute(
navController: NavController
) {
if (nip19.type == Nip19.Type.USER) {
val userBase = LocalCache.getOrCreateUser(nip19.hex.toByteArray())
val userState by userBase.live.observeAsState()
val userBase = LocalCache.getOrCreateUser(nip19.hex)
val userState by userBase.liveMetadata.observeAsState()
val user = userState?.user ?: return
val route = "User/${nip19.hex}"
@@ -48,8 +49,8 @@ fun ClickableRoute(
)
} else if (note.channel != null) {
ClickableText(
text = AnnotatedString("@${note.channel} "),
onClick = { navController.navigate("Channel/${nip19.hex}") },
text = AnnotatedString("@${note.channel?.toBestDisplayName()} "),
onClick = { navController.navigate("Channel/${note.channel?.idHex}") },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
} else {

View File

@@ -15,7 +15,7 @@ fun ClickableUserTag(
user: User,
navController: NavController
) {
val innerUserState by user.live.observeAsState()
val innerUserState by user.liveMetadata.observeAsState()
ClickableText(
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },

View File

@@ -0,0 +1,70 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@Composable
fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
navController: NavController
) {
var showFullText by remember { mutableStateOf(false) }
val text = if (showFullText) content else content.take(350)
Box(contentAlignment = Alignment.BottomCenter) {
RichTextViewer(text, canPreview, tags, navController)
if (content.length > 350 && !showFullText) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colors.background.copy(alpha = 0f),
MaterialTheme.colors.background
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { showFullText = !showFullText },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show More", color = Color.White)
}
}
}
}
}

View File

@@ -9,13 +9,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -23,11 +30,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import java.text.NumberFormat
@Composable
fun InvoicePreview(lnInvoice: String) {
@@ -78,20 +89,21 @@ fun InvoicePreview(lnInvoice: String) {
amount?.let {
Text(
text = "${amount.toInt()} sats",
text = "${NumberFormat.getInstance().format(amount)} sats",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
)
}
}
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning://$lnInvoice"))
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice"))
startActivity(context, intent, null)
}
},

View File

@@ -0,0 +1,144 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
@Composable
fun InvoiceRequest(lud16: String, onClose: () -> Unit ) {
val context = LocalContext.current
Column(modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, end = 30.dp)
.clip(shape = RoundedCornerShape(10.dp))
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
) {
Icon(
painter = painterResource(R.drawable.lightning),
null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
Text(
text = "Lightning Tips",
fontSize = 20.sp,
fontWeight = FontWeight.W500,
modifier = Modifier.padding(start = 10.dp)
)
}
Divider()
var message by remember { mutableStateOf("") }
var amount by remember { mutableStateOf(1000L) }
OutlinedTextField(
label = { Text(text = "Note to Receiver") },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = "Thank you so much!",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
),
singleLine = true
)
OutlinedTextField(
label = { Text(text = "Amount in Sats") },
modifier = Modifier.fillMaxWidth(),
value = amount.toString(),
onValueChange = {
runCatching {
if (it.isEmpty())
amount = 0
else
amount = it.toLong()
}
},
placeholder = {
Text(
text = "1000",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number
),
singleLine = true
)
Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
onClick = {
LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message) {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))
startActivity(context, intent, null)
}
onClose()
}
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Send Sats", color = Color.White, fontSize = 20.sp)
}
}
}
}

View File

@@ -1,32 +1,25 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Patterns
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import nostr.postr.toNpub
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
@@ -49,50 +42,79 @@ fun isValidURL(url: String?): Boolean {
}
@Composable
fun RichTextViewer(content: String, tags: List<List<String>>?, navController: NavController) {
Column(modifier = Modifier.padding(top = 5.dp)) {
fun RichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
navController: NavController
) {
Column(
Modifier
.fillMaxWidth()
.animateContentSize()) {
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
paragraph.split(' ').forEach { word: String ->
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
if (canPreview) {
// Explicit URL
val lnInvoice = LnInvoiceUtil.findInvoice(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
ZoomableImageView(word)
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(word)
} else {
UrlPreview(word, word)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
UrlPreview(word, word)
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
UrlPreview("https://$word", word)
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
if (isValidURL(word)) {
ClickableUrl("$word ", word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (noProtocolUrlValidator.matcher(word).matches()) {
ClickableUrl(word, "https://$word")
} else if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(word, tags, navController)
} else if (isBechLink(word)) {
BechLink(word, navController)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
}
}
}
}
}
}
fun isBechLink(word: String): Boolean {
return word.startsWith("nostr:", true)
|| word.startsWith("npub1", true)
@@ -165,3 +187,4 @@ fun TagLink(word: String, tags: List<List<String>>, navController: NavController
}
}

View File

@@ -0,0 +1,151 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.res.Resources
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.util.Locale
@Composable
fun TranslateableRichTextViewer(
content: String,
canPreview: Boolean,
tags: List<List<String>>?,
accountViewModel: AccountViewModel,
navController: NavController
) {
val translatedTextState = remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = accountState?.account ?: return
LaunchedEffect(accountState) {
LanguageTranslatorService.autoTranslate(content, account.dontTranslateFrom, account.translateTo)
.addOnCompleteListener { task ->
if (task.isSuccessful) {
translatedTextState.value = task.result
} else {
translatedTextState.value = ResultOrError(content, null, null, null)
}
}
}
val toBeViewed = if (showOriginal) content else translatedTextState.value.result ?: content
Column(modifier = Modifier.padding(top = 5.dp)) {
ExpandableRichTextViewer(
toBeViewed,
canPreview,
tags,
navController
)
val target = translatedTextState.value.targetLang
val source = translatedTextState.value.sourceLang
if (source != null && target != null) {
if (source != target) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append("Auto")
}
append("-translated from ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" to ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal")
showOriginal = span.item.toBoolean()
else
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
accountViewModel.dontTranslateFrom(source, context)
langSettingsPopupExpanded = false
}) {
Text("Never translate from ${Locale(source).displayName}")
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
accountViewModel.translateTo(lang, context)
langSettingsPopupExpanded = false
}) {
Text("Always translate to ${lang.displayName}")
}
}
}
}
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -32,7 +33,7 @@ fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
})
}
Crossfade(targetState = urlPreviewState) { state ->
Crossfade(targetState = urlPreviewState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
UrlPreviewCard(url, state.previewInfo)

View File

@@ -5,6 +5,7 @@ import android.widget.FrameLayout
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
@@ -19,14 +20,17 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
fun VideoView(videoUri: String) {
val context = LocalContext.current
val exoPlayer = ExoPlayer.Builder(LocalContext.current).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaItem(MediaItem.fromUri(videoUri))
prepare()
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
}
}
DisposableEffect(exoPlayer) {
exoPlayer.setMediaItem(MediaItem.fromUri(videoUri))
exoPlayer.prepare()
onDispose {
exoPlayer.release()
}

View File

@@ -2,35 +2,26 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import nostr.postr.toNpub
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
@Composable
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
@@ -75,6 +66,8 @@ fun ZoomableImageView(word: String) {
CloseButton(onCancel = {
dialogOpen = false
})
SaveToGallery(url = word)
}
ZoomableAsyncImage(word)

View File

@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
import androidx.compose.foundation.border
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.Row
@@ -36,6 +38,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
@@ -72,12 +75,13 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
}
}
@Composable
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUserState by account.userProfile().liveMetadata.observeAsState()
val accountUser = accountUserState?.user
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
@@ -104,19 +108,20 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box() {
Box(Modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxWidth().fillMaxHeight()
.padding(start = 0.dp, end = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = {
Client.allSubscriptions().map {
"${it} ${
Client.getSubscriptionFilters(it)
.joinToString { it.toJson() }
.joinToString { it.filter.toJson() }
}"
}.forEach {
Log.d("CURRENT FILTERS", it)
@@ -203,7 +208,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
},
actions = {
IconButton(
onClick = {}, modifier = Modifier
onClick = { wantsToEditRelays = true }, modifier = Modifier
) {
Icon(
painter = painterResource(R.drawable.ic_trends),

View File

@@ -81,52 +81,26 @@ fun DrawerContent(navController: NavHostController,
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user ?: return
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
Column() {
Box {
val banner = accountUser?.info?.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
}
ProfileContent(
accountUser,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
scaffoldState,
navController
)
}
ProfileContent(
account.userProfile(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
.padding(top = 100.dp),
scaffoldState,
navController
)
Divider(
thickness = 0.25.dp,
modifier = Modifier.padding(top = 20.dp)
)
ListContent(
accountUser,
account.userProfile(),
navController,
scaffoldState,
modifier = Modifier
@@ -135,50 +109,79 @@ fun DrawerContent(navController: NavHostController,
accountStateViewModel
)
BottomContent(accountUser, scaffoldState, navController)
BottomContent(account.userProfile(), scaffoldState, navController)
}
}
}
@Composable
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
val coroutineScope = rememberCoroutineScope()
Column(modifier = modifier) {
AsyncImage(
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = {
accountUser?.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
Text(
accountUser?.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(" @${accountUser?.bestUsername()}", color = Color.LightGray)
Row(modifier = Modifier.padding(top = 15.dp)) {
Row() {
Text("${accountUser?.follows?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Following")
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUser?.followers?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Followers")
val accountUserState by baseAccountUser.liveMetadata.observeAsState()
val accountUser = accountUserState?.user ?: return
val accountUserFollowsState by baseAccountUser.liveFollows.observeAsState()
val accountUserFollows = accountUserFollowsState?.user ?: return
Box {
val banner = accountUser.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
)
}
Column(modifier = modifier) {
AsyncImage(
model = accountUser.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = {
accountUser.let {
navController.navigate("User/${it.pubkeyHex}")
}
coroutineScope.launch {
scaffoldState.drawerState.close()
}
})
)
Text(
accountUser.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Text(" @${accountUser.bestUsername()}", color = Color.LightGray)
Row(modifier = Modifier.padding(top = 15.dp)) {
Row() {
Text("${accountUserFollows.follows?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Following")
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text("${accountUserFollows.followers?.size ?: "--"}", fontWeight = FontWeight.Bold)
Text(" Followers")
}
}
}
}

View File

@@ -115,7 +115,7 @@ private fun homeHasNewItems(cache: NotificationCache): Boolean {
val homeFeed = NostrHomeDataSource.feed().take(100)
val hasNewInFollows = homeFeed.filter {
it.event is RepostEvent || it.replyTo == null || it.replyTo?.size == 0
it.isNewThread()
}.filter {
(it.event?.createdAt ?: 0) > lastTimeFollows
}.isNotEmpty()
@@ -138,7 +138,7 @@ private fun messagesHasNewItems(cache: NotificationCache): Boolean {
return NostrChatroomListDataSource.feed().take(100).filter {
// only for known sources
val me = NostrChatroomListDataSource.account.userProfile()
it.channel != null || me.hasSentMessagesTo(it.author)
it.channel == null && me.hasSentMessagesTo(it.author) && it.author != me
}.filter {
val lastTime = if (it.channel != null) {
cache.load("Channel/${it.channel!!.idHex}", context)

View File

@@ -1,15 +1,24 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
@@ -43,7 +52,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
@Composable
fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier, isQuote: Boolean = false, navController: NavController, onClick: () -> Unit) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
@@ -56,11 +65,35 @@ fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Post was flagged as inappropriate",
modifier = Modifier.padding(30.dp),
color = Color.Gray,
)
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(30.dp)) {
Text(
text = "Post was flagged as inappropriate by",
color = Color.Gray,
)
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
reports.forEach {
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = loggedIn,
size = 35.dp
)
}
}
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = onClick,
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = "Show Anyway", color = Color.White)
}
}
}
Divider(

View File

@@ -76,19 +76,12 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, rou
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
likeSetCard.boostEvents.forEach {
val cardNoteState by it.live.observeAsState()
val cardNote = cardNoteState?.note
if (cardNote?.author != null) {
val userState by cardNote.author!!.live.observeAsState()
UserPicture(
user = userState?.user,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}

View File

@@ -53,9 +53,6 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user ?: return
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
@@ -64,7 +61,7 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel != null) {
val authorState by note.author!!.live.observeAsState()
val authorState by note.author!!.liveMetadata.observeAsState()
val author = authorState?.user
val channelState by note.channel!!.live.observeAsState()
@@ -108,25 +105,19 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
}
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
val replyAuthorBase = note.mentions?.first()
var userToComposeOn = author
var userToComposeOn = note.author!!
if ( replyAuthorBase != null ) {
val replyAuthorState by replyAuthorBase.live.observeAsState()
val replyAuthor = replyAuthorState?.user
if (author == accountUser) {
userToComposeOn = replyAuthor
if (replyAuthorBase != null) {
if (note.author == account.userProfile()) {
userToComposeOn = replyAuthorBase
}
}
val noteEvent = note.event
userToComposeOn?.let { user ->
userToComposeOn.let { user ->
val hasNewMessages =
if (noteEvent != null)
noteEvent.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
@@ -134,8 +125,8 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
false
ChannelName(
channelPicture = { UserPicture(user = user, userAccount = accountUser, size = 55.dp) },
channelTitle = { UsernameDisplay(user, it) },
channelPicture = { UserPicture(userToComposeOn, account.userProfile(), size = 55.dp) },
channelTitle = { UsernameDisplay(userToComposeOn, it) },
channelLastTime = noteEvent?.createdAt,
channelLastContent = accountViewModel.decrypt(note),
hasNewMessages = hasNewMessages,

View File

@@ -1,24 +1,34 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -29,14 +39,19 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
@@ -45,6 +60,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
@@ -59,24 +75,33 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
val noteReportsState by baseNote.liveReports.observeAsState()
val noteForReports = noteReportsState?.note ?: return
val accountUser = account.userProfile()
var popupExpanded by remember { mutableStateOf(false) }
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
if (note?.event == null) {
BlankNote(Modifier)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
Modifier,
innerQuote,
navController,
onClick = { showHiddenNote = true }
)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
var backgroundBubbleColor: Color
var alignment: Arrangement.Horizontal
var shape: Shape
if (author == accountUser) {
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
shape = ChatBubbleShapeMe
@@ -125,6 +150,9 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
) {
val authorState by note.author!!.liveMetadata.observeAsState()
val author = authorState?.user
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -189,19 +217,27 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
} else {
val eventContent = accountViewModel.decrypt(note)
if (eventContent != null)
RichTextViewer(
eventContent,
note.event?.tags,
navController
)
else
RichTextViewer(
"Could Not decrypt the message",
note.event?.tags,
navController
)
val canPreview = note.author == accountUser
|| (note.author?.let { accountUser.isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
} else {
TranslateableRichTextViewer(
"Could Not decrypt the message",
true,
note.event?.tags,
accountViewModel,
navController
)
}
}
}
@@ -215,6 +251,8 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontSize = 12.sp
)
RelayBadges(note)
}
}
}
@@ -227,3 +265,51 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote
}
}
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.liveRelays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
FlowRow(Modifier.padding(start = 10.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
Box(Modifier.size(15.dp).padding(1.dp)) {
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = rememberAsyncImagePainter("https://robohash.org/$url.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/$url.png"),
error = rememberAsyncImagePainter("https://robohash.org/$url.png"),
contentDescription = "Relay Icon",
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://" + url) } )
)
}
}
if (noteRelays.size > 3 && !expanded) {
IconButton(
modifier = Modifier.then(Modifier.size(15.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ChevronRight,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}

View File

@@ -76,19 +76,12 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
likeSetCard.likeEvents.forEach {
val cardNoteState by it.live.observeAsState()
val cardNote = cardNoteState?.note
if (cardNote?.author != null) {
val userState by cardNote.author!!.live.observeAsState()
UserPicture(
user = userState?.user,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
NoteAuthorPicture(
note = it,
navController = navController,
userAccount = account.userProfile(),
size = 35.dp
)
}
}

View File

@@ -5,11 +5,13 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -19,10 +21,14 @@ import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentColor
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -33,9 +39,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -45,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -56,6 +66,7 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following
import nostr.postr.events.TextNoteEvent
@@ -81,6 +92,7 @@ fun NoteCompose(
val noteForReports = noteReportsState?.note ?: return
var popupExpanded by remember { mutableStateOf(false) }
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext
@@ -89,15 +101,16 @@ fun NoteCompose(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else if (!account.isAcceptable(noteForReports)) {
HiddenNote(modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true },
), isInnerNote)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
modifier,
isInnerNote,
navController,
onClick = { showHiddenNote = true }
)
} else {
val authorState by note.author?.live!!.observeAsState()
val author = authorState?.user ?: return // if it has event, it should have an author
val isNew = routeForLastRead?.run {
val lastTime = NotificationCache.load(this, context)
@@ -140,65 +153,74 @@ fun NoteCompose(
top = 10.dp)
) {
// Draws the boosted picture outside the boosted card.
if (!isInnerNote) {
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
UserPicture(author, navController, account.userProfile(), 55.dp)
Column(Modifier.width(55.dp)) {
// Draws the boosted picture outside the boosted card.
Box(modifier = Modifier
.width(55.dp)
.padding(0.dp)) {
// boosted picture
val boostedPosts = note.replyTo
if (note.event is RepostEvent && boostedPosts != null && boostedPosts.isNotEmpty()) {
val boostedAuthor = boostedPosts[0].author
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
UserPicture(boostedAuthor, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
NoteAuthorPicture(note, navController, account.userProfile(), 55.dp)
if (note.event is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
NoteAuthorPicture(it, navController, account.userProfile(), 35.dp,
pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
)
}
}
}
// boosted picture
val baseChannel = note.channel
if (note.event is ChannelMessageEvent && baseChannel != null) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
if (channel != null) {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
AsyncImage(
model = channel.profilePicture(),
placeholder = null,
contentDescription = "Group Picture",
modifier = Modifier
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
}
}
}
}
// boosted picture
val baseChannel = note.channel
if (note.event is ChannelMessageEvent && baseChannel != null) {
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel
if (channel != null) {
Box(
Modifier
.width(30.dp)
.height(30.dp)
.align(Alignment.BottomEnd)) {
AsyncImage(
model = channel.profilePicture(),
placeholder = null,
contentDescription = "Group Picture",
modifier = Modifier
.width(30.dp)
.height(30.dp)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.border(
2.dp,
MaterialTheme.colors.background,
CircleShape
)
)
}
if (note.event is RepostEvent) {
note.replyTo?.lastOrNull()?.let {
RelayBadges(it)
}
} else {
RelayBadges(baseNote)
}
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author, Modifier.weight(1f))
NoteUsernameDisplay(note, Modifier.weight(1f))
if (note.event !is RepostEvent) {
Text(
@@ -245,18 +267,18 @@ fun NoteCompose(
}
} else {
val eventContent = note.event?.content
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
if (note.reports.size > 0) {
// Doesn't load images
Row() {
Text(
text = eventContent,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
} else {
RichTextViewer(eventContent, note.event?.tags, navController)
}
TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
}
ReactionsRow(note, accountViewModel)
@@ -275,39 +297,90 @@ fun NoteCompose(
}
@Composable
fun UserPicture(
user: User?,
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.liveRelays.observeAsState()
val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
var expanded by remember { mutableStateOf(false) }
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
val uri = LocalUriHandler.current
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
relaysToDisplay.forEach {
val url = it.removePrefix("wss://")
Box(Modifier.size(15.dp).padding(1.dp)) {
AsyncImage(
model = "https://${url}/favicon.ico",
placeholder = rememberAsyncImagePainter("https://robohash.org/$url.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/$url.png"),
error = rememberAsyncImagePainter("https://robohash.org/$url.png"),
contentDescription = "Relay Icon",
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
modifier = Modifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.clickable(onClick = { uri.openUri("https://" + url) } )
)
}
}
}
if (noteRelays.size > 3 && !expanded) {
Row(Modifier.fillMaxWidth().height(25.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Top) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { expanded = true }
) {
Icon(
imageVector = Icons.Default.ExpandMore,
null,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
)
}
}
}
}
@Composable
fun NoteAuthorPicture(
note: Note,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
UserPicture(user, userAccount, size, pictureModifier) {
user?.let {
navController.navigate("User/${it.pubkeyHex}")
}
NoteAuthorPicture(note, userAccount, size, pictureModifier) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun UserPicture(
user: User?,
userAccount: User,
fun NoteAuthorPicture(
baseNote: Note,
baseUserAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
onClick: ((User) -> Unit)? = null
) {
val accountState by userAccount.live.observeAsState()
val accountUser = accountState?.user ?: return
val noteState by baseNote.live.observeAsState()
val note = noteState?.note ?: return
val author = note.author
Box(
Modifier
.width(size)
.height(size)) {
if (user == null) {
if (author == null) {
AsyncImage(
model = "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
contentDescription = "Unknown Author",
placeholder = rememberAsyncImagePainter("https://robohash.org/ohno.png"),
modifier = pictureModifier
.fillMaxSize(1f)
@@ -315,54 +388,90 @@ fun UserPicture(
.background(MaterialTheme.colors.background)
)
} else {
val userState by user.live.observeAsState()
val userLive = userState?.user ?: return
UserPicture(author, baseUserAccount, size, pictureModifier, onClick)
}
}
}
AsyncImage(
model = userLive.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${userLive.pubkeyHex}.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null)
this.clickable(onClick = onClick)
else
this
}
@Composable
fun UserPicture(
user: User,
navController: NavController,
userAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier
) {
UserPicture(user, userAccount, size, pictureModifier) {
navController.navigate("User/${it.pubkeyHex}")
}
}
)
@Composable
fun UserPicture(
baseUser: User,
baseUserAccount: User,
size: Dp,
pictureModifier: Modifier = Modifier,
onClick: ((User) -> Unit)? = null
) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
if (accountUser.isFollowing(userLive) || userLive == accountUser) {
Box(
Modifier
.width(size.div(3.5f))
.height(size.div(3.5f))
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center
) {
// Background for the transparent checkmark
Text(
"x",
Modifier
.padding(4.dp)
.fillMaxSize()
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
.clip(shape = CircleShape)
)
Box(
Modifier
.width(size)
.height(size)) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"Following",
modifier = Modifier.fillMaxSize(),
tint = Following
)
AsyncImage(
model = user.profilePicture(),
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
fallback = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
error = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = pictureModifier
.fillMaxSize(1f)
.clip(shape = CircleShape)
.background(MaterialTheme.colors.background)
.run {
if (onClick != null)
this.clickable(onClick = { onClick(user) } )
else
this
}
)
val accountState by baseUserAccount.liveFollows.observeAsState()
val accountUser = accountState?.user ?: return
if (accountUser.isFollowing(user) || user == accountUser) {
Box(
Modifier
.width(size.div(3.5f))
.height(size.div(3.5f))
.align(Alignment.TopEnd),
contentAlignment = Alignment.Center
) {
// Background for the transparent checkmark
Text(
"x",
Modifier
.padding(4.dp)
.fillMaxSize()
.align(Alignment.Center)
.background(MaterialTheme.colors.background)
.clip(shape = CircleShape)
)
Icon(
painter = painterResource(R.drawable.ic_verified),
"Following",
modifier = Modifier.fillMaxSize(),
tint = Following
)
}
}
}
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -60,6 +61,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var popupExpanded by remember { mutableStateOf(false) }
val uri = LocalUriHandler.current
var wantsToReplyTo by remember {
mutableStateOf<Note?>(null)
@@ -153,7 +155,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
IconButton(
modifier = Modifier.then(Modifier.size(24.dp)),
onClick = { }
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,

View File

@@ -39,8 +39,6 @@ fun RelayCompose(
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val ctx = LocalContext.current.applicationContext
Column() {
Row(
modifier = Modifier
@@ -67,7 +65,7 @@ fun RelayCompose(
}
Text(
"${relay.counter} events received",
"${relay.counter} posts received",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -75,12 +73,10 @@ fun RelayCompose(
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.activeRelays()?.filter { it.url == relay.url }?.isEmpty() == true) {
if (account.activeRelays()?.none { it.url == relay.url } == true) {
AddRelayButton { onAddRelay() }
} else {
RemoveRelayButton {
onRemoveRelay()
}
RemoveRelayButton { onRemoveRelay() }
}
}
}

View File

@@ -24,14 +24,14 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, navController: NavController) {
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, navController: NavController) {
ReplyInformation(replyTo, mentions) {
navController.navigate("User/${it.pubkeyHex}")
}
}
@Composable
fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix: String = "", onUserTagClick: (User) -> Unit) {
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, prefix: String = "", onUserTagClick: (User) -> Unit) {
FlowRow() {
if (mentions != null && mentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
@@ -44,7 +44,7 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
val mentionSet = mentions.toSet()
mentionSet.toSet().forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUserState by user.liveMetadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
@@ -76,7 +76,7 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
ReplyInformationChannel(replyTo, mentions, channel,
onUserTagClick = {
navController.navigate("User/${it.pubkeyHex}")
@@ -89,7 +89,7 @@ fun ReplyInformationChannel(replyTo: MutableList<Note>?, mentions: List<User>?,
@Composable
fun ReplyInformationChannel(replyTo: MutableList<Note>?,
fun ReplyInformationChannel(replyTo: List<Note>?,
mentions: List<User>?,
baseChannel: Channel,
prefix: String = "",
@@ -123,7 +123,7 @@ fun ReplyInformationChannel(replyTo: MutableList<Note>?,
val mentionSet = mentions.toSet()
mentionSet.forEachIndexed { idx, user ->
val innerUserState by user.live.observeAsState()
val innerUserState by user.liveMetadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->

View File

@@ -34,14 +34,14 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
val userState by account.userProfile().liveFollows.observeAsState()
val userFollows = userState?.user ?: return
val ctx = LocalContext.current.applicationContext
Column(modifier =
Modifier.clickable(
onClick = { navController.navigate("User/${user.pubkeyHex}") }
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
)
) {
Row(
@@ -52,13 +52,16 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
top = 10.dp)
) {
UserPicture(user, navController, account.userProfile(), 55.dp)
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(user)
UsernameDisplay(baseUser)
}
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
@@ -68,15 +71,15 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account?.isHidden(user) == false) {
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(user.pubkeyHex)
account.showUser(baseUser.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (account?.userProfile()?.isFollowing(user) == true) {
UnfollowButton { account.unfollow(user) }
} else if (userFollows.isFollowing(baseUser)) {
UnfollowButton { account.unfollow(baseUser) }
} else {
FollowButton { account?.follow(user) }
FollowButton { account.follow(baseUser) }
}
}
}

View File

@@ -3,42 +3,61 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable
fun UsernameDisplay(user: User, weight: Modifier = Modifier) {
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
} else {
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live.observeAsState()
val note = noteState?.note ?: return
val author = note.author
if (author != null) {
UsernameDisplay(author, weight)
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
if (user.bestUsername() != null || user.bestDisplayName() != null) {
if (user.bestDisplayName().isNullOrBlank()) {
Text(
user.pubkeyDisplayHex,
"@${(user.bestUsername() ?: "")}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
} else {
Text(
user.bestDisplayName() ?: "",
fontWeight = FontWeight.Bold,
)
Text(
"@${(user.bestUsername() ?: "")}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
} else {
Text(
user.pubkeyDisplayHex,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = weight
)
}
}

View File

@@ -1,4 +1,4 @@
package com.vitorpamplona.amethyst.ui.navigation
package com.vitorpamplona.amethyst.ui.qrcode
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager

View File

@@ -36,6 +36,7 @@ import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
import nostr.postr.toNpub
@Composable
@@ -61,46 +62,55 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
}
Column(
modifier = Modifier.fillMaxSize().padding(10.dp),
verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxSize().padding(horizontal = 10.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
if (presenting) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(120.dp)
.height(120.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
AsyncImage(
model = user.profilePicture() ?: "https://robohash.org/ohno.png",
contentDescription = "Profile Image",
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
modifier = Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colors.background, CircleShape)
.background(MaterialTheme.colors.background)
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(" @${user.bestUsername()}", color = Color.LightGray)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 35.dp, vertical = 10.dp)
) {
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
}
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Button(
@@ -120,6 +130,15 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
} else {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Text(
"Point to the QR Code",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(30.dp)
@@ -127,18 +146,24 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
QrCodeScanner(onScan)
}
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth().padding(30.dp)
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 30.dp, vertical = 10.dp)
) {
Text(text = "Show QR")
Button(
onClick = { presenting = true },
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(text = "Show QR")
}
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -13,7 +14,7 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: St
val accountState by accountStateViewModel.accountContent.collectAsState()
Column() {
Crossfade(targetState = accountState) { state ->
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedOff -> {
LoginPage(accountStateViewModel)

View File

@@ -2,34 +2,18 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.security.crypto.EncryptedSharedPreferences
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.MainActivity
import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.bechToBytes
import nostr.postr.toHex
class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)

View File

@@ -18,11 +18,9 @@ class NoteCard(val note: Note): Card() {
class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
val createdAt = likeEvents.maxOf { it.event?.createdAt ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + "L" + createdAt
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -45,7 +46,7 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {

View File

@@ -9,8 +9,11 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.NostrDataSource
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -20,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
@@ -33,7 +37,6 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
refreshSuspended()
}
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
@@ -56,9 +59,9 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
private fun convertToCard(notes: List<Note>): List<Card> {
val reactionsPerEvent = mutableMapOf<Note, MutableList<Note>>()
notes
.filter { it.event is ReactionEvent }
.filter { it.event is ReactionEvent}
.forEach {
val reactedPost = it.replyTo?.last()
val reactedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
if (reactedPost != null)
reactionsPerEvent.getOrPut(reactedPost, { mutableListOf() }).add(it)
}
@@ -69,7 +72,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
notes
.filter { it.event is RepostEvent }
.forEach {
val boostedPost = it.replyTo?.last()
val boostedPost = it.replyTo?.lastOrNull() { it.event !is ChannelMetadataEvent && it.event !is ChannelCreateEvent }
if (boostedPost != null)
boostsPerEvent.getOrPut(boostedPost, { mutableListOf() }).add(it)
}
@@ -81,7 +84,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
return (reactionCards + boostCards + textNoteCards).sortedBy { it.createdAt() }.reversed()
}
fun updateFeed(notes: List<Card>) {
private fun updateFeed(notes: List<Card>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
@@ -97,19 +100,18 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
}
}
var handlerWaiting = AtomicBoolean()
var handlerWaiting = false
fun invalidateData() {
synchronized(handlerWaiting) {
if (handlerWaiting) return
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -36,7 +37,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
}
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
@@ -49,6 +50,11 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
}
}
is FeedState.Loaded -> {
LaunchedEffect(state.feed.value.firstOrNull()) {
if (listState.firstVisibleItemIndex <= 1)
listState.animateScrollToItem(0)
}
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
@@ -58,7 +64,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
state = listState
) {
var previousDate: String = ""
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item ->
ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController)
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -48,7 +49,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
@@ -87,7 +88,7 @@ private fun FeedLoaded(
),
state = listState
) {
itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item ->
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
ChatroomCompose(
item,
accountViewModel = accountViewModel,

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -56,7 +57,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.events.TextNoteEvent
import java.util.concurrent.atomic.AtomicBoolean
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
@@ -54,21 +55,17 @@ class NostrChatroomListNewFeedViewModel: FeedViewModel(NostrChatroomListDataSour
}
}
fun isNewThread(note: Note): Boolean {
return note.event is RepostEvent || note.replyTo == null || note.replyTo?.size == 0
}
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: no replies
return dataSource.feed().filter { isNewThread(it) }.take(100)
return dataSource.feed().filter { it.isNewThread() }.take(100)
}
}
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {
override fun newListFromDataSource(): List<Note> {
// Filter: only replies
return dataSource.feed().filter {!isNewThread(it) }.take(100)
return dataSource.feed().filter {! it.isNewThread() }.take(100)
}
}
@@ -104,7 +101,7 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
}
}
fun updateFeed(notes: List<Note>) {
private fun updateFeed(notes: List<Note>) {
val currentState = feedContent.value
if (notes.isEmpty()) {
@@ -117,18 +114,17 @@ abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel()
}
}
var handlerWaiting = false
fun invalidateData() {
synchronized(handlerWaiting) {
if (handlerWaiting) return
private var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}

View File

@@ -23,12 +23,14 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.note.RelayCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RelayFeedViewModel: ViewModel() {
val order = compareByDescending<User.RelayInfo> { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url }
@@ -39,27 +41,27 @@ class RelayFeedViewModel: ViewModel() {
var currentUser: User? = null
fun refresh() {
val beingUsed = currentUser?.relaysBeingUsed?.values?.toList() ?: emptyList()
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
viewModelScope.launch(Dispatchers.Default) {
val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList()
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
val newRelaysFromRecord = currentUser?.relays?.entries?.mapNotNull {
if (it.key !in beingUsedSet) {
User.RelayInfo(it.key, 0, 0)
} else {
null
}
} ?: emptyList()
val newRelaysFromRecord = currentUser?.relays?.entries?.mapNotNull {
if (it.key !in beingUsedSet) {
User.RelayInfo(it.key, 0, 0)
} else {
null
}
} ?: emptyList()
viewModelScope.launch {
withContext(Dispatchers.Main) {
_feedContent.update { (beingUsed + newRelaysFromRecord).sortedWith(order) }
}
val newList = (beingUsed + newRelaysFromRecord).sortedWith(order)
_feedContent.update { newList }
}
}
inner class CacheListener: User.Listener() {
override fun onNewRelayInfo() { refresh() }
override fun onRelayChange() { refresh() }
override fun onNewRelayInfo() { invalidateData() }
override fun onRelayChange() { invalidateData() }
}
val listener = CacheListener()
@@ -67,13 +69,31 @@ class RelayFeedViewModel: ViewModel() {
fun subscribeTo(user: User) {
currentUser = user
user.subscribe(listener)
refresh()
invalidateData()
}
fun unsubscribeTo(user: User) {
user.unsubscribe(listener)
currentUser = null
}
override fun onCleared() {
currentUser?.let { unsubscribeTo(it) }
}
var handlerWaiting = false
@Synchronized
fun invalidateData() {
if (handlerWaiting) return
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
}
}
@Composable

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@@ -15,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -32,6 +34,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.text.style.TextDirection
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
@@ -39,9 +42,12 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -71,7 +77,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
@@ -165,14 +171,20 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var showHiddenNote by remember { mutableStateOf(false) }
if (note?.event == null) {
BlankNote()
} else if (!account.isAcceptable(noteForReports)) {
HiddenNote()
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
Modifier,
false,
navController,
onClick = { showHiddenNote = true }
)
} else {
val authorState by note.author!!.live.observeAsState()
val author = authorState?.user
Column(
Modifier
.fillMaxWidth()
@@ -180,17 +192,21 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
Row(modifier = Modifier
.padding(start = 12.dp, end = 12.dp)
.clickable(onClick = {
author?.let {
note.author?.let {
navController.navigate("User/${it.pubkeyHex}")
}
})
) {
UserPicture(user = author, navController, userAccount = account.userProfile(), size = 55.dp)
NoteAuthorPicture(
note = baseNote,
navController = navController,
userAccount = account.userProfile(),
size = 55.dp
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
NoteUsernameDisplay(baseNote)
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -205,8 +221,20 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
Row(modifier = Modifier.padding(horizontal = 12.dp)) {
Column() {
val eventContent = note.event?.content
if (eventContent != null)
RichTextViewer(eventContent, note.event?.tags, navController)
val canPreview = note.author == account.userProfile()
|| (note.author?.let { account.userProfile().isFollowing(it) } ?: true )
|| !noteForReports.hasAnyReports()
if (eventContent != null) {
TranslateableRichTextViewer(
eventContent,
canPreview,
note.event?.tags,
accountViewModel,
navController
)
}
ReactionsRow(note, accountViewModel)

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
@@ -43,7 +44,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
},
) {
Column() {
Crossfade(targetState = feedState) { state ->
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is UserFeedState.Empty -> {
FeedEmpty {

View File

@@ -22,6 +22,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
class NostrUserProfileFollowsUserFeedViewModel(): UserFeedViewModel(
NostrUserProfileFollowsDataSource
@@ -46,6 +48,7 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
private fun refreshSuspended() {
val notes = dataSource.loadTop()
@@ -59,11 +62,10 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
fun updateFeed(notes: List<User>) {
private fun updateFeed(notes: List<User>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = feedContent.value
if (notes.isEmpty()) {
_feedContent.update { UserFeedState.Empty }
} else if (currentState is UserFeedState.Loaded) {
@@ -75,18 +77,18 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
}
}
var handlerWaiting = false
fun invalidateData() {
synchronized(handlerWaiting) {
if (handlerWaiting) return
var handlerWaiting = AtomicBoolean()
handlerWaiting = true
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting = false
}
@Synchronized
private fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
delay(100)
refresh()
handlerWaiting.set(false)
}
}

View File

@@ -10,9 +10,11 @@ import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent
import java.util.Locale
class AccountViewModel(private val account: Account): ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
fun reactTo(note: Note) {
account.reactTo(note)
@@ -47,4 +49,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
fun translateTo(lang: Locale, ctx: Context) {
account.updateTranslateTo(lang.language)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
fun dontTranslateFrom(lang: String, ctx: Context) {
account.addDontTranslateFrom(lang)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
}

View File

@@ -15,6 +15,8 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -63,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import nostr.postr.toNpub
@Composable
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
@@ -113,9 +116,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun
capitalization = KeyboardCapitalization.Sentences
),
shape = RoundedCornerShape(25.dp),
modifier = Modifier
.weight(1f, true)
.padding(end = 10.dp),
modifier = Modifier.weight(1f, true),
placeholder = {
Text(
text = "reply here.. ",
@@ -189,12 +190,12 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel:
channel?.let {
if (channel.creator == account.userProfile()) {
EditButton(account, it)
}
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
if (account.followingChannels.contains(channel.idHex)) {
LeaveButton(account, channel, navController)
} else {
JoinButton(account, channel, navController)
}
JoinButton(account, channel, navController)
}
}
}
@@ -214,10 +215,11 @@ private fun NoteCopyButton(
note: Channel
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier.padding(horizontal = 3.dp).width(50.dp),
onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())) },
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
@@ -229,6 +231,15 @@ private fun NoteCopyButton(
imageVector = Icons.Default.Share,
contentDescription = "Copies the Note ID to the clipboard for sharing"
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); popupExpanded = false }) {
Text("Copy Channel ID (Note) to the Clipboard")
}
}
}
}

View File

@@ -41,6 +41,7 @@ import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -70,13 +71,18 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
}
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
modifier = Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true)
) {
ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/${userId}")
}
//LAST ROW
Row(modifier = Modifier.padding(10.dp).fillMaxWidth(),
Row(modifier = Modifier
.padding(10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -118,29 +124,29 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
@Composable
fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val authorState by baseUser.live.observeAsState()
val author = authorState?.user
Column(modifier = Modifier.clickable(
onClick = { navController.navigate("User/${author?.pubkeyHex}") }
onClick = { navController.navigate("User/${baseUser.pubkeyHex}") }
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val authorState by baseUser.liveMetadata.observeAsState()
val author = authorState?.user
AsyncImage(
model = author?.profilePicture(),
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
contentDescription = "Profile Image",
modifier = Modifier
.width(35.dp).height(35.dp)
.width(35.dp)
.height(35.dp)
.clip(shape = CircleShape)
)
Column(modifier = Modifier.padding(start = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (author != null)
UsernameDisplay(author)
UsernameDisplay(baseUser)
}
}
}

View File

@@ -9,14 +9,9 @@ import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
@@ -24,15 +19,8 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.Routes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.System.currentTimeMillis
import kotlinx.coroutines.launch
@OptIn(ExperimentalPagerApi::class)

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -61,7 +62,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
val accountState by accountViewModel.accountContent.collectAsState()
if (currentRoute(navController) == Route.Home.route) {
Crossfade(targetState = accountState) { state ->
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.
@@ -77,7 +78,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
}
if (currentRoute(navController) == Route.Message.route) {
Crossfade(targetState = accountState) { state ->
Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is AccountState.LoggedInViewOnly -> {
// Does nothing.

View File

@@ -1,87 +1,66 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Password
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toNote
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.launch
import nostr.postr.toNpub
import nostr.postr.toNsec
@@ -92,39 +71,63 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val accountUserState by account.userProfile().live.observeAsState()
val accountUser = accountUserState?.user
if (userId == null) return
if (userId != null && accountUser != null) {
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
DisposableEffect(account) {
NostrUserProfileDataSource.loadUserProfile(userId)
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
NostrUserProfileFollowsDataSource.loadUserProfile(userId)
onDispose {
NostrUserProfileDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
}
onDispose {
NostrUserProfileDataSource.stop()
NostrUserProfileFollowsDataSource.stop()
NostrUserProfileFollowersDataSource.stop()
}
}
val baseUser = NostrUserProfileDataSource.user ?: return
val baseUser = NostrUserProfileDataSource.user ?: return
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
var columnSize by remember { mutableStateOf(IntSize.Zero) }
var tabsSize by remember { mutableStateOf(IntSize.Zero) }
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
Column(modifier = Modifier
.fillMaxSize()
.onSizeChanged {
columnSize = it
}
) {
Column() {
ProfileHeader(user, navController, account, accountUser, accountViewModel)
val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier
.verticalScroll(scrollState)
.nestedScroll(object : NestedScrollConnection {
override fun onPreScroll(
available: Offset,
source: NestedScrollSource
): Offset {
// When scrolling vertically, scroll the container first.
return if (available.y < 0 && scrollState.canScrollForward) {
coroutineScope.launch {
scrollState.scrollBy(-available.y)
}
Offset(0f, available.y)
} else {
Offset.Zero
}
}
})
.fillMaxHeight()
) {
Column(modifier = Modifier.padding()) {
TabRow(
ProfileHeader(baseUser, navController, account, accountViewModel)
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
@@ -132,6 +135,10 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
color = MaterialTheme.colors.primary
)
},
edgePadding = 8.dp,
modifier = Modifier.onSizeChanged {
tabsSize = it
}
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -145,7 +152,10 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
selected = pagerState.currentPage == 1,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
text = {
Text(text = "${user.follows?.size ?: "--"} Follows")
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.follows?.size ?: "--"
Text(text = "$userFollows Follows")
}
)
@@ -153,7 +163,10 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
Text(text = "${user.followers?.size ?: "--"} Followers")
val userState by baseUser.liveFollows.observeAsState()
val userFollows = userState?.user?.followers?.size ?: "--"
Text(text = "$userFollows Followers")
}
)
@@ -161,16 +174,29 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
selected = pagerState.currentPage == 3,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } },
text = {
Text(text = "${user.relaysBeingUsed.size ?: "--"} / ${user.relays?.size ?: "--"} Relays")
val userState by baseUser.liveRelays.observeAsState()
val userRelaysBeingUsed =
userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.liveRelayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.relays?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays Relays")
}
)
}
HorizontalPager(count = 4, state = pagerState) {
HorizontalPager(
count = 4,
state = pagerState,
modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
}
) {
when (pagerState.currentPage) {
0 -> TabNotes(user, accountViewModel, navController)
1 -> TabFollows(user, accountViewModel, navController)
2 -> TabFollowers(user, accountViewModel, navController)
3 -> TabRelays(user, accountViewModel, navController)
0 -> TabNotes(baseUser, accountViewModel, navController)
1 -> TabFollows(baseUser, accountViewModel, navController)
2 -> TabFollowers(baseUser, accountViewModel, navController)
3 -> TabRelays(baseUser, accountViewModel, navController)
}
}
}
@@ -181,36 +207,19 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
@Composable
private fun ProfileHeader(
user: User,
baseUser: User,
navController: NavController,
account: Account,
accountUser: User,
accountViewModel: AccountViewModel
) {
val ctx = LocalContext.current.applicationContext
var popupExpanded by remember { mutableStateOf(false) }
val accountUserState by account.userProfile().liveFollows.observeAsState()
val accountUser = accountUserState?.user ?: return
Box {
val banner = user.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
DrawBanner(baseUser)
Box(modifier = Modifier
.padding(horizontal = 10.dp)
@@ -230,12 +239,12 @@ private fun ProfileHeader(
contentPadding = PaddingValues(0.dp)
) {
Icon(
tint = Color.White,
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
)
UserProfileDropDownMenu(user, popupExpanded, { popupExpanded = false }, accountViewModel)
UserProfileDropDownMenu(baseUser, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
@@ -253,7 +262,7 @@ private fun ProfileHeader(
) {
UserPicture(
user, navController, account.userProfile(), 100.dp,
baseUser, navController, account.userProfile(), 100.dp,
pictureModifier = Modifier.border(
3.dp,
MaterialTheme.colors.background,
@@ -266,52 +275,141 @@ private fun ProfileHeader(
Row(modifier = Modifier
.height(35.dp)
.padding(bottom = 3.dp)) {
MessageButton(user, navController)
MessageButton(baseUser, navController)
if (accountUser == user && account.isWriteable()) {
if (accountUser == baseUser && account.isWriteable()) {
NSecCopyButton(account)
}
NPubCopyButton(user)
NPubCopyButton(baseUser)
if (accountUser == user) {
if (accountUser == baseUser) {
EditButton(account)
} else {
if (!account.isHidden(user)) {
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(user.pubkeyHex)
account.showUser(baseUser.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
}
} else if (accountUser.isFollowing(user)) {
UnfollowButton { account.unfollow(user) }
} else if (accountUser.isFollowing(baseUser)) {
UnfollowButton { account.unfollow(baseUser) }
} else {
FollowButton { account.follow(user) }
FollowButton { account.follow(baseUser) }
}
}
}
}
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
Text(
" @${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
DrawAdditionalInfo(baseUser)
Divider(modifier = Modifier.padding(top = 6.dp))
}
}
}
@Composable
private fun DrawAdditionalInfo(baseUser: User) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
val uri = LocalUriHandler.current
Text(
user.bestDisplayName() ?: "",
modifier = Modifier.padding(top = 7.dp),
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
Text(
"@${user.bestUsername()}",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
val website = user.info.website
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
imageVector = Icons.Default.Link,
contentDescription = "Website",
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { user.info.website?.let { runCatching { uri.openUri(it) } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
}
var ZapExpanded by remember { mutableStateOf(false) }
val lud16 = user.info.lud16
if (!lud16.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = BitcoinOrange,
imageVector = Icons.Default.Bolt,
contentDescription = "Lightning Address",
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(lud16),
onClick = { ZapExpanded = !ZapExpanded },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f)
)
}
if (ZapExpanded) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(lud16) {
ZapExpanded = false
}
}
}
}
Text(
"${user.info.about}",
color = MaterialTheme.colors.onSurface,
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
)
}
@Composable
private fun DrawBanner(baseUser: User) {
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
val banner = user.info.banner
if (banner != null && banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = "Profile Image",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
} else {
Image(
painter = painterResource(R.drawable.profile_banner),
contentDescription = "Profile Banner",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.height(125.dp)
)
}
}
@Composable
fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -358,11 +456,8 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
fun TabRelays(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val feedViewModel: RelayFeedViewModel = viewModel()
DisposableEffect(key1 = user) {
LaunchedEffect(key1 = user) {
feedViewModel.subscribeTo(user)
onDispose {
feedViewModel.unsubscribeTo(user)
}
}
Column(Modifier.fillMaxHeight()) {
@@ -379,23 +474,33 @@ private fun NSecCopyButton(
account: Account
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) } },
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
),
)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Password,
imageVector = Icons.Default.Key,
contentDescription = "Copies the Nsec ID (your password) to the clipboard for backup"
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) }; popupExpanded = false }) {
Text("Copy Private Key to the Clipboard")
}
}
}
}
@@ -404,12 +509,13 @@ private fun NPubCopyButton(
user: User
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())) },
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
@@ -419,8 +525,17 @@ private fun NPubCopyButton(
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
contentDescription = "Copies the Note ID to the clipboard for sharing"
contentDescription = "Copies the public key to the clipboard for sharing"
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())); popupExpanded = false }) {
Text("Copy Public Key (NPub) to the Clipboard")
}
}
}
}
@@ -535,13 +650,13 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
expanded = popupExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub() ?: "")); onDismiss() }) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())); onDismiss() }) {
Text("Copy User ID")
}
if ( account.userProfile() != user) {
Divider()
if (!account.isHidden(user)) {
if (account.isHidden(user)) {
DropdownMenuItem(onClick = {
user.let {
accountViewModel.show(

View File

@@ -8,13 +8,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
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.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Divider
@@ -34,20 +31,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
@@ -55,13 +50,21 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.channels.Channel as CoroutineChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
@@ -81,21 +84,47 @@ fun SearchScreen(accountViewModel: AccountViewModel, navController: NavControlle
}
}
@OptIn(FlowPreview::class)
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
val searchValue = remember { mutableStateOf(TextFieldValue("")) }
var searchValue by remember { mutableStateOf("") }
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
val scope = rememberCoroutineScope()
val onlineSearch = NostrSearchEventOrUserDataSource
val isTrailingIconVisible by remember {
derivedStateOf {
searchValue.value.text.isNotBlank()
searchValue.isNotBlank()
}
}
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
}
LaunchedEffect(Unit) {
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO){
searchTextChanges.receiveAsFlow()
.filter { it.isNotBlank() }
.distinctUntilChanged()
.debounce(300)
.collect {
if (it.removePrefix("npub").removePrefix("note").length >= 4)
onlineSearch.search(it)
searchResults.value = LocalCache.findUsersStartingWith(it)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it)
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
}
}
}
DisposableEffect(Unit) {
onDispose {
NostrSearchEventOrUserDataSource.clear()
@@ -111,16 +140,12 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = searchValue.value,
value = searchValue,
onValueChange = {
searchValue.value = it
if (it.text.removePrefix("npub").removePrefix("note").length >= 4)
onlineSearch.search(it.text)
searchResults.value = LocalCache.findUsersStartingWith(it.text)
searchResultsNotes.value = LocalCache.findNotesStartingWith(it.text)
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it.text)
searchValue = it
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
},
shape = RoundedCornerShape(25.dp),
keyboardOptions = KeyboardOptions.Default.copy(
@@ -147,7 +172,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
if (isTrailingIconVisible) {
IconButton(
onClick = {
searchValue.value = TextFieldValue("")
searchValue = ""
searchResults.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
@@ -169,7 +194,8 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
if (searchValue.value.text.isNotBlank()) {
if (searchValue.isNotBlank()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
@@ -210,9 +236,6 @@ fun UserLine(
account: Account,
onClick: () -> Unit
) {
val userState by baseUser.live.observeAsState()
val user = userState?.user ?: return
Column(modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
@@ -226,7 +249,7 @@ fun UserLine(
)
) {
UserPicture(user, account.userProfile(), 55.dp, Modifier, null)
UserPicture(baseUser, account.userProfile(), 55.dp, Modifier, null)
Column(
modifier = Modifier
@@ -234,9 +257,12 @@ fun UserLine(
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(user)
UsernameDisplay(baseUser)
}
val userState by baseUser.liveMetadata.observeAsState()
val user = userState?.user ?: return
Text(
user.info.about?.take(100) ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),

View File

@@ -1,37 +1,18 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -40,16 +21,13 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.sendMail
@Composable
fun LoginPage(accountViewModel: AccountStateViewModel) {
@@ -59,12 +37,144 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
val uri = LocalUriHandler.current
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceBetween
) {
// The first child is glued to the top.
// Hence we have nothing at the top, an empty box is used.
Box(modifier = Modifier.height(0.dp))
// The second child, this column, is centered vertically.
Column(
modifier = Modifier
.padding(20.dp)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
modifier = Modifier.size(200.dp),
contentScale = ContentScale.Inside
)
Spacer(modifier = Modifier.height(40.dp))
var showPassword by remember {
mutableStateOf(false)
}
OutlinedTextField(
value = key.value,
onValueChange = { key.value = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = "nsec / npub / hex private key",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) "Show Password" else "Hide Password"
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions = KeyboardActions(
onGo = {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
)
)
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptedTerms.value,
onCheckedChange = { acceptedTerms.value = it }
)
Text(text = "I accept the ")
ClickableText(
text = AnnotatedString("terms of use"),
onClick = { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
if (termsAcceptanceIsRequired.isNotBlank()) {
Text(
text = termsAcceptanceIsRequired,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = "Acceptance of terms is required"
}
if (key.value.text.isBlank()) {
errorMessage = "Key is required"
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
},
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Login")
}
}
}
// The last child is glued to the bottom.
ClickableText(
text = AnnotatedString("Generate a new key"),
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(20.dp),
.padding(20.dp)
.fillMaxWidth(),
onClick = {
if (acceptedTerms.value) {
accountViewModel.newKey()
@@ -75,127 +185,9 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
style = TextStyle(
fontSize = 14.sp,
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colors.primary
color = MaterialTheme.colors.primary,
textAlign = TextAlign.Center
)
)
}
Column(
modifier = Modifier.padding(20.dp).fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painterResource(id = R.drawable.amethyst),
contentDescription = "App Logo",
modifier = Modifier.size(200.dp),
contentScale = ContentScale.Inside
)
Spacer(modifier = Modifier.height(40.dp))
var showPassword by remember {
mutableStateOf(false)
}
OutlinedTextField(
value = key.value,
onValueChange = { key.value = it },
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Go
),
placeholder = {
Text(
text = "nsec / npub / hex private key",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) "Show Password" else "Hide Password"
)
}
},
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
keyboardActions = KeyboardActions(
onGo = {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
)
)
if (errorMessage.isNotBlank()) {
Text(
text = errorMessage,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = acceptedTerms.value,
onCheckedChange = { acceptedTerms.value = it }
)
Text(text = "I accept the ")
ClickableText(
text = AnnotatedString("terms of use"),
onClick = { runCatching { uri.openUri("https://github.com/vitorpamplona/amethyst/blob/main/PRIVACY.md") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
)
}
if (termsAcceptanceIsRequired.isNotBlank()) {
Text(
text = termsAcceptanceIsRequired,
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption
)
}
Spacer(modifier = Modifier.height(20.dp))
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = "Acceptance of terms is required"
}
if (key.value.text.isBlank()) {
errorMessage = "Key is required"
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.login(key.value.text)
} catch (e: Exception) {
errorMessage = "Invalid key"
}
}
},
shape = RoundedCornerShape(35.dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
) {
Text(text = "Login")
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More