mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbfd060328 | ||
|
|
9e894642ec | ||
|
|
22daf1a6a8 | ||
|
|
1a3b92a727 | ||
|
|
ee7120d803 | ||
|
|
a45a8789e7 | ||
|
|
bb3e38d079 | ||
|
|
1568cad693 | ||
|
|
10e5c43be9 | ||
|
|
e3aa573b8c | ||
|
|
bd77c43d40 | ||
|
|
8e02f67052 | ||
|
|
299cf1aabb | ||
|
|
343521ff58 | ||
|
|
a82f260142 | ||
|
|
fb73308995 | ||
|
|
b3ce10c2de | ||
|
|
4fa6873180 | ||
|
|
a34e88b4fc | ||
|
|
912ca72d68 | ||
|
|
c3c19ebb49 | ||
|
|
0f968ca5e5 | ||
|
|
2e17535a60 | ||
|
|
663eb9c604 | ||
|
|
4b894c5b33 | ||
|
|
814fb1845f | ||
|
|
94a4bfc2f3 | ||
|
|
4303e6040c | ||
|
|
75cb313886 | ||
|
|
88d2786473 | ||
|
|
7837860ac6 | ||
|
|
b7bc8a0b9d | ||
|
|
c71c121356 | ||
|
|
d94f35de0e | ||
|
|
3c52ff6e8d | ||
|
|
dc47845dd6 | ||
|
|
76087e5f25 | ||
|
|
6225b4f843 | ||
|
|
3a834a932c | ||
|
|
e59dfa7a76 | ||
|
|
a0ba313661 | ||
|
|
fb8d6daf3d | ||
|
|
f2d7dea860 | ||
|
|
29b1a94ec4 | ||
|
|
bba5afcdbf | ||
|
|
8b1e0f9af0 | ||
|
|
9fe73c7a97 | ||
|
|
9263684031 | ||
|
|
d1753a59f9 | ||
|
|
31e2e586b7 | ||
|
|
5a502f3b80 | ||
|
|
456d40c58e | ||
|
|
d19a1fa1b2 | ||
|
|
59dc19720f | ||
|
|
11cfc90d36 | ||
|
|
9f2f0727a0 | ||
|
|
6f9a69b9d4 | ||
|
|
5ad39d7163 | ||
|
|
acff1316be | ||
|
|
ee2684a775 | ||
|
|
5f3f80bc97 | ||
|
|
1e3654396b | ||
|
|
ae82c690ea | ||
|
|
a29a0a614b | ||
|
|
4d5a40f4ac | ||
|
|
27e12a63d6 | ||
|
|
a934b42524 | ||
|
|
c29c14f82b | ||
|
|
8a9b14fe5f | ||
|
|
68c8c8cd98 | ||
|
|
e751c206b2 | ||
|
|
74df3333ef | ||
|
|
b5ee783107 | ||
|
|
5452196f42 | ||
|
|
b7f13f6474 | ||
|
|
b4fb623a01 | ||
|
|
88a82b3fbc | ||
|
|
3dc09cce11 | ||
|
|
7399afa648 | ||
|
|
4e74ca6ed0 | ||
|
|
e20669ab3d | ||
|
|
93033295be | ||
|
|
d8ef083086 | ||
|
|
686dc954d9 | ||
|
|
0ad8176a7c | ||
|
|
47f89232c1 | ||
|
|
5c9b736591 | ||
|
|
875a518a05 | ||
|
|
22311f39e0 | ||
|
|
f6ad33b76a | ||
|
|
05267c598f | ||
|
|
f988d3e488 | ||
|
|
bd7a4a8372 | ||
|
|
a9788cfa63 | ||
|
|
74b0dd3b44 | ||
|
|
88ccb63a80 | ||
|
|
542bd485d0 | ||
|
|
4c15420b05 | ||
|
|
e4dfb2b974 | ||
|
|
71c847b478 | ||
|
|
5f1ce602ea | ||
|
|
380181fd05 | ||
|
|
857a31faf0 | ||
|
|
12e5ebeb84 | ||
|
|
965cdd9304 | ||
|
|
e7b6ca1bb2 | ||
|
|
30ab477d7d | ||
|
|
68bb6b3479 | ||
|
|
ebe9690f1b | ||
|
|
aa8993eb56 | ||
|
|
ca9f94eaac | ||
|
|
f931e8dfa9 | ||
|
|
b32c454303 | ||
|
|
e9eb7de24a | ||
|
|
654deb5e23 | ||
|
|
cae9502d11 | ||
|
|
687428abc1 | ||
|
|
45ea408877 | ||
|
|
c863b6c5e5 | ||
|
|
34dd009246 | ||
|
|
e064741148 | ||
|
|
b6c25cfa46 | ||
|
|
114f064bbf | ||
|
|
aeb78b293e | ||
|
|
2b4a1a3966 | ||
|
|
582f8e39c0 | ||
|
|
a6c3c5c606 | ||
|
|
b641678222 | ||
|
|
33fc47f1aa | ||
|
|
8d3d543318 | ||
|
|
a47aaab83c | ||
|
|
c33f7f615f | ||
|
|
0c2fd3adc0 | ||
|
|
a366809319 | ||
|
|
7baef64af9 | ||
|
|
9c04cb0e30 | ||
|
|
d13979b4b5 | ||
|
|
9f15b87d57 | ||
|
|
59305c9b15 | ||
|
|
53eeba210a | ||
|
|
38c17f9024 | ||
|
|
0c96d0a3fd | ||
|
|
7be2d08db1 | ||
|
|
0a02c66c5a | ||
|
|
261eecbcc3 | ||
|
|
f17936f029 | ||
|
|
d64766516a | ||
|
|
d685219ed9 | ||
|
|
bb9217466a | ||
|
|
80f58875bb | ||
|
|
ffe58f3d41 | ||
|
|
733801fddf | ||
|
|
5cb6bca224 | ||
|
|
790dd778bb | ||
|
|
a692ad47d6 | ||
|
|
46a8f81387 | ||
|
|
acb07352b7 | ||
|
|
5268da117c | ||
|
|
b6c1273ecd | ||
|
|
07f9c6e8c3 | ||
|
|
ea53a15105 | ||
|
|
716a150fc3 | ||
|
|
df3ae43ce7 | ||
|
|
09bbb59c61 | ||
|
|
0f731117d4 | ||
|
|
115c1ba082 | ||
|
|
135b24df6c | ||
|
|
207f03bb06 | ||
|
|
e78c7aba85 | ||
|
|
de6c594d5a | ||
|
|
41b82ec749 | ||
|
|
b915956f57 | ||
|
|
144b0a7512 | ||
|
|
359eb0b000 | ||
|
|
e47476129f | ||
|
|
0017845de2 | ||
|
|
ab0bab0c16 | ||
|
|
b9b1da1f04 | ||
|
|
0e22a25d3d | ||
|
|
8b61dc09d0 | ||
|
|
e1fa46290e | ||
|
|
ec5f510264 | ||
|
|
2d46b17493 | ||
|
|
99f478d891 | ||
|
|
451137e8fa | ||
|
|
fc88c2867d | ||
|
|
98704bc43d | ||
|
|
c5c8ffc70f | ||
|
|
94a228d78a | ||
|
|
625bbaf35c | ||
|
|
dd35e01f8a | ||
|
|
c1d46dcc2f | ||
|
|
85c66279b2 | ||
|
|
a18c5b975f |
13
PRIVACY.md
Normal file
13
PRIVACY.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Amethyst Privacy Policy & Terms of Use
|
||||
|
||||
Effective as of Jan 20, 2023.
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
|
||||
|
||||
If you have any questions about Amethyst or this privacy policy, you can send a message to amethyst@vitorpamplona.com
|
||||
@@ -19,7 +19,7 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [ ] Notification Bubbles
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Dropdown to Link Users/Posts when writting
|
||||
- [ ] Dropdown to Link Users/Posts when writing
|
||||
- [ ] Identity Verification (NIP-05)
|
||||
- [ ] Event Delegation (NIP-09)
|
||||
- [ ] Filter messages from Unknown People
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 11
|
||||
versionName "0.9"
|
||||
versionCode 35
|
||||
versionName "0.12.1"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -22,8 +22,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
//minifyEnabled false
|
||||
//proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -37,7 +37,7 @@ android {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.3.2'
|
||||
kotlinCompilerExtensionVersion '1.4.0'
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@@ -48,29 +48,30 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha04'
|
||||
implementation 'androidx.activity:activity-compose:1.6.1'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
implementation 'androidx.compose.material:material:1.3.1'
|
||||
|
||||
// Navigation
|
||||
implementation("androidx.navigation:navigation-compose:$nav_version")
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha04'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.3.1"
|
||||
|
||||
// Input
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha04'
|
||||
// Observe Live data as State
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha04'
|
||||
|
||||
implementation 'androidx.compose.material:material:1.4.0-alpha05'
|
||||
implementation "androidx.compose.material:material-icons-extended:1.4.0-alpha05"
|
||||
|
||||
// Lifecycle
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha05'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha05'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha05'
|
||||
implementation 'androidx.lifecycle:lifecycle-livedata:2.6.0-alpha05'
|
||||
|
||||
// Swipe Refresh
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.0'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -79,35 +80,44 @@ dependencies {
|
||||
exclude module: 'guava-testlib'
|
||||
}
|
||||
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.8.1'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
// Websockets API
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1'
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:0.28.0"
|
||||
|
||||
// link preview
|
||||
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
|
||||
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha04'
|
||||
|
||||
// upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:0.28.0"
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.2'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||
// view gifs
|
||||
implementation "io.coil-kt:coil-gif:2.2.2"
|
||||
// view svgs
|
||||
implementation("io.coil-kt:coil-svg:2.2.2")
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
// tabs for user profiles
|
||||
implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
|
||||
implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
|
||||
|
||||
// For QR generation
|
||||
implementation "com.google.zxing:core:3.5.0"
|
||||
implementation "androidx.camera:camera-camera2:1.2.1"
|
||||
implementation 'androidx.camera:camera-lifecycle:1.2.1'
|
||||
implementation 'androidx.camera:camera-view:1.2.1'
|
||||
implementation 'com.google.mlkit:vision-common:17.3.0'
|
||||
implementation 'com.google.android.gms:play-services-mlkit-barcode-scanning:18.1.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -18,4 +18,14 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
# For the Secp256k1 library
|
||||
-keepattributes InnerClasses
|
||||
|
||||
-keep class fr.acinq.secp256k1.jni.** { *; }
|
||||
# For the NostrPostr library
|
||||
-keep class nostr.postr.** { *; }
|
||||
-keep class nostr.postr.events.** { *; }
|
||||
-keep class com.google.gson.reflect.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.model.** { *; }
|
||||
-keep class com.vitorpamplona.amethyst.service.** { *; }
|
||||
@@ -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
|
||||
@@ -27,7 +28,7 @@ class EUrlUserTagTransformationTest {
|
||||
|
||||
@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(
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/amethyst"
|
||||
@@ -14,21 +15,36 @@
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Amethyst"
|
||||
android:largeHeap="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:label="Amethyst"
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="" />
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="barcode" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -4,9 +4,9 @@ import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
|
||||
class KeyStorage {
|
||||
class EncryptedStorage {
|
||||
|
||||
fun encryptedPreferences(context: Context): EncryptedSharedPreferences {
|
||||
fun preferences(context: Context): EncryptedSharedPreferences {
|
||||
val secretKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
|
||||
val preferencesName = "secret_keeper"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
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 nostr.postr.Persona
|
||||
import nostr.postr.toHex
|
||||
|
||||
class LocalPreferences(context: Context) {
|
||||
val encryptedPreferences = EncryptedStorage().preferences(context)
|
||||
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
remove("nostr_privkey")
|
||||
remove("nostr_pubkey")
|
||||
remove("following_channels")
|
||||
remove("hidden_users")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||
account.followingChannels.let { putStringSet("following_channels", it) }
|
||||
account.hiddenUsers.let { putStringSet("hidden_users", it) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString("nostr_privkey", null)
|
||||
val pubKey = getString("nostr_pubkey", null)
|
||||
val followingChannels = getStringSet("following_channels", null)?.toMutableSet() ?: mutableSetOf()
|
||||
val hiddenUsers = getStringSet("hidden_users", emptySet())?.toMutableSet() ?: mutableSetOf()
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLastRead(route: String, timestampInSecs: Long) {
|
||||
encryptedPreferences.edit().apply {
|
||||
putLong("last_read_route_${route}", timestampInSecs)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadLastRead(route: String): Long {
|
||||
encryptedPreferences.run {
|
||||
return getLong("last_read_route_${route}", 0)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object NotificationCache {
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
|
||||
fun markAsRead(route: String, timestampInSecs: Long, context: Context) {
|
||||
val lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null || timestampInSecs > lastTime) {
|
||||
lastReadByRoute.put(route, timestampInSecs)
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
LocalPreferences(context).saveLastRead(route, timestampInSecs)
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load(route: String, context: Context): Long {
|
||||
var lastTime = lastReadByRoute[route]
|
||||
if (lastTime == null) {
|
||||
lastTime = LocalPreferences(context).loadLastRead(route)
|
||||
lastReadByRoute[route] = lastTime
|
||||
}
|
||||
return lastTime
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: NotificationLiveData = NotificationLiveData(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache): LiveData<NotificationState>(NotificationState(cache)) {
|
||||
fun refresh() {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationState(val cache: NotificationCache)
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Constants
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
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.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
|
||||
fun start(account: Account) {
|
||||
this.account = account
|
||||
start()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
val myAccount = account
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: Constants.defaultRelays)
|
||||
|
||||
// start services
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrNotificationDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
|
||||
NostrGlobalDataSource.account = myAccount
|
||||
NostrChannelDataSource.account = myAccount
|
||||
|
||||
NostrUserProfileDataSource.account = myAccount
|
||||
NostrUserProfileFollowsDataSource.account = myAccount
|
||||
NostrUserProfileFollowersDataSource.account = myAccount
|
||||
|
||||
NostrAccountDataSource.start()
|
||||
NostrGlobalDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrNotificationDataSource.start()
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleChannelDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
NostrThreadDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
} else {
|
||||
// if not logged in yet, start a basic service wit default relays
|
||||
Client.connect()
|
||||
}
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrUserProfileFollowersDataSource.stop()
|
||||
NostrUserProfileFollowsDataSource.stop()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrNotificationDataSource.stop()
|
||||
NostrSingleChannelDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.lnurl
|
||||
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
@@ -128,7 +129,7 @@ object LnInvoiceUtil {
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier) {
|
||||
return when (multiplier.toLowerCase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
|
||||
@@ -6,14 +6,23 @@ 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.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.Contact
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
@@ -23,25 +32,66 @@ val DefaultChannels = setOf(
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
|
||||
)
|
||||
|
||||
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) {
|
||||
var seeReplies: Boolean = true
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
var followingChannels: Set<String> = DefaultChannels.toMutableSet(),
|
||||
var hiddenUsers: Set<String> = mutableSetOf()
|
||||
) {
|
||||
|
||||
fun userProfile(): User {
|
||||
return LocalCache.getOrCreateUser(loggedIn.pubKey)
|
||||
return LocalCache.getOrCreateUser(loggedIn.pubKey.toHexKey())
|
||||
}
|
||||
|
||||
fun followingChannels(): List<Channel> {
|
||||
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
fun hiddenUsers(): List<User> {
|
||||
return hiddenUsers.map { LocalCache.getOrCreateUser(it) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean {
|
||||
return loggedIn.privKey != null
|
||||
}
|
||||
|
||||
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
val event = if (lastestContactList != null) {
|
||||
ContactListEvent.create(
|
||||
lastestContactList.follows,
|
||||
relays,
|
||||
loggedIn.privKey!!)
|
||||
} else {
|
||||
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun sendNewUserMetadata(toString: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
loggedIn.privKey?.let {
|
||||
val createdAt = Date().time / 1000
|
||||
val content = toString
|
||||
val pubKey = Utils.pubkeyCreate(it)
|
||||
val tags = listOf<List<String>>()
|
||||
val id = Event.generateId(pubKey, createdAt, MetadataEvent.kind, tags, content)
|
||||
val sig = Utils.sign(id, it)
|
||||
val event = MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun reactTo(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (note.reactions.firstOrNull { it.author == userProfile() } != null) {
|
||||
if (note.hasReacted(userProfile(), "+")) {
|
||||
// has already liked this note
|
||||
return
|
||||
}
|
||||
@@ -53,9 +103,48 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
}
|
||||
}
|
||||
|
||||
fun report(note: Note, type: ReportEvent.ReportType) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (note.hasReacted(userProfile(), "⚠️")) {
|
||||
// has already liked this note
|
||||
return
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.createWarning(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReportEvent.create(it, type, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun report(user: User, type: ReportEvent.ReportType) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (user.hasReport(userProfile(), type)) {
|
||||
// has already reported this note
|
||||
return
|
||||
}
|
||||
|
||||
val event = ReportEvent.create(user.pubkeyHex, type, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun boost(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (note.hasBoosted(userProfile())) {
|
||||
// has already bosted in the past 5mins
|
||||
return
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = RepostEvent.create(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
@@ -72,12 +161,19 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
fun follow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().lastestContactList
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
val event = if (lastestContactList != null) {
|
||||
ContactListEvent.create(lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), lastestContactList.relayUse, loggedIn.privKey!!)
|
||||
ContactListEvent.create(
|
||||
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
|
||||
userProfile().relays,
|
||||
loggedIn.privKey!!)
|
||||
} else {
|
||||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(listOf(Contact(user.pubkeyHex, null)), relays, loggedIn.privKey!!)
|
||||
ContactListEvent.create(
|
||||
listOf(Contact(user.pubkeyHex, null)),
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
@@ -87,52 +183,44 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
fun unfollow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().lastestContactList
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
if (lastestContactList != null) {
|
||||
val event = ContactListEvent.create(lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, lastestContactList.relayUse, loggedIn.privKey!!)
|
||||
val event = ContactListEvent.create(
|
||||
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
userProfile().relays,
|
||||
loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(message: String, originalNote: Note?, modifiedMentions: List<User>?) {
|
||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val replyToEvent = originalNote?.event
|
||||
if (replyToEvent is TextNoteEvent) {
|
||||
val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet()
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
|
||||
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex()).plus(modifiedMentionsHex).filter {
|
||||
it in modifiedMentionsHex
|
||||
}
|
||||
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = repliesTo,
|
||||
mentions = mentions,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
} else {
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = null,
|
||||
mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(),
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) {
|
||||
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List<User>?) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvent = ChannelMessageEvent.create(
|
||||
message = message,
|
||||
channel = toChannel,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
@@ -154,7 +242,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendCreateNewChannel(name: String, about: String, picture: String, accountStateViewModel: AccountStateViewModel) {
|
||||
fun sendCreateNewChannel(name: String, about: String, picture: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val metadata = ChannelCreateEvent.ChannelData(
|
||||
@@ -169,17 +257,27 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
joinChannel(event.id.toHex(), accountStateViewModel)
|
||||
joinChannel(event.id.toHex())
|
||||
}
|
||||
|
||||
fun joinChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
|
||||
followingChannels.add(idHex)
|
||||
accountStateViewModel.saveToEncryptedStorage(this)
|
||||
fun joinChannel(idHex: String) {
|
||||
followingChannels = followingChannels + idHex
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun leaveChannel(idHex: String, accountStateViewModel: AccountStateViewModel) {
|
||||
followingChannels.remove(idHex)
|
||||
accountStateViewModel.saveToEncryptedStorage(this)
|
||||
fun leaveChannel(idHex: String) {
|
||||
followingChannels = followingChannels - idHex
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun hideUser(pubkeyHex: String) {
|
||||
hiddenUsers = hiddenUsers + pubkeyHex
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun showUser(pubkeyHex: String) {
|
||||
hiddenUsers = hiddenUsers - pubkeyHex
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
|
||||
@@ -198,7 +296,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
followingChannels.add(event.id.toHex())
|
||||
joinChannel(event.id.toHex())
|
||||
}
|
||||
|
||||
fun decryptContent(note: Note): String? {
|
||||
@@ -231,26 +329,65 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
}
|
||||
}
|
||||
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
return userProfile().relays?.map { Relay(it.key, it.value.read, it.value.write) }?.toTypedArray()
|
||||
}
|
||||
|
||||
init {
|
||||
userProfile().subscribe(object: User.Listener() {
|
||||
override fun onRelayChange() {
|
||||
Client.disconnect()
|
||||
Client.connect(activeRelays() ?: Constants.defaultRelays)
|
||||
RelayPool.requestAndWatch()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
fun isHidden(user: User) = user in hiddenUsers()
|
||||
|
||||
fun isAcceptable(user: User): Boolean {
|
||||
return user !in hiddenUsers() // if user hasn't hided this author
|
||||
&& user.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
|
||||
&& user.reportsBy( userProfile().follows ).size < 5
|
||||
}
|
||||
|
||||
fun isAcceptableDirect(note: Note): Boolean {
|
||||
return note.reportsBy( userProfile() ).isEmpty() // if user has not reported this post
|
||||
&& note.reportsBy( userProfile().follows ).size < 5 // if it has 5 reports by reliable users
|
||||
}
|
||||
|
||||
fun isAcceptable(note: Note): Boolean {
|
||||
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)
|
||||
) // is not a reaction about a blocked post
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
|
||||
fun refresh() {
|
||||
postValue(AccountState(account))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
}
|
||||
}
|
||||
|
||||
class AccountState(val account: Account)
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
class Channel(val id: ByteArray) {
|
||||
val idHex = id.toHexKey()
|
||||
@@ -20,13 +17,13 @@ class Channel(val id: ByteArray) {
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return info.name ?: idDisplayHex
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
return notes[idHex] ?: run {
|
||||
val answer = Note(idHex)
|
||||
notes.put(idHex, answer)
|
||||
answer
|
||||
}
|
||||
fun addNote(note: Note) {
|
||||
notes[note.idHex] = note
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
@@ -60,6 +57,16 @@ class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState
|
||||
fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
NostrSingleEventDataSource.add(channel.idHex)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
NostrSingleEventDataSource.remove(channel.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelState(val channel: Channel)
|
||||
|
||||
@@ -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 {
|
||||
@@ -26,8 +37,6 @@ fun HexKey.toDisplayHexKey(): String {
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (key.startsWith("npub")) {
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.time.Instant
|
||||
@@ -17,8 +19,10 @@ import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.DeletionEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
import nostr.postr.events.RecommendRelayEvent
|
||||
@@ -37,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
|
||||
}
|
||||
@@ -67,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) {
|
||||
@@ -80,6 +81,9 @@ 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)}")
|
||||
}
|
||||
@@ -90,29 +94,33 @@ object LocalCache {
|
||||
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||
}
|
||||
|
||||
fun consume(event: TextNoteEvent) {
|
||||
fun consume(event: TextNoteEvent, relay: Relay? = null) {
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
|
||||
if (relay != null)
|
||||
author.addRelay(relay, event.createdAt)
|
||||
|
||||
// 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) }.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 {
|
||||
it.taggedPosts.add(note)
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
replyTo.forEach {
|
||||
it.author?.taggedPosts?.add(note)
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
@@ -128,7 +136,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}")
|
||||
@@ -136,21 +144,32 @@ 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(event.toJson())
|
||||
println("UpdateFollows: " + event.toJson())
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}.filterNotNull(),
|
||||
}.filterNotNull().toSet(),
|
||||
event.createdAt
|
||||
)
|
||||
|
||||
user.lastestContactList = event
|
||||
}
|
||||
try {
|
||||
if (event.content.isNotEmpty()) {
|
||||
val relays: Map<String, ContactListEvent.ReadWrite> =
|
||||
Event.gson.fromJson(
|
||||
event.content,
|
||||
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
|
||||
)
|
||||
user.updateRelays(relays)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
user.latestContactList = event
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PrivateDmEvent) {
|
||||
@@ -159,13 +178,13 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val recipient = event.recipientPubKey?.let { getOrCreateUser(it) }
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
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)
|
||||
|
||||
@@ -189,21 +208,21 @@ 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 {
|
||||
it.taggedPosts.add(note)
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.author?.taggedPosts?.add(note)
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
@@ -215,14 +234,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)
|
||||
|
||||
@@ -230,38 +249,76 @@ object LocalCache {
|
||||
|
||||
// Adds notifications to users.
|
||||
mentions.forEach {
|
||||
it.taggedPosts.add(note)
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.author?.taggedPosts?.add(note)
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
if (event.content == "" || event.content == "+" || event.content == "\uD83E\uDD19") {
|
||||
if (
|
||||
event.content == "" ||
|
||||
event.content == "+" ||
|
||||
event.content == "\u2764\uFE0F" || // red heart
|
||||
event.content == "\uD83E\uDD19" || // call me hand
|
||||
event.content == "\uD83D\uDC4D" // thumbs up
|
||||
) {
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addReaction(note)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.content == "!" // nostr_console hide.
|
||||
|| event.content == "\u26A0\uFE0F" // Warning sign
|
||||
) {
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ReportEvent) {
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
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)
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
|
||||
//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)
|
||||
|
||||
val note = oldChannel.getOrCreateNote(event.id.toHex())
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
// older data, does nothing
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
fun consume(event: ChannelMetadataEvent) {
|
||||
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
@@ -269,20 +326,21 @@ 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)
|
||||
|
||||
val note = oldChannel.getOrCreateNote(event.id.toHex())
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
fun consume(event: ChannelMessageEvent) {
|
||||
@@ -290,14 +348,17 @@ object LocalCache {
|
||||
|
||||
val channel = getOrCreateChannel(event.channel)
|
||||
|
||||
val note = channel.getOrCreateNote(event.id.toHex())
|
||||
val note = getOrCreateNote(event.id.toHex())
|
||||
channel.addNote(note)
|
||||
|
||||
// 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 { channel.getOrCreateNote(it) }.toMutableList())
|
||||
val author = getOrCreateUser(event.pubKey.toHexKey())
|
||||
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)
|
||||
@@ -306,10 +367,10 @@ object LocalCache {
|
||||
|
||||
// Adds notifications to users.
|
||||
mentions.forEach {
|
||||
it.taggedPosts.add(note)
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
replyTo.forEach {
|
||||
it.author?.taggedPosts?.add(note)
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
@@ -338,7 +399,8 @@ object LocalCache {
|
||||
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
return notes.values.filter {
|
||||
(it.event is TextNoteEvent && it.event?.content?.contains(text) ?: false)
|
||||
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|
||||
|| (it.event is ChannelMessageEvent && it.event?.content?.contains(text, true) ?: false)
|
||||
|| it.idHex.startsWith(text, true)
|
||||
|| it.id.toNote().startsWith(text, true)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
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
|
||||
@@ -15,35 +15,48 @@ import kotlinx.coroutines.Dispatchers
|
||||
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.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
|
||||
val id = Hex.decode(idHex)
|
||||
val idDisplayHex = id.toShortenHex()
|
||||
val idDisplayNote = id.toNote().toShortenHex()
|
||||
|
||||
// These fields are only available after the Text Note event is received.
|
||||
// They are immutable after that.
|
||||
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>())
|
||||
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 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.invalidateData()
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
@@ -74,18 +87,31 @@ class Note(val idHex: String) {
|
||||
}
|
||||
|
||||
fun addReply(note: Note) {
|
||||
if (replies.add(note))
|
||||
invalidateData()
|
||||
if (note !in replies) {
|
||||
replies = replies + note
|
||||
liveReplies.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (boosts.add(note))
|
||||
invalidateData()
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
liveBoosts.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (reactions.add(note))
|
||||
invalidateData()
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
liveReactions.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
if (note !in reports) {
|
||||
reports = reports + note
|
||||
liveReports.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
@@ -96,26 +122,75 @@ class Note(val idHex: String) {
|
||||
return boosts.any { it.author == user }
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): List<Note> {
|
||||
return reports.filter { it.author == user }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
return reports.filter { it.author in users }
|
||||
}
|
||||
|
||||
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.
|
||||
val live: NoteLiveData = NoteLiveData(this)
|
||||
|
||||
val liveReactions: NoteLiveData = NoteLiveData(this)
|
||||
val liveBoosts: NoteLiveData = NoteLiveData(this)
|
||||
val liveReplies: NoteLiveData = NoteLiveData(this)
|
||||
val liveReports: NoteLiveData = NoteLiveData(this)
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
live.refresh()
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
fun refresh() {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object UrlCachedPreviewer {
|
||||
val cache = ConcurrentHashMap<String, UrlInfoItem>()
|
||||
var cache = mapOf<String, UrlInfoItem>()
|
||||
private set
|
||||
var failures = mapOf<String, Throwable>()
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
cache[url]?.let {
|
||||
@@ -22,15 +25,21 @@ object UrlCachedPreviewer {
|
||||
return
|
||||
}
|
||||
|
||||
failures[url]?.let {
|
||||
callback?.onFailed(it)
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
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 = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}).fetchUrlPreview()
|
||||
@@ -53,8 +62,10 @@ object UrlCachedPreviewer {
|
||||
// Preload Images? Isn't this too heavy?
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
// Do nothing for now.
|
||||
} else {
|
||||
} else if (isValidURL(removedParamsFromUrl)) {
|
||||
previewInfo(it)
|
||||
} else {
|
||||
previewInfo("https://${it}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
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.Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -14,24 +15,46 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.toNpub
|
||||
|
||||
class User(val pubkey: ByteArray) {
|
||||
val pubkeyHex = pubkey.toHexKey()
|
||||
val pubkeyDisplayHex = pubkey.toShortenHex()
|
||||
class User(val pubkeyHex: String) {
|
||||
val pubkey = Hex.decode(pubkeyHex)
|
||||
val pubkeyDisplayHex = pubkey.toNpub().toShortenHex()
|
||||
|
||||
var info = UserMetadata()
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
var updatedFollowsAt: Long = 0;
|
||||
|
||||
var lastestContactList: ContactListEvent? = null
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||
val follows = Collections.synchronizedSet(mutableSetOf<User>())
|
||||
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||
var follows = setOf<User>()
|
||||
private set
|
||||
var followers = setOf<User>()
|
||||
private set
|
||||
|
||||
val followers = Collections.synchronizedSet(mutableSetOf<User>())
|
||||
val messages = ConcurrentHashMap<User, MutableSet<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
|
||||
|
||||
var relaysBeingUsed = mapOf<String, RelayInfo>()
|
||||
private set
|
||||
|
||||
var messages = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
var latestMetadataRequestEOSE: Long? = null
|
||||
var latestReportRequestEOSE: Long? = null
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
return bestDisplayName() ?: bestUsername() ?: pubkeyDisplayHex
|
||||
@@ -50,44 +73,103 @@ class User(val pubkey: ByteArray) {
|
||||
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
|
||||
}
|
||||
|
||||
fun follow(user: User) {
|
||||
follows.add(user)
|
||||
user.followers.add(this)
|
||||
fun follow(user: User, followedAt: Long) {
|
||||
follows = follows + user
|
||||
user.followers = user.followers + this
|
||||
|
||||
invalidateData()
|
||||
user.invalidateData()
|
||||
liveFollows.invalidateData()
|
||||
user.liveFollows.invalidateData()
|
||||
|
||||
listeners.forEach {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
fun addTaggedPost(note: Note) {
|
||||
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 (note !in reports) {
|
||||
reports = reports + note
|
||||
liveReports.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun reportsBy(user: User): List<Note> {
|
||||
return reports.filter { it.author == user }
|
||||
}
|
||||
|
||||
fun reportsBy(users: Set<User>): List<Note> {
|
||||
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 = 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)
|
||||
live.refresh()
|
||||
val channel = getOrCreateChannel(user)
|
||||
if (msg !in channel) {
|
||||
messages = messages + Pair(user, channel + msg)
|
||||
liveMessages.invalidateData()
|
||||
updateSubscribers { it.onNewMessage() }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFollows(newFollows: List<User>, updateAt: Long) {
|
||||
val toBeAdded = synchronized(follows) {
|
||||
newFollows - follows
|
||||
}
|
||||
val toBeRemoved = synchronized(follows) {
|
||||
follows - newFollows
|
||||
data class RelayInfo (
|
||||
val url: String,
|
||||
var lastEvent: Long,
|
||||
var counter: Long
|
||||
)
|
||||
|
||||
fun addRelay(relay: Relay, eventTime: Long) {
|
||||
val here = relaysBeingUsed[relay.url]
|
||||
if (here == null) {
|
||||
relaysBeingUsed = relaysBeingUsed + Pair(relay.url, RelayInfo(relay.url, eventTime, 1))
|
||||
} else {
|
||||
if (eventTime > here.lastEvent) {
|
||||
here.lastEvent = eventTime
|
||||
}
|
||||
here.counter++
|
||||
}
|
||||
|
||||
updateSubscribers { it.onNewRelayInfo() }
|
||||
liveRelayInfo.invalidateData()
|
||||
}
|
||||
|
||||
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
|
||||
val toBeAdded = newFollows - follows
|
||||
val toBeRemoved = follows - newFollows
|
||||
|
||||
toBeAdded.forEach {
|
||||
follow(it)
|
||||
follow(it, updateAt)
|
||||
}
|
||||
toBeRemoved.forEach {
|
||||
unfollow(it)
|
||||
@@ -96,36 +178,87 @@ class User(val pubkey: ByteArray) {
|
||||
updatedFollowsAt = updateAt
|
||||
}
|
||||
|
||||
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
|
||||
if (relays != relayUse) {
|
||||
relays = relayUse
|
||||
listeners.forEach {
|
||||
it.onRelayChange()
|
||||
}
|
||||
}
|
||||
|
||||
liveRelays.invalidateData()
|
||||
}
|
||||
|
||||
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
|
||||
info = newUserInfo
|
||||
updatedMetadataAt = updateAt
|
||||
|
||||
invalidateData()
|
||||
liveMetadata.invalidateData()
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
return synchronized(follows) {
|
||||
follows.contains(user)
|
||||
}
|
||||
return follows.contains(user)
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: UserLiveData = UserLiveData(this)
|
||||
fun hasSentMessagesTo(user: User?): Boolean {
|
||||
val messagesToUser = messages[user] ?: return false
|
||||
|
||||
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
|
||||
private var listeners = setOf<Listener>()
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
|
||||
fun unsubscribe(listener: Listener) {
|
||||
listeners = listeners.minus(listener)
|
||||
}
|
||||
|
||||
abstract class Listener {
|
||||
open fun onRelayChange() = Unit
|
||||
open fun onFollowsChange() = 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.
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
var handlerWaiting = false
|
||||
var modelHandlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
fun updateSubscribers(on: (Listener) -> Unit) {
|
||||
if (modelHandlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
modelHandlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
live.refresh()
|
||||
handlerWaiting = false
|
||||
listeners.forEach {
|
||||
on(it)
|
||||
}
|
||||
modelHandlerWaiting = false
|
||||
}
|
||||
}
|
||||
|
||||
// UI Observers line up here.
|
||||
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 {
|
||||
@@ -154,7 +287,23 @@ class UserMetadata {
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||
fun refresh() {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import java.util.UUID
|
||||
import nostr.postr.JsonFilter
|
||||
|
||||
data class Channel (
|
||||
val id: String = UUID.randomUUID().toString().substring(0,4)
|
||||
val id: String = UUID.randomUUID().toString().substring(0,4),
|
||||
val onEOSE: ((Long) -> Unit)? = null
|
||||
) {
|
||||
var filter: List<JsonFilter>? = null // Inactive when null
|
||||
|
||||
fun updateEOSE(l: Long) {
|
||||
onEOSE?.let { it(l) }
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,21 @@ object Constants {
|
||||
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://nostr.v0l.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://nostr-relay.wlvs.space", read = true, write = true),
|
||||
//Relay("wss://nostr-2.zebedee.cloud", read = true, write = true),
|
||||
Relay("wss://eden.nostr.land", read = true, write = true),
|
||||
Relay("wss://nostr.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)
|
||||
Relay("wss://relay.nostr.ch", read = true, write = true),
|
||||
Relay("wss://no.str.cr", read = true, write = true),
|
||||
Relay("wss://nos.lol", read = true, write = true)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.bechToBytes
|
||||
|
||||
class Nip19 {
|
||||
|
||||
enum class Type {
|
||||
USER, NOTE
|
||||
}
|
||||
data class Return(val type: Type, val hex: String)
|
||||
|
||||
fun uriToRoute(uri: String?): Return? {
|
||||
try {
|
||||
val key = uri?.removePrefix("nostr:")
|
||||
|
||||
if (key != null) {
|
||||
val bytes = key.bechToBytes()
|
||||
if (key.startsWith("npub")) {
|
||||
return Return(Type.USER, bytes.toHexKey())
|
||||
}
|
||||
if (key.startsWith("note")) {
|
||||
return Return(Type.NOTE, bytes.toHexKey())
|
||||
}
|
||||
if (key.startsWith("nprofile")) {
|
||||
val tlv = parseTLV(bytes)
|
||||
val hex = tlv.get(0)?.get(0)?.toHexKey()
|
||||
if (hex != null)
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
if (key.startsWith("nevent")) {
|
||||
val tlv = parseTLV(bytes)
|
||||
val hex = tlv.get(0)?.get(0)?.toHexKey()
|
||||
if (hex != null)
|
||||
return Return(Type.USER, hex)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
println("Trying to Decode NIP19: ${uri}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseTLV(data: ByteArray): Map<Byte, List<ByteArray>> {
|
||||
var result = mutableMapOf<Byte, MutableList<ByteArray>>()
|
||||
var rest = data
|
||||
while (rest.isNotEmpty()) {
|
||||
val t = rest[0]
|
||||
val l = rest[1]
|
||||
val v = rest.sliceArray(IntRange(2, (2 + l) - 1))
|
||||
rest = rest.sliceArray(IntRange(2 + l, rest.size-1))
|
||||
if (v.size < l) continue
|
||||
|
||||
if (!result.containsKey(t)) {
|
||||
result.put(t, mutableListOf())
|
||||
}
|
||||
result.get(t)?.add(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,6 @@ import nostr.postr.events.TextNoteEvent
|
||||
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.observeForever(cacheListener)
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.removeObserver(cacheListener)
|
||||
}
|
||||
|
||||
fun createAccountContactListFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
@@ -41,34 +25,32 @@ object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
|
||||
return JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 3
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
|
||||
val accountMetadataChannel = requestNewChannel()
|
||||
val accountContactListChannel = requestNewChannel()
|
||||
fun createNotificationFilter() = JsonFilter(
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 100
|
||||
)
|
||||
|
||||
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 }
|
||||
.sortedBy { it.event!!.createdAt }
|
||||
.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.filter = listOf(
|
||||
createAccountMetadataFilter(),
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
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.ChannelMessageEvent
|
||||
import nostr.postr.JsonFilter
|
||||
|
||||
object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var channel: com.vitorpamplona.amethyst.model.Channel? = null
|
||||
|
||||
fun loadMessagesBetween(channelId: String) {
|
||||
channel = LocalCache.channels[channelId]
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
@@ -18,7 +20,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
return JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
|
||||
limit = 200
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -28,7 +30,7 @@ object NostrChannelDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
return channel?.notes?.values?.sortedBy { it.event?.createdAt } ?: emptyList()
|
||||
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -16,30 +16,44 @@ 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(): JsonFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
return if (myPeer != null) {
|
||||
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(): JsonFilter? {
|
||||
val myPeer = withUser
|
||||
|
||||
return if (myPeer != null) {
|
||||
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]
|
||||
val messages = account.userProfile().messages[withUser] ?: return emptyList()
|
||||
|
||||
return messages?.sortedBy { it.event!!.createdAt } ?: emptyList()
|
||||
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.filter = listOfNotNull(createMessagesToMeFilter(), createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -52,44 +52,35 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
val messagingWith = messages.keys.filter { account.isAcceptable(it) }
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull {
|
||||
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val publicChannels = account.followingChannels().map {
|
||||
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val channelsCreatedByMe = LocalCache.channels.values.filter {
|
||||
it.creator == account.userProfile()
|
||||
}.map {
|
||||
it.notes.values.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
|
||||
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
|
||||
}
|
||||
|
||||
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.filter = listOfNotNull(
|
||||
list,
|
||||
createLastChannelInfoFilter(),
|
||||
createLastMessageOfEachChannelFilter()
|
||||
).flatten().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
@@ -11,10 +12,13 @@ import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMuteUserEvent
|
||||
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.Relay
|
||||
import java.util.Collections
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -31,10 +35,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 channels = mapOf<String, Channel>()
|
||||
private var eventCounter = mapOf<String, Int>()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
@@ -44,35 +46,44 @@ 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 channels.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)
|
||||
}
|
||||
|
||||
//println("AAA ${debugName} ${subscriptionId} ${event.kind}")
|
||||
when (event) {
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is TextNoteEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
is RepostEvent -> LocalCache.consume(event)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
else -> when (event.kind) {
|
||||
RepostEvent.kind -> LocalCache.consume(RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
try {
|
||||
when (event) {
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is TextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ContactListEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event)
|
||||
is DeletionEvent -> LocalCache.consume(event)
|
||||
else -> when (event.kind) {
|
||||
RepostEvent.kind -> {
|
||||
val repostEvent = RepostEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig)
|
||||
|
||||
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))
|
||||
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))
|
||||
repostEvent.containedPost?.let { onEvent(it, subscriptionId, relay) }
|
||||
LocalCache.consume(repostEvent)
|
||||
}
|
||||
ReactionEvent.kind -> LocalCache.consume(ReactionEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
ReportEvent.kind -> LocalCache.consume(ReportEvent(event.id, event.pubKey, event.createdAt, event.tags, event.content, event.sig))
|
||||
|
||||
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))
|
||||
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))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +91,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."
|
||||
@@ -88,11 +99,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
|
||||
channels[channel]?.updateEOSE(Date().time / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
@@ -109,7 +119,7 @@ abstract class NostrDataSource<T>(val debugName: String) {
|
||||
}
|
||||
|
||||
open fun stop() {
|
||||
channels.forEach { channel ->
|
||||
channels.values.forEach { channel ->
|
||||
if (channel.filter != null) // if it is active, close
|
||||
Client.close(channel.id)
|
||||
}
|
||||
@@ -135,43 +145,48 @@ abstract class NostrDataSource<T>(val debugName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(): Channel {
|
||||
val newChannel = Channel()
|
||||
channels.add(newChannel)
|
||||
channelIds.add(newChannel.id)
|
||||
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Channel {
|
||||
val newChannel = Channel(debugName+UUID.randomUUID().toString().substring(0,4), onEOSE)
|
||||
channels = channels + Pair(newChannel.id, newChannel)
|
||||
return newChannel
|
||||
}
|
||||
|
||||
fun dismissChannel(channel: Channel) {
|
||||
Client.close(channel.id)
|
||||
channels.remove(channel)
|
||||
channelIds.remove(channel.id)
|
||||
channels = channels.minus(channel.id)
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
delay(200)
|
||||
resetFilters()
|
||||
resetFiltersSuspend()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFiltersSuspend() {
|
||||
// saves the channels that are currently active
|
||||
val activeChannels = channels.filter { it.filter != null }
|
||||
val activeChannels = channels.values.filter { it.filter != null }
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilter = activeChannels.associate { it.id to it.filter!!.joinToString("|") { it.toJson() } }
|
||||
|
||||
updateChannelFilters()
|
||||
|
||||
// Makes sure to only send an updated filter when it actually changes.
|
||||
channels.forEach { channel ->
|
||||
channels.values.forEach { channel ->
|
||||
val channelsNewFilter = channel.filter
|
||||
|
||||
if (channel in activeChannels) {
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
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.ChannelMessageEvent
|
||||
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),
|
||||
limit = 50
|
||||
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
override fun feed() = LocalCache.notes.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
|
||||
(it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()) ||
|
||||
(it.event is ChannelMessageEvent && (it.event as ChannelMessageEvent).replyTos.isEmpty())
|
||||
}
|
||||
.sortedBy { it.event!!.createdAt }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrHiddenAccountsDataSource: NostrDataSource<User>("HiddenAccounts") {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed() = account.hiddenUsers()
|
||||
|
||||
override fun updateChannelFilters() {}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
@@ -12,29 +12,29 @@ import nostr.postr.toHex
|
||||
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
resetFilters()
|
||||
object cacheListener: User.Listener() {
|
||||
override fun onFollowsChange() {
|
||||
resetFilters()
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.observeForever(cacheListener)
|
||||
account.userProfile().subscribe(cacheListener)
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.removeObserver(cacheListener)
|
||||
account.userProfile().unsubscribe(cacheListener)
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): JsonFilter {
|
||||
val follows = account.userProfile().follows ?: emptySet()
|
||||
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))
|
||||
@@ -42,7 +42,7 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
|
||||
return JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
|
||||
authors = followSet,
|
||||
limit = 200
|
||||
limit = 400
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,16 +67,10 @@ object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
|
||||
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 }
|
||||
.sortedBy { it.event!!.createdAt }
|
||||
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author in user.follows }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,28 +2,27 @@ 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 }
|
||||
}
|
||||
|
||||
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() {}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 nostr.postr.JsonFilter
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
object NostrSearchEventOrUserDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
private var hexToWatch: String? = null
|
||||
|
||||
private fun createAnythingWithIDFilter(): List<JsonFilter>? {
|
||||
if (hexToWatch == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return listOf(
|
||||
JsonFilter(
|
||||
ids = listOfNotNull(hexToWatch)
|
||||
),
|
||||
JsonFilter(
|
||||
authors = listOfNotNull(hexToWatch)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val searchChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return emptyList<Note>()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
searchChannel.filter = createAnythingWithIDFilter()
|
||||
}
|
||||
|
||||
fun search(eventId: String) {
|
||||
try {
|
||||
val hex = if (eventId.startsWith("npub") || eventId.startsWith("nsec")) {
|
||||
decodePublicKey(eventId).toHex()
|
||||
} else if (eventId.startsWith("note")) {
|
||||
eventId.bechToBytes().toHex()
|
||||
} else {
|
||||
eventId
|
||||
}
|
||||
hexToWatch = hex
|
||||
invalidateFilters()
|
||||
} catch (e: Exception) {
|
||||
// Usually when people add an incomplete npub or note.
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
hexToWatch = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrSingleChannelDataSource: NostrDataSource<Note>("SingleChannelFeed") {
|
||||
private var channelsToWatch = setOf<String>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): JsonFilter? {
|
||||
val reactionsToWatch = channelsToWatch.map { it }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads all the reactions to a given event.
|
||||
return JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
|
||||
val directEventsToLoad = channelsToWatch
|
||||
.map { LocalCache.getOrCreateChannel(it) }
|
||||
.filter { it.notes.isEmpty() }
|
||||
|
||||
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
}
|
||||
|
||||
val singleChannelChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
|
||||
singleChannelChannel.filter = listOfNotNull(reactions, missing).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
channelsToWatch = channelsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -2,70 +2,95 @@ 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.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import java.util.Collections
|
||||
import java.util.Date
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<String>()
|
||||
|
||||
private fun createRepliesAndReactionsFilter(): JsonFilter? {
|
||||
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
|
||||
private fun createRepliesAndReactionsFilter(): List<JsonFilter>? {
|
||||
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),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
)
|
||||
val now = Date().time / 1000
|
||||
|
||||
return reactionsToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime;
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.map {
|
||||
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<JsonFilter>? {
|
||||
val directEventsToLoad = eventsToWatch
|
||||
.mapNotNull { LocalCache.notes[it] }
|
||||
.map { LocalCache.getOrCreateNote(it) }
|
||||
.filter { it.event == null }
|
||||
|
||||
val threadingEventsToLoad = eventsToWatch
|
||||
.mapNotNull { LocalCache.notes[it] }
|
||||
.map { LocalCache.getOrCreateNote(it) }
|
||||
.mapNotNull { it.replyTo }
|
||||
.flatten()
|
||||
.filter { it.event == null }
|
||||
|
||||
val interestedEvents =
|
||||
(directEventsToLoad + threadingEventsToLoad)
|
||||
.map { it.idHex.substring(0, 8) }
|
||||
.map { it.idHex }.toSet()
|
||||
|
||||
if (interestedEvents.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// downloads linked events to this event.
|
||||
return JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
|
||||
ids = interestedEvents
|
||||
)
|
||||
return listOf(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 eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
return synchronized(eventsToWatch) {
|
||||
eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
|
||||
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
|
||||
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
|
||||
singleEventChannel.filter = listOfNotNull(reactions, missing).flatten().ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
|
||||
@@ -2,35 +2,54 @@ 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 nostr.postr.JsonFilter
|
||||
import nostr.postr.events.MetadataEvent
|
||||
|
||||
object NostrSingleUserDataSource: NostrDataSource<Note>("SingleUserFeed") {
|
||||
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
|
||||
var usersToWatch = setOf<String>()
|
||||
|
||||
fun createUserFilter(): List<JsonFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return usersToWatch.map {
|
||||
return usersToWatch.filter { LocalCache.getOrCreateUser(it).latestMetadata == null }.map {
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it.substring(0, 8)),
|
||||
limit = 10
|
||||
authors = listOf(it),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel()
|
||||
fun createUserReportFilter(): List<JsonFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return usersToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
usersToWatch.map {
|
||||
LocalCache.users[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userChannel.filter = createUserFilter()
|
||||
userChannel.filter = listOfNotNull(createUserFilter(), createUserReportFilter()).flatten()
|
||||
}
|
||||
|
||||
fun add(userId: String) {
|
||||
|
||||
@@ -9,25 +9,23 @@ 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.substring(0, 8) }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
if (eventsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
tags = mapOf("e" to eventsToWatch.toList())
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
|
||||
val eventsToLoad = eventsToWatch
|
||||
.map { LocalCache.notes[it] }
|
||||
.filterNotNull()
|
||||
val nodes = eventsToWatch.map { LocalCache.getOrCreateNote(it) }
|
||||
|
||||
val eventsToLoad = nodes
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex.substring(0, 8) }
|
||||
|
||||
@@ -40,37 +38,64 @@ object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
val repliesAndReactionsChannel = requestNewChannel()
|
||||
val loadEventsChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
repliesAndReactionsChannel.filter = listOfNotNull(createRepliesAndReactionsFilter()).ifEmpty { null }
|
||||
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String) {
|
||||
val note = LocalCache.notes[noteId] ?: return
|
||||
|
||||
val thread = mutableListOf<Note>()
|
||||
val threadSet = mutableSetOf<Note>()
|
||||
|
||||
val threadRoot = note.replyTo?.firstOrNull() ?: note
|
||||
|
||||
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 })
|
||||
return eventsToWatch.map {
|
||||
LocalCache.getOrCreateNote(it)
|
||||
}.sortedWith(order)
|
||||
}
|
||||
|
||||
resetFilters()
|
||||
override fun updateChannelFilters() {
|
||||
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter(), createRepliesAndReactionsFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
val markedAsRoot = note.event?.tags?.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1)
|
||||
if (markedAsRoot != null) return LocalCache.getOrCreateNote(markedAsRoot)
|
||||
|
||||
val hasNoReplyTo = note.replyTo?.firstOrNull { it.replyTo?.isEmpty() == true }
|
||||
if (hasNoReplyTo != null) return hasNoReplyTo
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
// recursive
|
||||
val roots = note.replyTo?.map {
|
||||
if (it !in testedNotes)
|
||||
searchRoot(it, testedNotes)
|
||||
else
|
||||
null
|
||||
}?.filterNotNull()
|
||||
|
||||
if (roots != null && roots.isNotEmpty()) {
|
||||
return roots[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String) {
|
||||
val note = LocalCache.getOrCreateNote(noteId)
|
||||
|
||||
if (note.event != null) {
|
||||
val thread = mutableListOf<Note>()
|
||||
val threadSet = mutableSetOf<Note>()
|
||||
|
||||
val threadRoot = searchRoot(note) ?: note
|
||||
|
||||
loadDown(threadRoot, thread, threadSet)
|
||||
|
||||
eventsToWatch = thread.map { it.idHex }.toSet()
|
||||
} else {
|
||||
eventsToWatch = setOf(noteId)
|
||||
}
|
||||
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun loadDown(note: Note, thread: MutableList<Note>, threadSet: MutableSet<Note>) {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
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.User
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.users[userId]
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
@@ -31,19 +35,35 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowersFilter() = 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.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.filter = listOf(
|
||||
createUserInfoFilter(),
|
||||
createUserPostsFilter(),
|
||||
createFollowFilter(),
|
||||
createFollowersFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
@@ -13,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.toList()
|
||||
}
|
||||
return user?.followers?.filter { account.isAcceptable(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followerChannel.filter = listOf(createFollowersFilter()).ifEmpty { null }
|
||||
}
|
||||
override fun updateChannelFilters() {}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
@@ -13,21 +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> {
|
||||
return user?.follows?.toList() ?: emptyList()
|
||||
return user?.follows?.filter { account.isAcceptable(it) } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followChannel.filter = listOf(createFollowFilter()).ifEmpty { null }
|
||||
}
|
||||
override fun updateChannelFilters() {}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class ChannelMessageEvent (
|
||||
|
||||
init {
|
||||
channel = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
replyTos = tags.filter { it.firstOrNull() == "e" && (it.size < 3 || (it.size > 3 && it[3] != "root")) }.mapNotNull { it.getOrNull(1) }
|
||||
replyTos = tags.filter { it.getOrNull(1) != channel }.mapNotNull { it.getOrNull(1) }
|
||||
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ class ReactionEvent (
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
fun createWarning(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("+", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.ui.text.toUpperCase
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
// NIP 56 event.
|
||||
class ReportEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
@Transient val reportType: List<ReportType>
|
||||
@Transient val reportedPost: List<String>
|
||||
@Transient val reportedAuthor: List<String>
|
||||
|
||||
init {
|
||||
reportType = tags.filter { it.firstOrNull() == "report" }.mapNotNull { it.getOrNull(1) }.map { ReportType.valueOf(it.toUpperCase()) }
|
||||
reportedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
reportedAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 1984
|
||||
|
||||
fun create(reportedPost: Event, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportTypeTag = listOf("report", type.name.toLowerCase())
|
||||
val reportPostTag = listOf("e", reportedPost.id.toHex())
|
||||
val reportAuthorTag = listOf("p", reportedPost.pubKey.toHex())
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags:List<List<String>> = listOf(reportTypeTag, reportPostTag, reportAuthorTag)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
|
||||
fun create(reportedUser: String, type: ReportType, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReportEvent {
|
||||
val content = ""
|
||||
|
||||
val reportTypeTag = listOf("report", type.name.toLowerCase())
|
||||
val reportAuthorTag = listOf("p", reportedUser)
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags:List<List<String>> = listOf(reportTypeTag, reportAuthorTag)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ReportType() {
|
||||
EXPLICIT,
|
||||
ILLEGAL,
|
||||
SPAM,
|
||||
IMPERSONATION
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
@@ -16,17 +17,24 @@ class RepostEvent (
|
||||
|
||||
@Transient val boostedPost: List<String>
|
||||
@Transient val originalAuthor: List<String>
|
||||
@Transient val containedPost: Event?
|
||||
|
||||
init {
|
||||
boostedPost = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
originalAuthor = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
containedPost = try {
|
||||
fromJson(content, Client.lenient)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 6
|
||||
|
||||
fun create(boostedPost: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RepostEvent {
|
||||
val content = ""
|
||||
val content = boostedPost.toJson()
|
||||
|
||||
val replyToPost = listOf("e", boostedPost.id.toHex())
|
||||
val replyToAuthor = listOf("p", boostedPost.pubKey.toHex())
|
||||
|
||||
@@ -27,10 +27,9 @@ object Client: RelayPool.Listener {
|
||||
private var relays = Constants.defaultRelays
|
||||
private val subscriptions = mutableMapOf<String, List<JsonFilter>>()
|
||||
|
||||
fun connect(
|
||||
relays: Array<Relay> = Constants.defaultRelays
|
||||
) {
|
||||
fun connect(relays: Array<Relay> = Constants.defaultRelays) {
|
||||
RelayPool.register(this)
|
||||
RelayPool.unloadRelays()
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
this.relays = relays
|
||||
}
|
||||
@@ -73,8 +72,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) {
|
||||
@@ -113,7 +112,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.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import java.util.Date
|
||||
import java.util.concurrent.TimeUnit
|
||||
import nostr.postr.events.Event
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -9,14 +12,25 @@ import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
||||
class Relay(
|
||||
val url: String,
|
||||
var read: Boolean = true,
|
||||
var write: Boolean = true
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
var write: Boolean = true
|
||||
) {
|
||||
private val httpClient = OkHttpClient()
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
|
||||
var eventDownloadCounter = 0
|
||||
var eventUploadCounter = 0
|
||||
var errorCounter = 0
|
||||
var ping: Long? = null
|
||||
|
||||
var closingTime = 0L
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -30,86 +44,117 @@ class Relay(
|
||||
}
|
||||
|
||||
fun requestAndWatch() {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val listener = object : WebSocketListener() {
|
||||
try {
|
||||
val request = Request.Builder().url(url.trim()).build()
|
||||
val listener = object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
|
||||
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
|
||||
}
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT) }
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
|
||||
val type = msg[0].asString
|
||||
val channel = msg[1].asString
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
listeners.forEach { it.onEvent(this@Relay, channel, event) }
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
|
||||
val type = msg[0].asString
|
||||
val channel = msg[1].asString
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
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, channel)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
// "channel" being the second string in the string array ...
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
it.onError(
|
||||
this@Relay,
|
||||
channel,
|
||||
Error("Unknown type $type on channel $channel. Msg was $text")
|
||||
)
|
||||
}
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
// "channel" being the second string in the string array ...
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: $channel"))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
it.onError(
|
||||
this@Relay,
|
||||
channel,
|
||||
Error("Unknown type $type on channel $channel. Msg was $text")
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
t.printStackTrace()
|
||||
text.chunked(2000) { chunked ->
|
||||
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
listeners.forEach { it.onRelayStateChange(
|
||||
this@Relay,
|
||||
Type.DISCONNECTING,
|
||||
null
|
||||
) }
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
socket = null
|
||||
closingTime = Date().time / 1000
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT, null) }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
errorCounter++
|
||||
|
||||
socket?.close(1000, "Normal close")
|
||||
// Failures disconnect the relay.
|
||||
socket = null
|
||||
closingTime = Date().time / 1000
|
||||
|
||||
Log.w("Relay", "Relay onFailure $url, ${response?.message}")
|
||||
t.printStackTrace()
|
||||
text.chunked(2000) { chunked ->
|
||||
listeners.forEach { it.onError(this@Relay, "", Error("Problem with $chunked")) }
|
||||
listeners.forEach {
|
||||
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECTING) }
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
socket = null
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.DISCONNECT) }
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
t.printStackTrace()
|
||||
listeners.forEach {
|
||||
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
|
||||
}
|
||||
}
|
||||
socket = httpClient.newWebSocket(request, listener)
|
||||
} catch (e: Exception) {
|
||||
closingTime = Date().time / 1000
|
||||
Log.e("Relay", "Relay Invalid $url")
|
||||
e.printStackTrace()
|
||||
}
|
||||
socket = httpClient.newWebSocket(request, listener)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
//httpClient.dispatcher.executorService.shutdown()
|
||||
closingTime = Date().time / 1000
|
||||
socket?.close(1000, "Normal close")
|
||||
socket = null
|
||||
}
|
||||
|
||||
fun sendFilter(requestId: String) {
|
||||
if (socket == null) {
|
||||
requestAndWatch()
|
||||
} else {
|
||||
val filters = Client.getSubscriptionFilters(requestId)
|
||||
if (filters.isNotEmpty()) {
|
||||
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
|
||||
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
|
||||
socket!!.send(request)
|
||||
if (read) {
|
||||
if (socket == null) {
|
||||
// waits 10 seconds
|
||||
if (Date().time / 1000 > closingTime + 10) {
|
||||
requestAndWatch()
|
||||
}
|
||||
} else {
|
||||
val filters = Client.getSubscriptionFilters(requestId)
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.toJson() }}]"""
|
||||
//println("FILTERSSENT ${url} " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
|
||||
socket?.send(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,8 +166,10 @@ class Relay(
|
||||
}
|
||||
|
||||
fun send(signedEvent: Event) {
|
||||
if (write)
|
||||
if (write) {
|
||||
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
|
||||
eventUploadCounter++
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String){
|
||||
@@ -154,6 +201,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?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ 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
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.events.Event
|
||||
|
||||
/**
|
||||
@@ -20,6 +23,10 @@ object RelayPool: Relay.Listener {
|
||||
return relays.filter { it.isConnected() }.size
|
||||
}
|
||||
|
||||
fun getRelay(url: String): Relay? {
|
||||
return relays.firstOrNull() { it.url == url }
|
||||
}
|
||||
|
||||
fun loadRelays(relayList: List<Relay>? = null){
|
||||
if (!relayList.isNullOrEmpty()){
|
||||
relayList.forEach { addRelay(it) }
|
||||
@@ -80,7 +87,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)
|
||||
}
|
||||
@@ -95,8 +102,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()
|
||||
}
|
||||
|
||||
@@ -108,7 +115,10 @@ object RelayPool: Relay.Listener {
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
live.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -14,28 +15,33 @@ import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.KeyStorage
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
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.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import coil.memory.MemoryCache
|
||||
import coil.request.CachePolicy
|
||||
import com.vitorpamplona.amethyst.EncryptedStorage
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.bechToBytes
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val nip19 = Nip19().uriToRoute(intent?.data?.toString())
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(this).components {
|
||||
if (SDK_INT >= 28) {
|
||||
@@ -44,7 +50,9 @@ class MainActivity : ComponentActivity() {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
}.build()
|
||||
}
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
setContent {
|
||||
@@ -53,10 +61,10 @@ class MainActivity : ComponentActivity() {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
|
||||
val accountViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
|
||||
AccountStateViewModel(LocalPreferences(applicationContext))
|
||||
}
|
||||
|
||||
AccountScreen(accountViewModel)
|
||||
AccountScreen(accountViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,24 +74,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Client.connect()
|
||||
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChannelDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrUserProfileFollowersDataSource.stop()
|
||||
NostrUserProfileFollowsDataSource.stop()
|
||||
ServiceManager.pause()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrNotificationDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
Client.disconnect()
|
||||
super.onPause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
@@ -15,8 +16,12 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
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
|
||||
@@ -26,10 +31,11 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel: AccountStateViewModel, channel: Channel? = null) {
|
||||
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
|
||||
val postViewModel: NewChannelViewModel = viewModel()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
postViewModel.load(account, channel, accountStateViewModel)
|
||||
postViewModel.load(account, channel)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
@@ -55,7 +61,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
postViewModel.create(context)
|
||||
onClose()
|
||||
},
|
||||
postViewModel.channelName.value.text.isNotBlank()
|
||||
@@ -77,7 +83,8 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
)
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
@@ -111,6 +118,7 @@ fun NewChannelView(onClose: () -> Unit, account: Account, accountStateViewModel:
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
maxLines = 10
|
||||
|
||||
)
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import nostr.postr.toHex
|
||||
|
||||
class NewChannelViewModel: ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var originalChannel: Channel? = null
|
||||
private var accountStateViewModel: AccountStateViewModel? = null
|
||||
|
||||
val channelName = mutableStateOf(TextFieldValue())
|
||||
val channelPicture = mutableStateOf(TextFieldValue())
|
||||
val channelDescription = mutableStateOf(TextFieldValue())
|
||||
|
||||
|
||||
fun load(account: Account, channel: Channel?, accountStateViewModel: AccountStateViewModel) {
|
||||
this.accountStateViewModel = accountStateViewModel
|
||||
fun load(account: Account, channel: Channel?) {
|
||||
this.account = account
|
||||
if (channel != null) {
|
||||
originalChannel = channel
|
||||
@@ -29,21 +30,24 @@ class NewChannelViewModel: ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun create() {
|
||||
if (originalChannel == null)
|
||||
this.account?.sendCreateNewChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text,
|
||||
accountStateViewModel!!
|
||||
)
|
||||
else
|
||||
this.account?.sendChangeChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text,
|
||||
originalChannel!!
|
||||
)
|
||||
fun create(context: Context) {
|
||||
this.account?.let { account ->
|
||||
if (originalChannel == null) {
|
||||
account.sendCreateNewChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text
|
||||
)
|
||||
|
||||
LocalPreferences(context).saveToEncryptedStorage(account)
|
||||
} else
|
||||
account.sendChangeChannel(
|
||||
channelName.value.text,
|
||||
channelDescription.value.text,
|
||||
channelPicture.value.text,
|
||||
originalChannel!!
|
||||
)
|
||||
}
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -35,8 +37,11 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
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
|
||||
@@ -67,7 +72,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
|
||||
val context = LocalContext.current
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = FocusRequester()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -152,7 +157,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary)
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
@@ -164,7 +170,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
|
||||
)
|
||||
) {
|
||||
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
UserLine(item) {
|
||||
UserLine(item, account) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +37,15 @@ 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 ->
|
||||
this.mentions = (replyNote.mentions ?: emptyList()).plus(replyUser)
|
||||
val currentMentions = replyNote.mentions ?: emptyList()
|
||||
if (currentMentions.contains(replyUser)) {
|
||||
this.mentions = currentMentions
|
||||
} else {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
this.account = account
|
||||
@@ -72,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)
|
||||
|
||||
@@ -89,7 +96,12 @@ class NewPostViewModel: ViewModel() {
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
|
||||
account?.sendPost(newMessage, originalNote, mentions)
|
||||
if (originalNote?.channel != null) {
|
||||
account?.sendChannelMeesage(newMessage, originalNote!!.channel!!.idHex, originalNote!!, mentions)
|
||||
} else {
|
||||
account?.sendPost(newMessage, replyTos, mentions)
|
||||
}
|
||||
|
||||
message = TextFieldValue("")
|
||||
urlPreview = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
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
|
||||
import androidx.compose.material.MaterialTheme
|
||||
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.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
|
||||
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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import java.lang.Math.round
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
|
||||
val feedState by postViewModel.relays.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
|
||||
if (index == 0)
|
||||
ServerConfigHeader()
|
||||
ServerConfig(item,
|
||||
onToggleDownload = {
|
||||
postViewModel.toggleDownload(it)
|
||||
},
|
||||
onToggleUpload = {
|
||||
postViewModel.toggleUpload(it)
|
||||
},
|
||||
onDelete = {
|
||||
postViewModel.deleteRelay(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
EditableServerConfig(relayToAdd) {
|
||||
postViewModel.addRelay(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerConfigHeader() {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Relay Address",
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.size(20.dp))
|
||||
|
||||
Text(
|
||||
text = "Posts",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
text = "Posts",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
|
||||
Text(
|
||||
text = "Errors",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ServerConfig(
|
||||
item: NewRelayListViewModel.Relay,
|
||||
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit,
|
||||
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit,
|
||||
onDelete: (NewRelayListViewModel.Relay) -> Unit) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = item.url.removePrefix("wss://"),
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.downloadCount)}",
|
||||
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 Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.uploadCount)}",
|
||||
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
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.errorCount)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
|
||||
var url by remember { mutableStateOf<String>(relayToAdd) }
|
||||
var read by remember { mutableStateOf(true) }
|
||||
var write by remember { mutableStateOf(true) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Add a Relay") },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = url,
|
||||
onValueChange = { url = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "server.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
IconButton(onClick = { read = !read }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(35.dp)
|
||||
.padding(horizontal = 5.dp),
|
||||
tint = if (read) Color.Green else Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { write = !write }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Upload,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(35.dp)
|
||||
.padding(horizontal = 5.dp),
|
||||
tint = if (write) Color.Green else Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (url.isNotBlank()) {
|
||||
val addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
|
||||
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write))
|
||||
url = ""
|
||||
write = true
|
||||
read = true
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (url.isNotBlank()) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = "Add", color = Color.White)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun countToHumanReadable(counter: Int) = when {
|
||||
counter >= 1000000000 -> "${round(counter/1000000000f)}G"
|
||||
counter >= 1000000 -> "${round(counter/1000000f)}M"
|
||||
counter >= 1000 -> "${round(counter/1000f)}k"
|
||||
else -> "$counter"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.Constants
|
||||
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())
|
||||
val relays = _relays.asStateFlow()
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
clear()
|
||||
}
|
||||
|
||||
fun create() {
|
||||
relays.let {
|
||||
account.sendNewRelayList(it.value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
|
||||
}
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_relays.update {
|
||||
val relayFile = account.userProfile().relays
|
||||
|
||||
if (relayFile != null)
|
||||
relayFile.map {
|
||||
val liveRelay = RelayPool.getRelay(it.key)
|
||||
|
||||
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)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
else
|
||||
Constants.defaultRelays.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)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
fun addRelay(relay: Relay) {
|
||||
if (relays.value.filter { it.url == relay.url }.isNotEmpty()) return
|
||||
|
||||
_relays.update {
|
||||
it.plus(relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRelay(relay: Relay) {
|
||||
_relays.update {
|
||||
it.minus(relay)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDownload(relay: Relay) {
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(read = !relay.read))
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleUpload(relay: Relay) {
|
||||
_relays.update {
|
||||
it.updated(relay, relay.copy(write = !relay.write))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Iterable<T>.updated(old: T, new: T): List<T> = map { if (it == old) new else it }
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
val postViewModel: NewUserMetadataViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
},
|
||||
postViewModel.userName.value.isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Display Name") },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.displayName.value,
|
||||
onValueChange = { postViewModel.displayName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "My display name",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Text("@", Modifier.padding(5.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Username") },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.userName.value,
|
||||
onValueChange = { postViewModel.userName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "My username",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "About me") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
value = postViewModel.about.value,
|
||||
onValueChange = { postViewModel.about.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "About me",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
maxLines = 10
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Avatar URL") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.picture.value,
|
||||
onValueChange = { postViewModel.picture.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "https://mywebsite.com/me.jpg",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Banner URL") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.banner.value,
|
||||
onValueChange = { postViewModel.banner.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "https://mywebsite.com/mybanner.jpg",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Website URL") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.website.value,
|
||||
onValueChange = { postViewModel.website.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "https://mywebsite.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "NIP-05") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.nip05.value,
|
||||
onValueChange = { postViewModel.nip05.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "_@mywebsite.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "LN Address") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.lnAddress.value,
|
||||
onValueChange = { postViewModel.lnAddress.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "me@mylightiningnode.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.StringWriter
|
||||
|
||||
class NewUserMetadataViewModel: ViewModel() {
|
||||
private lateinit var account: Account
|
||||
|
||||
val userName = mutableStateOf("")
|
||||
val displayName = mutableStateOf("")
|
||||
val about = mutableStateOf("")
|
||||
|
||||
val picture = mutableStateOf("")
|
||||
val banner = mutableStateOf("")
|
||||
|
||||
val website = mutableStateOf("")
|
||||
val nip05 = mutableStateOf("")
|
||||
val lnAddress = mutableStateOf("")
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
|
||||
account.userProfile().let {
|
||||
userName.value = it.bestUsername() ?: ""
|
||||
displayName.value = it.bestDisplayName() ?: ""
|
||||
about.value = it.info.about ?: ""
|
||||
picture.value = it.info.picture ?: ""
|
||||
banner.value = it.info.banner ?: ""
|
||||
website.value = it.info.website ?: ""
|
||||
nip05.value = it.info.nip05 ?: ""
|
||||
lnAddress.value = it.info.lud16 ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
fun create() {
|
||||
// Tries to not delete any existing attribute that we do not work with.
|
||||
val latest = account.userProfile().latestMetadata
|
||||
val currentJson = if (latest != null) {
|
||||
ObjectMapper().readTree(
|
||||
ByteArrayInputStream(latest.content.toByteArray(Charsets.UTF_8))
|
||||
) as ObjectNode
|
||||
} else {
|
||||
ObjectMapper().createObjectNode()
|
||||
}
|
||||
currentJson.put("name", userName.value)
|
||||
currentJson.put("username", userName.value)
|
||||
currentJson.put("display_name", displayName.value)
|
||||
currentJson.put("displayName", displayName.value)
|
||||
currentJson.put("picture", picture.value)
|
||||
currentJson.put("banner", banner.value)
|
||||
currentJson.put("website", website.value)
|
||||
currentJson.put("about", about.value)
|
||||
currentJson.put("nip05", nip05.value)
|
||||
currentJson.put("lud16", lnAddress.value)
|
||||
|
||||
val writer = StringWriter()
|
||||
ObjectMapper().writeValue(writer, currentJson)
|
||||
|
||||
account.sendNewUserMetadata(writer.buffer.toString())
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
userName.value = ""
|
||||
displayName.value = ""
|
||||
about.value = ""
|
||||
picture.value = ""
|
||||
banner.value = ""
|
||||
website.value = ""
|
||||
nip05.value = ""
|
||||
lnAddress.value = ""
|
||||
}
|
||||
}
|
||||
@@ -48,14 +48,14 @@ fun UploadFromGallery(onImageChosen: (Uri) -> Unit) {
|
||||
showGallerySelect = true
|
||||
}
|
||||
) {
|
||||
Text("Add Image from Gallery")
|
||||
Text("Upload Image")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column {
|
||||
Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
|
||||
Text("Add Image from Gallery")
|
||||
Text("Upload Image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,13 +28,13 @@ import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewChannelButton(account: Account, accountStateViewModel: AccountStateViewModel) {
|
||||
fun NewChannelButton(account: Account) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost)
|
||||
NewChannelView({ wantsToPost = false }, account = account, accountStateViewModel)
|
||||
NewChannelView({ wantsToPost = false }, account = account)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { wantsToPost = true },
|
||||
|
||||
@@ -14,13 +14,12 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
|
||||
@Composable
|
||||
fun ClickableNoteTag(
|
||||
note: Note,
|
||||
baesNote: Note,
|
||||
navController: NavController
|
||||
) {
|
||||
val innerNoteState by note.live.observeAsState()
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerNoteState?.note?.id?.toNote()?.toShortenHex()} "),
|
||||
onClick = { navController.navigate("Note/${innerNoteState?.note?.idHex}") },
|
||||
text = AnnotatedString("@${baesNote.id.toNote().toShortenHex()} "),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
import com.vitorpamplona.amethyst.service.Nip19
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import nostr.postr.toNpub
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
val userBase = LocalCache.getOrCreateUser(nip19.hex)
|
||||
|
||||
val userState by userBase.liveMetadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val route = "User/${nip19.hex}"
|
||||
val text = user.toBestDisplayName()
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${text} "),
|
||||
onClick = { navController.navigate(route) },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote} "),
|
||||
onClick = { navController.navigate("Channel/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else if (note.channel != null) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.channel?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("Channel/${note.channel?.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
} else {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${note.idDisplayNote} "),
|
||||
onClick = { navController.navigate("Note/${nip19.hex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}") },
|
||||
|
||||
@@ -2,22 +2,31 @@ 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.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 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)$")
|
||||
@@ -40,8 +49,9 @@ fun isValidURL(url: String?): Boolean {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun RichTextViewer(content: String, tags: List<List<String>>?, navController: NavController) {
|
||||
Column(modifier = Modifier.padding(top = 5.dp)) {
|
||||
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
content.split('\n').forEach { paragraph ->
|
||||
|
||||
@@ -62,14 +72,19 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches()) {
|
||||
} 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 ")
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +93,42 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
|
||||
}
|
||||
}
|
||||
|
||||
fun isBechLink(word: String): Boolean {
|
||||
return word.startsWith("nostr:", true)
|
||||
|| word.startsWith("npub1", true)
|
||||
|| word.startsWith("note1", true)
|
||||
|| word.startsWith("nprofile1", true)
|
||||
|| word.startsWith("nevent1", true)
|
||||
|| word.startsWith("@npub1", true)
|
||||
|| word.startsWith("@note1", true)
|
||||
|| word.startsWith("@nprofile1", true)
|
||||
|| word.startsWith("@nevent1", true)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BechLink(word: String, navController: NavController) {
|
||||
val uri = if (word.startsWith("nostr", true)) {
|
||||
word
|
||||
} else if (word.startsWith("@")) {
|
||||
word.replaceFirst("@", "nostr:")
|
||||
} else {
|
||||
"nostr:${word}"
|
||||
}
|
||||
|
||||
val nip19Route = try {
|
||||
Nip19().uriToRoute(uri)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (nip19Route == null) {
|
||||
Text(text = "$word ")
|
||||
} else {
|
||||
ClickableRoute(nip19Route, navController)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun TagLink(word: String, tags: List<List<String>>, navController: NavController) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
@@ -100,14 +151,14 @@ fun TagLink(word: String, tags: List<List<String>>, navController: NavController
|
||||
if (user != null) {
|
||||
ClickableUserTag(user, navController)
|
||||
} else {
|
||||
Text(text = "${tags[index][1].toShortenHex()} ")
|
||||
Text(text = "${tags[index][1].toByteArray().toNpub().toShortenHex()} ")
|
||||
}
|
||||
} else if (tags[index][0] == "e") {
|
||||
val note = LocalCache.notes[tags[index][1]]
|
||||
if (note != null) {
|
||||
ClickableNoteTag(note, navController)
|
||||
} else {
|
||||
Text(text = "${tags[index][1].toShortenHex()} ")
|
||||
Text(text = "${tags[index][1].toByteArray().toNote().toShortenHex()} ")
|
||||
}
|
||||
} else
|
||||
Text(text = "$word ")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.BottomNavigation
|
||||
import androidx.compose.material.BottomNavigationItem
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavHostController
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.note.NewItemsBubble
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val bottomNavigationItems = listOf(
|
||||
Route.Home,
|
||||
@@ -24,6 +42,7 @@ val bottomNavigationItems = listOf(
|
||||
@Composable
|
||||
fun AppBottomBar(navController: NavHostController) {
|
||||
val currentRoute = currentRoute(navController)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column() {
|
||||
Divider(
|
||||
@@ -36,21 +55,31 @@ fun AppBottomBar(navController: NavHostController) {
|
||||
) {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
BottomNavigationItem(
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = item.icon),
|
||||
null,
|
||||
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
|
||||
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
|
||||
)
|
||||
},
|
||||
icon = { NotifiableIcon(item, currentRoute) },
|
||||
selected = currentRoute == item.route,
|
||||
onClick = {
|
||||
if (currentRoute != item.route) {
|
||||
navController.navigate(item.route)
|
||||
} else {
|
||||
// TODO: Make it scrool to the top
|
||||
navController.navigate(item.route)
|
||||
coroutineScope.launch {
|
||||
if (currentRoute != item.route) {
|
||||
navController.navigate(item.route){
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start)
|
||||
restoreState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
} else {
|
||||
// TODO: Make it scrool to the top
|
||||
navController.navigate(item.route){
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start) { inclusive = item.route == Route.Home.route }
|
||||
restoreState = true
|
||||
}
|
||||
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -58,3 +87,52 @@ fun AppBottomBar(navController: NavHostController) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotifiableIcon(item: Route, currentRoute: String?) {
|
||||
Box(Modifier.size(if ("Home" == item.route) 25.dp else 23.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(id = item.icon),
|
||||
null,
|
||||
modifier = Modifier.size(if ("Home" == item.route) 24.dp else 20.dp),
|
||||
tint = if (currentRoute == item.route) MaterialTheme.colors.primary else Color.Unspecified
|
||||
)
|
||||
|
||||
// Notification
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val notifState = NotificationCache.live.observeAsState()
|
||||
|
||||
val db = dbState.value
|
||||
val notif = notifState.value
|
||||
|
||||
if (db != null && notif != null) {
|
||||
if (item.hasNewItems(db.cache, notif.cache)) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(10.dp)
|
||||
.height(10.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(10.dp)
|
||||
.height(10.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.primary),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
Text(
|
||||
"",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,16 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
nextPage: String? = null
|
||||
) {
|
||||
NavHost(navController, startDestination = Route.Home.route) {
|
||||
Routes.forEach {
|
||||
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, accountStateViewModel, navController))
|
||||
}
|
||||
}
|
||||
|
||||
if (nextPage != null) {
|
||||
navController.navigate(nextPage)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,16 +24,24 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
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.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.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
@@ -45,6 +57,9 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -59,7 +74,10 @@ fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, ac
|
||||
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().liveMetadata.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
@@ -68,47 +86,98 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToEditRelays)
|
||||
NewRelayListView({ wantsToEditRelays = false }, account)
|
||||
|
||||
Column() {
|
||||
TopAppBar(
|
||||
elevation = 0.dp,
|
||||
backgroundColor = Color(0xFFFFFF),
|
||||
title = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 22.dp, end = 0.dp)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
Client.allSubscriptions().map { "${it} ${Client.getSubscriptionFilters(it).joinToString { it.toJson() }}" }.forEach {
|
||||
Log.d("CURRENT FILTERS", it)
|
||||
Box() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 0.dp, end = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
Client.allSubscriptions().map {
|
||||
"${it} ${
|
||||
Client.getSubscriptionFilters(it)
|
||||
.joinToString { it.toJson() }
|
||||
}"
|
||||
}.forEach {
|
||||
Log.d("CURRENT FILTERS", it)
|
||||
}
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatRoomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrNotificationDataSource.printCounter()
|
||||
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
NostrUserProfileFollowersDataSource.printCounter()
|
||||
NostrUserProfileFollowsDataSource.printCounter()
|
||||
|
||||
println("AAA: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(context)
|
||||
println("${imageLoader.diskCache?.size}/${imageLoader.diskCache?.maxSize}")
|
||||
println("${imageLoader.memoryCache?.size}/${imageLoader.memoryCache?.maxSize}")
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.amethyst),
|
||||
null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.End,
|
||||
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}",
|
||||
color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
modifier = Modifier.clickable(
|
||||
onClick = {
|
||||
wantsToEditRelays = true
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatRoomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrNotificationDataSource.printCounter()
|
||||
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
NostrUserProfileFollowersDataSource.printCounter()
|
||||
NostrUserProfileFollowsDataSource.printCounter()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.amethyst),
|
||||
null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -123,19 +192,16 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(34.dp).height(34.dp)
|
||||
.width(34.dp)
|
||||
.height(34.dp)
|
||||
.clip(shape = CircleShape),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Text(
|
||||
"${connectedRelaysLiveData ?: "--"}/${availableRelaysLiveData ?: "--"}",
|
||||
color = if (connectedRelaysLiveData == 0) Color.Red else MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {}, modifier = Modifier
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -7,6 +8,9 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -16,6 +20,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.Surface
|
||||
@@ -23,7 +28,10 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
@@ -34,14 +42,35 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontWeight.Companion.W500
|
||||
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.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import com.google.zxing.qrcode.encoder.Encoder
|
||||
import com.google.zxing.qrcode.encoder.QRCode
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.toNpub
|
||||
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import com.google.zxing.qrcode.encoder.ByteMatrix
|
||||
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
|
||||
@Composable
|
||||
fun DrawerContent(navController: NavHostController,
|
||||
@@ -52,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
|
||||
|
||||
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
|
||||
@@ -105,50 +108,80 @@ fun DrawerContent(navController: NavHostController,
|
||||
.weight(1F),
|
||||
accountStateViewModel
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
println("AAA " + accountUser?.profilePicture())
|
||||
val accountUserState by baseAccountUser.liveMetadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AsyncImage(
|
||||
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
|
||||
contentDescription = "Profile Image",
|
||||
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 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +195,8 @@ fun ListContent(
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountStateViewModel
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
LazyColumn() {
|
||||
item {
|
||||
@@ -178,14 +213,19 @@ fun ListContent(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
Column(modifier = modifier.padding(horizontal = 25.dp)) {
|
||||
Text(
|
||||
text = "Settings",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = W500
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = { accountViewModel.logOff() }),
|
||||
) {
|
||||
Row(modifier = Modifier.clickable(onClick = {
|
||||
navController.navigate(Route.Filters.route)
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
})) {
|
||||
Text(
|
||||
text = "Security Filters",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = W500
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) {
|
||||
Text(
|
||||
text = "Log out",
|
||||
modifier = Modifier.padding(vertical = 15.dp),
|
||||
@@ -232,5 +272,73 @@ fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomContent(user: User, scaffoldState: ScaffoldState, navController: NavController) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 15.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 15.dp),
|
||||
) {
|
||||
/*
|
||||
IconButton(
|
||||
onClick = {
|
||||
when (AppCompatDelegate.getDefaultNightMode()) {
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_theme),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}*/
|
||||
Box(modifier = Modifier.weight(1F))
|
||||
IconButton(onClick = {
|
||||
dialogOpen = true
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_qrcode),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
ShowQRDialog(user,
|
||||
onScan = {
|
||||
dialogOpen = false
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
navController.navigate(it)
|
||||
},
|
||||
onClose = { dialogOpen = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import com.google.zxing.qrcode.encoder.ByteMatrix
|
||||
import com.google.zxing.qrcode.encoder.Encoder
|
||||
import com.google.zxing.qrcode.encoder.QRCode
|
||||
|
||||
|
||||
const val QR_MARGIN_PX = 100f
|
||||
|
||||
@Composable
|
||||
fun QrCodeDrawer(contents: String, modifier: Modifier = Modifier) {
|
||||
val qrCode = remember(contents) {
|
||||
createQrCode(contents = contents)
|
||||
}
|
||||
|
||||
val foregroundColor = MaterialTheme.colors.onSurface
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.defaultMinSize(48.dp, 48.dp)
|
||||
.aspectRatio(1f)
|
||||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
// Calculate the height and width of each column/row
|
||||
val rowHeight = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.height
|
||||
val columnWidth = (size.width - QR_MARGIN_PX * 2f) / qrCode.matrix.width
|
||||
|
||||
// Draw all of the finder patterns required by the QR spec. Calculate the ratio
|
||||
// of the number of rows/columns to the width and height
|
||||
drawQrCodeFinders(
|
||||
sideLength = size.width,
|
||||
finderPatternSize = Size(
|
||||
width = columnWidth * FINDER_PATTERN_ROW_COUNT,
|
||||
height = rowHeight * FINDER_PATTERN_ROW_COUNT
|
||||
),
|
||||
color = foregroundColor
|
||||
)
|
||||
|
||||
// Draw data bits (encoded data part)
|
||||
drawAllQrCodeDataBits(
|
||||
bytes = qrCode.matrix,
|
||||
size = Size(
|
||||
width = columnWidth,
|
||||
height = rowHeight
|
||||
),
|
||||
color = foregroundColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private typealias Coordinate = Pair<Int, Int>
|
||||
|
||||
private fun createQrCode(contents: String): QRCode {
|
||||
require(contents.isNotEmpty())
|
||||
|
||||
return Encoder.encode(
|
||||
contents,
|
||||
ErrorCorrectionLevel.Q,
|
||||
mapOf(
|
||||
EncodeHintType.CHARACTER_SET to "UTF-8",
|
||||
EncodeHintType.MARGIN to QR_MARGIN_PX,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun newPath(withPath: Path.() -> Unit) = Path().apply {
|
||||
fillType = PathFillType.EvenOdd
|
||||
withPath(this)
|
||||
}
|
||||
|
||||
fun DrawScope.drawAllQrCodeDataBits(
|
||||
bytes: ByteMatrix,
|
||||
size: Size,
|
||||
color: Color,
|
||||
) {
|
||||
setOf(
|
||||
// data bits between top left finder pattern and top right finder pattern.
|
||||
Pair(
|
||||
first = Coordinate(first = FINDER_PATTERN_ROW_COUNT, second = 0),
|
||||
second = Coordinate(
|
||||
first = (bytes.width - FINDER_PATTERN_ROW_COUNT),
|
||||
second = FINDER_PATTERN_ROW_COUNT
|
||||
)
|
||||
),
|
||||
// data bits below top left finder pattern and above bottom left finder pattern.
|
||||
Pair(
|
||||
first = Coordinate(first = 0, second = FINDER_PATTERN_ROW_COUNT),
|
||||
second = Coordinate(
|
||||
first = bytes.width,
|
||||
second = bytes.height - FINDER_PATTERN_ROW_COUNT
|
||||
)
|
||||
),
|
||||
// data bits to the right of the bottom left finder pattern.
|
||||
Pair(
|
||||
first = Coordinate(
|
||||
first = FINDER_PATTERN_ROW_COUNT,
|
||||
second = (bytes.height - FINDER_PATTERN_ROW_COUNT)
|
||||
),
|
||||
second = Coordinate(
|
||||
first = bytes.width,
|
||||
second = bytes.height
|
||||
)
|
||||
)
|
||||
).forEach { section ->
|
||||
for (y in section.first.second until section.second.second) {
|
||||
for (x in section.first.first until section.second.first) {
|
||||
if (bytes[x, y] == 1.toByte()) {
|
||||
drawPath(
|
||||
color = color,
|
||||
path = newPath {
|
||||
addRect(
|
||||
rect = Rect(
|
||||
offset = Offset(
|
||||
x = QR_MARGIN_PX + x * size.width,
|
||||
y = QR_MARGIN_PX + y * size.height
|
||||
),
|
||||
size = size
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val FINDER_PATTERN_ROW_COUNT = 7
|
||||
private const val INTERIOR_EXTERIOR_SHAPE_RATIO = 3f / FINDER_PATTERN_ROW_COUNT
|
||||
private const val INTERIOR_EXTERIOR_OFFSET_RATIO = 2f / FINDER_PATTERN_ROW_COUNT
|
||||
private const val INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS = 0.12f
|
||||
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO = 5f / FINDER_PATTERN_ROW_COUNT
|
||||
private const val INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO = 1f / FINDER_PATTERN_ROW_COUNT
|
||||
private const val INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS = 0.5f
|
||||
|
||||
/**
|
||||
* A valid QR code has three finder patterns (top left, top right, bottom left).
|
||||
*
|
||||
* @param qrCodeProperties how the QR code is drawn
|
||||
* @param sideLength length, in pixels, of each side of the QR code
|
||||
* @param finderPatternSize [Size] of each finder patten, based on the QR code spec
|
||||
*/
|
||||
internal fun DrawScope.drawQrCodeFinders(
|
||||
sideLength: Float,
|
||||
finderPatternSize: Size,
|
||||
color: Color
|
||||
) {
|
||||
|
||||
setOf(
|
||||
// Draw top left finder pattern.
|
||||
Offset(x = QR_MARGIN_PX, y = QR_MARGIN_PX),
|
||||
// Draw top right finder pattern.
|
||||
Offset(x = sideLength - (QR_MARGIN_PX + finderPatternSize.width), y = QR_MARGIN_PX),
|
||||
// Draw bottom finder pattern.
|
||||
Offset(x = QR_MARGIN_PX, y = sideLength - (QR_MARGIN_PX + finderPatternSize.height))
|
||||
).forEach { offset ->
|
||||
drawQrCodeFinder(
|
||||
topLeft = offset,
|
||||
finderPatternSize = finderPatternSize,
|
||||
cornerRadius = CornerRadius.Zero,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This func is responsible for drawing a single finder pattern, for a QR code
|
||||
*/
|
||||
private fun DrawScope.drawQrCodeFinder(
|
||||
topLeft: Offset,
|
||||
finderPatternSize: Size,
|
||||
cornerRadius: CornerRadius,
|
||||
color: Color
|
||||
) {
|
||||
drawPath(
|
||||
color = color,
|
||||
path = newPath {
|
||||
// Draw the outer rectangle for the finder pattern.
|
||||
addRoundRect(
|
||||
roundRect = RoundRect(
|
||||
rect = Rect(
|
||||
offset = topLeft,
|
||||
size = finderPatternSize
|
||||
),
|
||||
cornerRadius = cornerRadius
|
||||
)
|
||||
)
|
||||
|
||||
// Draw background for the finder pattern interior (this keeps the arc ratio consistent).
|
||||
val innerBackgroundOffset = Offset(
|
||||
x = finderPatternSize.width * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO,
|
||||
y = finderPatternSize.height * INTERIOR_BACKGROUND_EXTERIOR_OFFSET_RATIO
|
||||
)
|
||||
addRoundRect(
|
||||
roundRect = RoundRect(
|
||||
rect = Rect(
|
||||
offset = topLeft + innerBackgroundOffset,
|
||||
size = finderPatternSize * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_RATIO
|
||||
),
|
||||
cornerRadius = cornerRadius * INTERIOR_BACKGROUND_EXTERIOR_SHAPE_CORNER_RADIUS
|
||||
)
|
||||
)
|
||||
|
||||
// Draw the inner rectangle for the finder pattern.
|
||||
val innerRectOffset = Offset(
|
||||
x = finderPatternSize.width * INTERIOR_EXTERIOR_OFFSET_RATIO,
|
||||
y = finderPatternSize.height * INTERIOR_EXTERIOR_OFFSET_RATIO
|
||||
)
|
||||
addRoundRect(
|
||||
roundRect = RoundRect(
|
||||
rect = Rect(
|
||||
offset = topLeft + innerRectOffset,
|
||||
size = finderPatternSize * INTERIOR_EXTERIOR_SHAPE_RATIO
|
||||
),
|
||||
cornerRadius = cornerRadius * INTERIOR_EXTERIOR_SHAPE_CORNER_RADIUS
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.ImageFormat
|
||||
import android.util.Size
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.vitorpamplona.amethyst.service.Nip19
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@Composable
|
||||
fun QrCodeScanner(onScan: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) }
|
||||
|
||||
val cameraExecutor= Executors.newSingleThreadExecutor()
|
||||
|
||||
var hasCameraPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.CAMERA
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
onResult = { granted ->
|
||||
hasCameraPermission = granted
|
||||
}
|
||||
)
|
||||
|
||||
val analyzer = QRCodeAnalyzer { result ->
|
||||
result?.let {
|
||||
try {
|
||||
val nip19 = Nip19().uriToRoute(it)
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (startingPage != null) {
|
||||
onScan(startingPage)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
// QR can be anythign. do not throw errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(key1 = true) {
|
||||
launcher.launch(Manifest.permission.CAMERA)
|
||||
onDispose() {
|
||||
cameraProviderFuture.get().unbindAll()
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Column() {
|
||||
if (hasCameraPermission) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
val previewView = PreviewView(context)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
bindPreview(analyzer, previewView, cameraExecutor, cameraProvider, lifecycleOwner)
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
|
||||
return@AndroidView previewView
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun bindPreview(
|
||||
analyzer: ImageAnalysis.Analyzer,
|
||||
previewView: PreviewView,
|
||||
cameraExecutor: ExecutorService,
|
||||
cameraProvider: ProcessCameraProvider,
|
||||
lifecycleOwner: LifecycleOwner
|
||||
) {
|
||||
val preview = Preview.Builder().build()
|
||||
|
||||
val selector = CameraSelector.Builder()
|
||||
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
|
||||
.build()
|
||||
|
||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
imageAnalysis.setAnalyzer(
|
||||
cameraExecutor,
|
||||
analyzer
|
||||
)
|
||||
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
selector,
|
||||
imageAnalysis,
|
||||
preview
|
||||
)
|
||||
}
|
||||
|
||||
class QRCodeAnalyzer(
|
||||
private val onQrCodeScanned: (result: String?) -> Unit
|
||||
) : ImageAnalysis.Analyzer {
|
||||
|
||||
private val scanningOptions = BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build()
|
||||
|
||||
fun scanBarcodes(inputImage: InputImage) {
|
||||
BarcodeScanning.getClient(scanningOptions).process(inputImage)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
if (barcodes.isNotEmpty()) {
|
||||
onQrCodeScanned(barcodes[0].displayValue)
|
||||
}
|
||||
}
|
||||
.addOnFailureListener {
|
||||
it.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(androidx.camera.core.ExperimentalGetImage::class)
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
imageProxy.image?.let { image ->
|
||||
val inputImage = InputImage.fromMediaImage(image, imageProxy.imageInfo.rotationDegrees)
|
||||
scanBarcodes(inputImage)
|
||||
}
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
@@ -9,12 +10,23 @@ import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.FiltersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.SearchScreen
|
||||
@@ -25,13 +37,30 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
sealed class Route(
|
||||
val route: String,
|
||||
val icon: Int,
|
||||
val hasNewItems: @Composable (LocalCache, NotificationCache) -> Boolean = @Composable { _,_ -> false },
|
||||
val arguments: List<NamedNavArgument> = emptyList(),
|
||||
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
|
||||
) {
|
||||
object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } })
|
||||
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }})
|
||||
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }})
|
||||
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }})
|
||||
object Home : Route("Home", R.drawable.ic_home,
|
||||
hasNewItems = { _, cache -> homeHasNewItems(cache) },
|
||||
buildScreen = { acc, accSt, nav -> { _ -> HomeScreen(acc, nav) } }
|
||||
)
|
||||
object Search : Route("Search", R.drawable.ic_search,
|
||||
buildScreen = { acc, accSt, nav -> { _ -> SearchScreen(acc, nav) }}
|
||||
)
|
||||
object Notification : Route("Notification", R.drawable.ic_notifications,
|
||||
hasNewItems = { _, cache -> notificationHasNewItems(cache) },
|
||||
buildScreen = { acc, accSt, nav -> { _ -> NotificationScreen(acc, nav) }}
|
||||
)
|
||||
|
||||
object Message : Route("Message", R.drawable.ic_dm,
|
||||
hasNewItems = { _, cache -> messagesHasNewItems(cache) },
|
||||
buildScreen = { acc, accSt, nav -> { _ -> ChatroomListScreen(acc, nav) }}
|
||||
)
|
||||
|
||||
object Filters : Route("Filters", R.drawable.ic_dm,
|
||||
buildScreen = { acc, accSt, nav -> { _ -> FiltersScreen(acc, nav) }}
|
||||
)
|
||||
|
||||
object Profile : Route("User/{id}", R.drawable.ic_profile,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
|
||||
@@ -65,11 +94,58 @@ val Routes = listOf(
|
||||
Route.Profile,
|
||||
Route.Note,
|
||||
Route.Room,
|
||||
Route.Channel
|
||||
Route.Channel,
|
||||
Route.Filters
|
||||
)
|
||||
|
||||
//**
|
||||
//* Functions below only exist because we have not broken the datasource classes into backend and frontend.
|
||||
//**
|
||||
@Composable
|
||||
public fun currentRoute(navController: NavHostController): String? {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
return navBackStackEntry?.destination?.route
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun homeHasNewItems(cache: NotificationCache): Boolean {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val lastTimeFollows = cache.load("HomeFollows", context)
|
||||
|
||||
val homeFeed = NostrHomeDataSource.feed().take(100)
|
||||
|
||||
val hasNewInFollows = homeFeed.filter {
|
||||
it.isNewThread()
|
||||
}.filter {
|
||||
(it.event?.createdAt ?: 0) > lastTimeFollows
|
||||
}.isNotEmpty()
|
||||
|
||||
return hasNewInFollows
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun notificationHasNewItems(cache: NotificationCache): Boolean {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val lastTime = cache.load("Notification", context)
|
||||
return NostrNotificationDataSource.loadTop()
|
||||
.filter { it.event != null && it.event!!.createdAt > lastTime }
|
||||
.isNotEmpty()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun messagesHasNewItems(cache: NotificationCache): Boolean {
|
||||
val context = LocalContext.current.applicationContext
|
||||
return NostrChatroomListDataSource.feed().take(100).filter {
|
||||
// only for known sources
|
||||
val me = NostrChatroomListDataSource.account.userProfile()
|
||||
it.channel == null && me.hasSentMessagesTo(it.author) && it.author != me
|
||||
}.filter {
|
||||
val lastTime = if (it.channel != null) {
|
||||
cache.load("Channel/${it.channel!!.idHex}", context)
|
||||
} else {
|
||||
cache.load("Room/${it.author?.pubkeyHex}", context)
|
||||
}
|
||||
|
||||
NostrChatroomListDataSource.account.isAcceptable(it) && it.event != null && it.event!!.createdAt > lastTime
|
||||
}.isNotEmpty()
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
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.Row
|
||||
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.width
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
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.text.font.FontWeight
|
||||
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 coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import nostr.postr.toNpub
|
||||
|
||||
@Composable
|
||||
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
var presenting by remember { mutableStateOf(true) }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onClose)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(10.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
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)
|
||||
) {
|
||||
QrCodeDrawer("nostr:${user.pubkey.toNpub()}")
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(30.dp)
|
||||
) {
|
||||
|
||||
Button(
|
||||
onClick = { presenting = false },
|
||||
shape = RoundedCornerShape(35.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(50.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = "Scan QR")
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(30.dp)
|
||||
) {
|
||||
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
|
||||
)
|
||||
) {
|
||||
Text(text = "Show QR")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,38 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Referenced event not found",
|
||||
text = "Referenced post not found",
|
||||
modifier = Modifier.padding(30.dp),
|
||||
color = Color.Gray,
|
||||
)
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun HiddenNote(modifier: Modifier = Modifier, isQuote: Boolean = false) {
|
||||
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)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
start = 20.dp,
|
||||
end = 20.dp,
|
||||
bottom = 25.dp,
|
||||
top = 15.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Post was flagged as inappropriate",
|
||||
modifier = Modifier.padding(30.dp),
|
||||
color = Color.Gray,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -17,24 +19,39 @@ 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.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.ui.screen.BoostSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by likeSetCard.note.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
Column() {
|
||||
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(
|
||||
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
|
||||
)
|
||||
) {
|
||||
Row(modifier = Modifier
|
||||
.padding(
|
||||
start = if (!isInnerNote) 12.dp else 0.dp,
|
||||
@@ -59,30 +76,16 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, acc
|
||||
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()
|
||||
|
||||
AsyncImage(
|
||||
model = userState?.user?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
.height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
userState?.let {
|
||||
navController.navigate("User/${it.user.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
|
||||
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.Center
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
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
|
||||
@@ -17,11 +25,21 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
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.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
@@ -32,63 +50,87 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val notificationCacheState = NotificationCache.live.observeAsState()
|
||||
val notificationCache = notificationCacheState.value ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
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()
|
||||
val channel = channelState?.channel
|
||||
|
||||
val description = if (note.event is ChannelCreateEvent) {
|
||||
val noteEvent = note.event
|
||||
|
||||
val description = if (noteEvent is ChannelCreateEvent) {
|
||||
"Channel created"
|
||||
} else if (note.event is ChannelMetadataEvent) {
|
||||
} else if (noteEvent is ChannelMetadataEvent) {
|
||||
"Channel Information changed to "
|
||||
} else {
|
||||
note.event?.content
|
||||
noteEvent?.content
|
||||
}
|
||||
channel?.let {
|
||||
channel?.let { channel ->
|
||||
val hasNewMessages =
|
||||
if (noteEvent != null)
|
||||
noteEvent.createdAt > notificationCache.cache.load("Channel/${channel.idHex}", context)
|
||||
else
|
||||
false
|
||||
|
||||
ChannelName(
|
||||
channelPicture = it.profilePicture(),
|
||||
channelPicture = channel.profilePicture(),
|
||||
channelPicturePlaceholder = null,
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${it.info.name}",
|
||||
fontWeight = FontWeight.Bold
|
||||
"${channel.info.name}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = it,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
Text(
|
||||
" Public Chat",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
channelLastTime = note.event?.createdAt,
|
||||
channelLastContent = "${author?.toBestDisplayName()}: " + description,
|
||||
onClick = { navController.navigate("Channel/${it.idHex}") })
|
||||
hasNewMessages = hasNewMessages,
|
||||
onClick = { navController.navigate("Channel/${channel.idHex}") })
|
||||
}
|
||||
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
userToComposeOn?.let {
|
||||
val noteEvent = note.event
|
||||
|
||||
userToComposeOn.let { user ->
|
||||
val hasNewMessages =
|
||||
if (noteEvent != null)
|
||||
noteEvent.createdAt > notificationCache.cache.load("Room/${userToComposeOn.pubkeyHex}", context)
|
||||
else
|
||||
false
|
||||
|
||||
ChannelName(
|
||||
channelPicture = it.profilePicture(),
|
||||
channelTitle = { UsernameDisplay(it) },
|
||||
channelLastTime = note.event?.createdAt,
|
||||
channelPicture = { UserPicture(userToComposeOn, account.userProfile(), size = 55.dp) },
|
||||
channelTitle = { UsernameDisplay(userToComposeOn, it) },
|
||||
channelLastTime = noteEvent?.createdAt,
|
||||
channelLastContent = accountViewModel.decrypt(note),
|
||||
onClick = { navController.navigate("Room/${it.pubkeyHex}") })
|
||||
hasNewMessages = hasNewMessages,
|
||||
onClick = { navController.navigate("Room/${user.pubkeyHex}") })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,24 +139,47 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
@Composable
|
||||
fun ChannelName(
|
||||
channelPicture: String,
|
||||
channelTitle: @Composable () -> Unit,
|
||||
channelPicturePlaceholder: Painter?,
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?,
|
||||
channelLastContent: String?,
|
||||
hasNewMessages: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.clickable(onClick = onClick) ) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
) {
|
||||
|
||||
ChannelName(
|
||||
channelPicture = {
|
||||
AsyncImage(
|
||||
model = channelPicture,
|
||||
placeholder = channelPicturePlaceholder,
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
},
|
||||
channelTitle,
|
||||
channelLastTime,
|
||||
channelLastContent,
|
||||
hasNewMessages,
|
||||
onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelName(
|
||||
channelPicture: @Composable () -> Unit,
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?,
|
||||
channelLastContent: String?,
|
||||
hasNewMessages: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.clickable(onClick = onClick) ) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
) {
|
||||
channelPicture()
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp),
|
||||
verticalArrangement = Arrangement.SpaceAround) {
|
||||
@@ -122,7 +187,7 @@ fun ChannelName(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
channelTitle()
|
||||
channelTitle(Modifier.weight(1f))
|
||||
|
||||
channelLastTime?.let {
|
||||
Text(
|
||||
@@ -133,20 +198,29 @@ fun ChannelName(
|
||||
|
||||
}
|
||||
|
||||
if (channelLastContent != null)
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
else
|
||||
Text(
|
||||
"Referenced event not found",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (channelLastContent != null)
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
else
|
||||
Text(
|
||||
"Referenced event not found",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (hasNewMessages) {
|
||||
NewItemsBubble()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,4 +229,27 @@ fun ChannelName(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NewItemsBubble() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 3.dp)
|
||||
.width(10.dp)
|
||||
.height(10.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
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
|
||||
@@ -29,15 +30,20 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.vitorpamplona.amethyst.NotificationCache
|
||||
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 com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -46,26 +52,27 @@ val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUser = account.userProfile()
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} 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
|
||||
@@ -75,6 +82,17 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
shape = ChatBubbleShapeThem
|
||||
}
|
||||
|
||||
// Mark read
|
||||
val isNew = routeForLastRead?.run {
|
||||
val isNew = NotificationCache.load(this, context)
|
||||
|
||||
val createdAt = note.event?.createdAt
|
||||
if (createdAt != null)
|
||||
NotificationCache.markAsRead(this, createdAt, context)
|
||||
|
||||
isNew
|
||||
}
|
||||
|
||||
Column() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(1f).padding(
|
||||
@@ -87,7 +105,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.fillMaxWidth(0.85f)
|
||||
modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f)
|
||||
) {
|
||||
|
||||
Surface(
|
||||
@@ -103,7 +121,10 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
|
||||
) {
|
||||
|
||||
if (author != accountUser && note.event is ChannelMessageEvent) {
|
||||
val authorState by note.author!!.liveMetadata.observeAsState()
|
||||
val author = authorState?.user
|
||||
|
||||
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment,
|
||||
@@ -111,6 +132,7 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
) {
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(25.dp)
|
||||
@@ -135,6 +157,22 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
}
|
||||
}
|
||||
|
||||
val replyTo = note.replyTo
|
||||
if (!innerQuote && replyTo != null && replyTo.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
replyTo.toSet().mapIndexed { index, note ->
|
||||
if (note.event != null)
|
||||
ChatroomMessageCompose(
|
||||
note,
|
||||
null,
|
||||
innerQuote = true,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val event = note.event
|
||||
if (event is ChannelCreateEvent) {
|
||||
@@ -154,23 +192,18 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
RichTextViewer(
|
||||
eventContent,
|
||||
note.event?.tags,
|
||||
note,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
else
|
||||
RichTextViewer(
|
||||
"Could Not decrypt the message",
|
||||
note.event?.tags,
|
||||
note,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -17,25 +19,40 @@ 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.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.ui.screen.LikeSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by likeSetCard.note.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
if (note?.event == null) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
Column(modifier = modifier) {
|
||||
Row( modifier = Modifier
|
||||
val isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead, context)
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt, context)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(
|
||||
if (isNew) MaterialTheme.colors.primary.copy(0.12f) else MaterialTheme.colors.background
|
||||
)
|
||||
) {
|
||||
Row(modifier = Modifier
|
||||
.padding(
|
||||
start = if (!isInnerNote) 12.dp else 0.dp,
|
||||
end = if (!isInnerNote) 12.dp else 0.dp,
|
||||
@@ -59,30 +76,16 @@ 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()
|
||||
|
||||
AsyncImage(
|
||||
model = userState?.user?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp).height(35.dp)
|
||||
.height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
userState?.let {
|
||||
navController.navigate("User/${it.user.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NoteCompose(note, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
|
||||
NoteCompose(note, null, Modifier.padding(top = 5.dp), true, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,19 @@ import androidx.compose.foundation.combinedClickable
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,40 +32,102 @@ 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.graphics.Shape
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.TextDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.model.ChannelMessageEvent
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toNpub
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun NoteCompose(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
isInnerNote: Boolean = false,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val noteReportsState by baseNote.liveReports.observeAsState()
|
||||
val noteForReports = noteReportsState?.note ?: return
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(modifier, isInnerNote)
|
||||
BlankNote(modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true },
|
||||
), isInnerNote)
|
||||
} else if (!account.isAcceptable(noteForReports)) {
|
||||
HiddenNote(modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true },
|
||||
), isInnerNote)
|
||||
} else {
|
||||
val authorState by note.author!!.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
val isNew = routeForLastRead?.run {
|
||||
val lastTime = NotificationCache.load(this, context)
|
||||
|
||||
val createdAt = note.event?.createdAt
|
||||
if (createdAt != null) {
|
||||
NotificationCache.markAsRead(this, createdAt, context)
|
||||
createdAt > lastTime
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} ?: false
|
||||
|
||||
Column(modifier =
|
||||
modifier.combinedClickable(
|
||||
onClick = { navController.navigate("Note/${note.idHex}") },
|
||||
onLongClick = { popupExpanded = true },
|
||||
)
|
||||
onClick = {
|
||||
if (note.event !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}"){
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
note.channel?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
).run {
|
||||
if (isNew) {
|
||||
this.background(MaterialTheme.colors.primary.copy(0.12f))
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -71,51 +139,68 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
|
||||
// Draws the boosted picture outside the boosted card.
|
||||
if (!isInnerNote) {
|
||||
Box(modifier = Modifier.width(55.dp).padding(0.dp)) {
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
author?.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
Box(modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)) {
|
||||
|
||||
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 boostedPosts = note.replyTo
|
||||
if (note.event is RepostEvent && boostedPosts != null && boostedPosts.isNotEmpty()) {
|
||||
AsyncImage(
|
||||
model = boostedPosts[0].author?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp).height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.align(Alignment.BottomEnd)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.border(2.dp, MaterialTheme.colors.primary, CircleShape)
|
||||
.clickable(onClick = {
|
||||
boostedPosts[0].author?.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (author != null)
|
||||
UsernameDisplay(author)
|
||||
NoteUsernameDisplay(note, Modifier.weight(1f))
|
||||
|
||||
if (note.event !is RepostEvent) {
|
||||
Text(
|
||||
timeAgo(note.event?.createdAt),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
@@ -128,13 +213,17 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
|
||||
if (note.event is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
|
||||
ReplyInformation(note.replyTo, note.mentions, navController)
|
||||
} else if (note.event is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
|
||||
note.channel?.let {
|
||||
ReplyInformationChannel(note.replyTo, note.mentions, it, navController)
|
||||
}
|
||||
}
|
||||
|
||||
if (note.event is ReactionEvent || note.event is RepostEvent) {
|
||||
note.replyTo?.mapIndexed { index, note ->
|
||||
note.replyTo?.lastOrNull()?.let {
|
||||
NoteCompose(
|
||||
note,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
it,
|
||||
modifier = Modifier,
|
||||
isInnerNote = true,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
@@ -152,10 +241,21 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
}
|
||||
} else {
|
||||
val eventContent = note.event?.content
|
||||
if (eventContent != null)
|
||||
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
ReactionsRowState(note, accountViewModel)
|
||||
ReactionsRow(note, accountViewModel)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
@@ -170,9 +270,138 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun NoteAuthorPicture(
|
||||
note: Note,
|
||||
navController: NavController,
|
||||
userAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
) {
|
||||
NoteAuthorPicture(note, userAccount, size, pictureModifier) {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun NoteAuthorPicture(
|
||||
baseNote: Note,
|
||||
baseUserAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier,
|
||||
onClick: ((User) -> Unit)? = null
|
||||
) {
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
val author = note.author
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.width(size)
|
||||
.height(size)) {
|
||||
if (author == null) {
|
||||
AsyncImage(
|
||||
model = "https://robohash.org/ohno.png",
|
||||
contentDescription = "Unknown Author",
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/ohno.png"),
|
||||
modifier = pictureModifier
|
||||
.fillMaxSize(1f)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
} else {
|
||||
UserPicture(author, baseUserAccount, size, pictureModifier, onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.width(size)
|
||||
.height(size)) {
|
||||
|
||||
AsyncImage(
|
||||
model = user.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
placeholder = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded,
|
||||
@@ -182,7 +411,7 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
Text("Copy Text")
|
||||
}
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
|
||||
Text("Copy User ID")
|
||||
Text("Copy User PubKey")
|
||||
}
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())); onDismiss() }) {
|
||||
Text("Copy Note ID")
|
||||
@@ -191,5 +420,38 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
|
||||
Text("Broadcast")
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) {
|
||||
Text("Hide User")
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.SPAM);
|
||||
note.author?.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("Report Spam / Scam")
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.IMPERSONATION);
|
||||
note.author?.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("Report Impersonation")
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.EXPLICIT);
|
||||
note.author?.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("Report Explicit Content")
|
||||
}
|
||||
DropdownMenuItem(onClick = {
|
||||
accountViewModel.report(note, ReportEvent.ReportType.ILLEGAL);
|
||||
note.author?.let { accountViewModel.hide(it, context) }
|
||||
onDismiss()
|
||||
}) {
|
||||
Text("Report Illegal Behaviour")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,67 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.BarChart
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
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.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.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
|
||||
import coil.compose.AsyncImage
|
||||
import coil.imageLoader
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (Note) -> Unit) {
|
||||
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val reactionsState by baseNote.liveReactions.observeAsState()
|
||||
val reactedNote = reactionsState?.note
|
||||
|
||||
val boostsState by baseNote.liveBoosts.observeAsState()
|
||||
val boostedNote = boostsState?.note
|
||||
|
||||
val repliesState by baseNote.liveReplies.observeAsState()
|
||||
val replies = repliesState?.note?.replies ?: emptySet()
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -35,105 +70,130 @@ fun ReactionsRow(note: Note, account: Account, boost: (Note) -> Unit, reactTo: (
|
||||
if (wantsToReplyTo != null)
|
||||
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, account)
|
||||
|
||||
Row(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) wantsToReplyTo = note }
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) wantsToReplyTo = baseNote }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = grayTint,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(replies.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = grayTint,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) accountViewModel.boost(baseNote) }
|
||||
) {
|
||||
if (boostedNote?.isBoostedBy(account.userProfile()) == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = grayTint,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweet),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(note.replies?.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = grayTint
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) boost(note) }
|
||||
) {
|
||||
if (note.isBoostedBy(account.userProfile())) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweet),
|
||||
null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(note.boosts?.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) reactTo(note) }
|
||||
) {
|
||||
if (note.isReactedBy(account.userProfile())) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_like),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
" ${showCount(boostedNote?.boosts?.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
" ${showCount(note.reactions?.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 40.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { if (account.isWriteable()) accountViewModel.reactTo(baseNote) }
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { }
|
||||
) {
|
||||
if (reactedNote?.isReactedBy(account.userProfile()) == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_share),
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_like),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(reactedNote?.reactions?.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BarChart,
|
||||
null,
|
||||
modifier = Modifier.size(19.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
|
||||
.crossfade(true)
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build(),
|
||||
contentDescription = "View count",
|
||||
modifier = Modifier.height(24.dp),
|
||||
colorFilter = ColorFilter.tint(grayTint)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { popupExpanded = true }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = grayTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
|
||||
fun showCount(size: Int?): String {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ReactionsRowState(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
if (account == null || note == null) return
|
||||
|
||||
ReactionsRow(note, account, accountViewModel::boost, accountViewModel::reactTo)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun RelayCompose(
|
||||
relay: User.RelayInfo,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
onAddRelay: () -> Unit,
|
||||
onRemoveRelay: () -> Unit
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
Column() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
) {
|
||||
|
||||
//UserPicture(user, navController, account.userProfile(), 55.dp)
|
||||
|
||||
Column(modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
relay.url.trim().removePrefix("wss://"),
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
timeAgo(relay.lastEvent),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"${relay.counter} posts received",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
if (account.activeRelays()?.none { it.url == relay.url } == true) {
|
||||
AddRelayButton { onAddRelay() }
|
||||
} else {
|
||||
RemoveRelayButton { onRemoveRelay() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddRelayButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = "Add", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoveRelayButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = "Remove", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("MMM d, uuuu hh:mm a"))
|
||||
}
|
||||
@@ -19,18 +19,19 @@ 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.model.Channel
|
||||
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()) {
|
||||
@@ -40,8 +41,10 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
mentions.toSet().forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live.observeAsState()
|
||||
val mentionSet = mentions.toSet()
|
||||
|
||||
mentionSet.toSet().forEachIndexed { idx, user ->
|
||||
val innerUserState by user.liveMetadata.observeAsState()
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
@@ -51,13 +54,92 @@ fun ReplyInformation(replyTo: MutableList<Note>?, mentions: List<User>?, prefix:
|
||||
onClick = { onUserTagClick(myUser) }
|
||||
)
|
||||
|
||||
if (idx < mentions.size - 2) {
|
||||
if (idx < mentionSet.size - 2) {
|
||||
Text(
|
||||
", ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
} else if (idx < mentions.size - 1) {
|
||||
} else if (idx < mentionSet.size - 1) {
|
||||
Text(
|
||||
" and ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
|
||||
ReplyInformationChannel(replyTo, mentions, channel,
|
||||
onUserTagClick = {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
},
|
||||
onChannelTagClick = {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ReplyInformationChannel(replyTo: List<Note>?,
|
||||
mentions: List<User>?,
|
||||
baseChannel: Channel,
|
||||
prefix: String = "",
|
||||
onUserTagClick: (User) -> Unit,
|
||||
onChannelTagClick: (Channel) -> Unit
|
||||
) {
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = channelState?.channel ?: return
|
||||
|
||||
FlowRow() {
|
||||
Text(
|
||||
"in channel ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
AnnotatedString("${channel.info.name} "),
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
|
||||
onClick = { onChannelTagClick(channel) }
|
||||
)
|
||||
|
||||
if (mentions != null && mentions.isNotEmpty()) {
|
||||
if (replyTo != null && replyTo.isNotEmpty()) {
|
||||
Text(
|
||||
"replying to ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
val mentionSet = mentions.toSet()
|
||||
|
||||
mentionSet.forEachIndexed { idx, user ->
|
||||
val innerUserState by user.liveMetadata.observeAsState()
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
ClickableText(
|
||||
AnnotatedString("${prefix}@${myUser.toBestDisplayName()}"),
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
|
||||
onClick = { onUserTagClick(myUser) }
|
||||
)
|
||||
|
||||
if (idx < mentionSet.size - 2) {
|
||||
Text(
|
||||
", ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
} else if (idx < mentionSet.size - 1) {
|
||||
Text(
|
||||
" and ",
|
||||
fontSize = 13.sp,
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.text.format.DateUtils
|
||||
|
||||
fun timeAgo(mills: Long?): String {
|
||||
if (mills == null) return " "
|
||||
if (mills == 0L) return " • never"
|
||||
|
||||
var humanReadable = DateUtils.getRelativeTimeSpanString(
|
||||
mills * 1000,
|
||||
|
||||
@@ -16,24 +16,29 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.FollowButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.ShowUserButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val userState by baseUser.live.observeAsState()
|
||||
val user = 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(
|
||||
@@ -44,30 +49,34 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
top = 10.dp)
|
||||
) {
|
||||
|
||||
AsyncImage(
|
||||
model = user.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
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?.take(100) ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
user.info.about ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
if (accountState?.account?.userProfile()?.isFollowing(user) == true) {
|
||||
UnfollowButton { accountState?.account?.unfollow(user) }
|
||||
if (account.isHidden(baseUser)) {
|
||||
ShowUserButton {
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
} else if (account.userProfile().isFollowing(baseUser)) {
|
||||
UnfollowButton { account.unfollow(baseUser) }
|
||||
} else {
|
||||
FollowButton { accountState?.account?.follow(user) }
|
||||
FollowButton { account.follow(baseUser) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,31 +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) {
|
||||
if (user.bestUsername() != null || user.bestDisplayName() != null) {
|
||||
if (user.bestDisplayName().isNullOrBlank()) {
|
||||
Text(
|
||||
"@${(user.bestUsername() ?: "")}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
"@${(user.bestUsername() ?: "")}",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
)
|
||||
}
|
||||
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.bestUsername() ?: "")}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = weight
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
user.pubkeyDisplayHex,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,29 @@
|
||||
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
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun AccountScreen(accountStateViewModel: AccountStateViewModel) {
|
||||
val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle()
|
||||
fun AccountScreen(accountStateViewModel: AccountStateViewModel, startingPage: String?) {
|
||||
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)
|
||||
}
|
||||
is AccountState.LoggedIn -> {
|
||||
MainScreen(AccountViewModel(state.account), accountStateViewModel)
|
||||
MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage)
|
||||
}
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
MainScreen(AccountViewModel(state.account), accountStateViewModel)
|
||||
MainScreen(AccountViewModel(state.account), accountStateViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
@@ -14,22 +16,28 @@ 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 encryptedPreferences: EncryptedSharedPreferences): ViewModel() {
|
||||
class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() {
|
||||
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
|
||||
val accountContent = _accountContent.asStateFlow()
|
||||
|
||||
init {
|
||||
// pulls account from storage.
|
||||
loadFromEncryptedStorage()?.let {
|
||||
localPreferences.loadFromEncryptedStorage()?.let {
|
||||
login(it)
|
||||
}
|
||||
}
|
||||
@@ -49,14 +57,14 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
|
||||
Account(Persona(Hex.decode(key)))
|
||||
}
|
||||
|
||||
saveToEncryptedStorage(account)
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
|
||||
login(account)
|
||||
}
|
||||
|
||||
fun newKey() {
|
||||
val account = Account(Persona())
|
||||
saveToEncryptedStorage(account)
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
login(account)
|
||||
}
|
||||
|
||||
@@ -66,53 +74,14 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
|
||||
else
|
||||
_accountContent.update { AccountState.LoggedInViewOnly ( account ) }
|
||||
|
||||
NostrAccountDataSource.account = account
|
||||
NostrHomeDataSource.account = account
|
||||
NostrNotificationDataSource.account = account
|
||||
NostrChatroomListDataSource.account = account
|
||||
|
||||
NostrAccountDataSource.start()
|
||||
NostrGlobalDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrNotificationDataSource.start()
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
NostrThreadDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start(account)
|
||||
}
|
||||
}
|
||||
|
||||
fun logOff() {
|
||||
_accountContent.update { AccountState.LoggedOff }
|
||||
|
||||
clearEncryptedStorage()
|
||||
}
|
||||
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
remove("nostr_privkey")
|
||||
remove("nostr_pubkey")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||
account.followingChannels.let { putStringSet("following_channels", account.followingChannels) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString("nostr_privkey", null)
|
||||
val pubKey = getString("nostr_pubkey", null)
|
||||
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
localPreferences.clearEncryptedStorage()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
abstract class Card() {
|
||||
abstract fun createdAt(): Long
|
||||
abstract fun id(): String
|
||||
}
|
||||
|
||||
class NoteCard(val note: Note): Card() {
|
||||
override fun createdAt(): Long {
|
||||
return note.event?.createdAt ?: 0
|
||||
}
|
||||
|
||||
override fun id() = note.idHex
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
|
||||
@@ -26,11 +30,13 @@ class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
|
||||
override fun createdAt(): Long {
|
||||
return createdAt
|
||||
}
|
||||
|
||||
override fun id() = note.idHex + "B" + createdAt
|
||||
}
|
||||
|
||||
sealed class CardFeedState {
|
||||
object Loading: CardFeedState()
|
||||
class Loaded(val feed: List<Card>): CardFeedState()
|
||||
class Loaded(val feed: MutableState<List<Card>>): CardFeedState()
|
||||
object Empty: CardFeedState()
|
||||
class FeedError(val errorMessage: String): CardFeedState()
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
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
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,14 +26,12 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
@@ -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 {
|
||||
@@ -58,21 +59,12 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
is CardFeedState.Loaded -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed) { index, item ->
|
||||
when (item) {
|
||||
is NoteCard -> NoteCompose(item.note, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
|
||||
is LikeSetCard -> LikeSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
|
||||
is BoostSetCard -> BoostSetCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
FeedLoaded(
|
||||
state,
|
||||
accountViewModel,
|
||||
navController,
|
||||
routeForLastRead
|
||||
)
|
||||
}
|
||||
CardFeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
@@ -82,3 +74,47 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
state: CardFeedState.Loaded,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
routeForLastRead: String
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { index, item ->
|
||||
when (item) {
|
||||
is NoteCard -> NoteCompose(
|
||||
item.note,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is LikeSetCard -> LikeSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is BoostSetCard -> BoostSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
@@ -27,7 +30,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
private var lastNotes: List<Note>? = null
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
}
|
||||
@@ -43,7 +46,7 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
val newCards = convertToCard(notes.minus(lastNotesCopy))
|
||||
if (newCards.isNotEmpty()) {
|
||||
lastNotes = notes
|
||||
updateFeed((oldNotesState.feed + newCards).sortedBy { it.createdAt() }.reversed())
|
||||
updateFeed((oldNotesState.feed.value + newCards).sortedBy { it.createdAt() }.reversed())
|
||||
}
|
||||
} else {
|
||||
val cards = convertToCard(notes)
|
||||
@@ -55,9 +58,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)
|
||||
}
|
||||
@@ -68,7 +71,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)
|
||||
}
|
||||
@@ -83,25 +86,32 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
fun updateFeed(notes: List<Card>) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
val currentState = feedContent.value
|
||||
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { CardFeedState.Empty }
|
||||
} else if (currentState is CardFeedState.Loaded) {
|
||||
// updates the current list
|
||||
currentState.feed.value = notes
|
||||
} else {
|
||||
_feedContent.update { CardFeedState.Loaded(notes) }
|
||||
_feedContent.update { CardFeedState.Loaded(mutableStateOf(notes)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
var handlerWaiting = false
|
||||
fun invalidateData() {
|
||||
synchronized(handlerWaiting) {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -13,7 +14,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
@@ -21,11 +22,10 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -36,46 +36,36 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
},
|
||||
) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
isRefreshing = true
|
||||
}
|
||||
}
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
}
|
||||
}
|
||||
is FeedState.Loaded -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
reverseLayout = true,
|
||||
state = listState
|
||||
) {
|
||||
var previousDate: String = ""
|
||||
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
|
||||
ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
isRefreshing = true
|
||||
}
|
||||
}
|
||||
is FeedState.Loaded -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
var previousDate: String = ""
|
||||
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
|
||||
ChatroomMessageCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
listState.animateScrollToItem(state.feed.size-1, 0)
|
||||
}
|
||||
}
|
||||
FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
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
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -19,6 +23,8 @@ import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
@@ -27,7 +33,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
@@ -43,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 {
|
||||
@@ -56,17 +62,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
|
||||
}
|
||||
}
|
||||
is FeedState.Loaded -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
|
||||
ChatroomCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
FeedLoaded(state, accountViewModel, navController)
|
||||
}
|
||||
FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
@@ -76,3 +72,28 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
|
||||
ChatroomCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
|
||||
sealed class FeedState {
|
||||
object Loading : FeedState()
|
||||
class Loaded(val feed: List<Note>) : FeedState()
|
||||
class Loaded(val feed: MutableState<List<Note>>) : FeedState()
|
||||
object Empty : FeedState()
|
||||
class FeedError(val errorMessage: String) : FeedState()
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
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
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.Button
|
||||
@@ -14,32 +16,35 @@ import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
viewModel.hardRefresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
@@ -50,8 +55,9 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
|
||||
isRefreshing = true
|
||||
},
|
||||
) {
|
||||
|
||||
Column() {
|
||||
Crossfade(targetState = feedState) { state ->
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
@@ -64,17 +70,12 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
|
||||
}
|
||||
}
|
||||
is FeedState.Loaded -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
|
||||
NoteCompose(item, isInnerNote = false, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
FeedLoaded(
|
||||
state,
|
||||
routeForLastRead,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
@@ -85,6 +86,34 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
state: FeedState.Loaded,
|
||||
routeForLastRead: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
|
||||
NoteCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingFeed() {
|
||||
Column(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -15,10 +16,7 @@ import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -28,69 +26,105 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
|
||||
class NostrChatroomListFeedViewModel: FeedViewModel(NostrChatroomListDataSource)
|
||||
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
|
||||
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource)
|
||||
class NostrGlobalFeedViewModel: FeedViewModel(NostrGlobalDataSource)
|
||||
class NostrThreadFeedViewModel: FeedViewModel(NostrThreadDataSource)
|
||||
class NostrUserProfileFeedViewModel: FeedViewModel(NostrUserProfileDataSource)
|
||||
|
||||
class NostrChatroomListKnownFeedViewModel: FeedViewModel(NostrChatroomListDataSource) {
|
||||
override fun newListFromDataSource(): List<Note> {
|
||||
// Filter: all channels + PMs the account has replied to
|
||||
return super.newListFromDataSource().filter {
|
||||
val me = NostrChatroomListDataSource.account.userProfile()
|
||||
it.channel != null || me.hasSentMessagesTo(it.author)
|
||||
}
|
||||
}
|
||||
}
|
||||
class NostrChatroomListNewFeedViewModel: FeedViewModel(NostrChatroomListDataSource) {
|
||||
override fun newListFromDataSource(): List<Note> {
|
||||
// Filter: no channels + PMs the account has never replied to
|
||||
return super.newListFromDataSource().filter {
|
||||
val me = NostrChatroomListDataSource.account.userProfile()
|
||||
it.channel == null && !me.hasSentMessagesTo(it.author)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource) {
|
||||
override fun newListFromDataSource(): List<Note> {
|
||||
// Filter: no replies
|
||||
return dataSource.feed().filter { it.isNewThread() }.take(100)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {
|
||||
override fun newListFromDataSource(): List<Note> {
|
||||
// Filter: only replies
|
||||
return dataSource.feed().filter {! it.isNewThread() }.take(100)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
}
|
||||
open fun newListFromDataSource(): List<Note> {
|
||||
return dataSource.loadTop()
|
||||
}
|
||||
|
||||
private fun refreshSuspended() {
|
||||
val notes = dataSource.loadTop()
|
||||
fun hardRefresh() {
|
||||
dataSource.resetFilters()
|
||||
}
|
||||
|
||||
val oldNotesState = feedContent.value
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
if (notes != oldNotesState.feed) {
|
||||
updateFeed(notes)
|
||||
fun refresh() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val notes = newListFromDataSource()
|
||||
|
||||
val oldNotesState = feedContent.value
|
||||
if (oldNotesState is FeedState.Loaded) {
|
||||
if (notes != oldNotesState.feed) {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateFeed(notes)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withContext(Dispatchers.Main) {
|
||||
updateFeed(notes)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updateFeed(notes)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFeed(notes: List<Note>) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { FeedState.Empty }
|
||||
} else {
|
||||
_feedContent.update { FeedState.Loaded(notes) }
|
||||
}
|
||||
val currentState = feedContent.value
|
||||
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { FeedState.Empty }
|
||||
} else if (currentState is FeedState.Loaded) {
|
||||
// updates the current list
|
||||
currentState.feed.value = notes
|
||||
} else {
|
||||
_feedContent.update { FeedState.Loaded(mutableStateOf(notes)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCurrentList() {
|
||||
val state = feedContent.value
|
||||
if (state is FeedState.Loaded) {
|
||||
_feedContent.update { FeedState.Loaded(state.feed) }
|
||||
}
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
synchronized(handlerWaiting) {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
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
|
||||
|
||||
class RelayFeedViewModel: ViewModel() {
|
||||
val order = compareByDescending<User.RelayInfo> { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url }
|
||||
|
||||
private val _feedContent = MutableStateFlow<List<User.RelayInfo>>(emptyList())
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
var currentUser: User? = null
|
||||
|
||||
fun refresh() {
|
||||
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 newList = (beingUsed + newRelaysFromRecord).sortedWith(order)
|
||||
|
||||
_feedContent.update { newList }
|
||||
}
|
||||
}
|
||||
|
||||
inner class CacheListener: User.Listener() {
|
||||
override fun onNewRelayInfo() { invalidateData() }
|
||||
override fun onRelayChange() { invalidateData() }
|
||||
}
|
||||
|
||||
val listener = CacheListener()
|
||||
|
||||
fun subscribeTo(user: User) {
|
||||
currentUser = user
|
||||
user.subscribe(listener)
|
||||
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
|
||||
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
var wantsToAddRelay by remember {
|
||||
mutableStateOf( "")
|
||||
}
|
||||
|
||||
if (wantsToAddRelay.isNotEmpty())
|
||||
NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay)
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
SwipeRefresh(
|
||||
state = swipeRefreshState,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
},
|
||||
) {
|
||||
Column() {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(feedState, key = { _, item -> item.url }) { index, item ->
|
||||
RelayCompose(item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
onAddRelay = { wantsToAddRelay = item.url },
|
||||
onRemoveRelay = { wantsToAddRelay = item.url }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
|
||||
class RelayPoolViewModel: ViewModel() {
|
||||
val connectedRelaysLiveData: LiveData<Int> = Transformations.map(RelayPool.live) {
|
||||
val connectedRelaysLiveData: LiveData<Int> = RelayPool.live.map {
|
||||
it.relays.connectedRelays()
|
||||
}
|
||||
val availableRelaysLiveData: LiveData<Int> = Transformations.map(RelayPool.live) {
|
||||
val availableRelaysLiveData: LiveData<Int> = RelayPool.live.map {
|
||||
it.relays.availableRelays()
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -30,23 +32,28 @@ import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
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.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.ReactionsRowState
|
||||
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
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
@@ -67,7 +74,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 {
|
||||
@@ -86,9 +93,9 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
|
||||
listState.animateScrollToItem(noteIdPositionInThread, 0)
|
||||
}
|
||||
|
||||
val notePosition = state.feed.filter { it.idHex == noteId}.firstOrNull()
|
||||
val notePosition = state.feed.value.filter { it.idHex == noteId}.firstOrNull()
|
||||
if (notePosition != null) {
|
||||
noteIdPositionInThread = state.feed.indexOf(notePosition)
|
||||
noteIdPositionInThread = state.feed.value.indexOf(notePosition)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
@@ -98,7 +105,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
|
||||
if (index == 0)
|
||||
NoteMaster(item, accountViewModel = accountViewModel, navController = navController)
|
||||
else {
|
||||
@@ -106,7 +113,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
|
||||
Row() {
|
||||
NoteCompose(
|
||||
item,
|
||||
Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
modifier = Modifier.drawReplyLevel(item.replyLevel(), MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
@@ -155,12 +162,17 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val noteReportsState by baseNote.liveReports.observeAsState()
|
||||
val noteForReports = noteReportsState?.note ?: return
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote()
|
||||
} else if (!account.isAcceptable(noteForReports)) {
|
||||
HiddenNote()
|
||||
} else {
|
||||
val authorState by note.author!!.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -168,24 +180,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}")
|
||||
}
|
||||
})
|
||||
) {
|
||||
// Draws the boosted picture outside the boosted card.
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
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) {
|
||||
@@ -201,9 +210,9 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|
||||
Column() {
|
||||
val eventContent = note.event?.content
|
||||
if (eventContent != null)
|
||||
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
|
||||
RichTextViewer(eventContent, note.event?.tags, navController)
|
||||
|
||||
ReactionsRowState(note, accountViewModel)
|
||||
ReactionsRow(note, accountViewModel)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
sealed class UserFeedState {
|
||||
object Loading : UserFeedState()
|
||||
class Loaded(val feed: List<User>) : UserFeedState()
|
||||
class Loaded(val feed: MutableState<List<User>>) : UserFeedState()
|
||||
object Empty : UserFeedState()
|
||||
class FeedError(val errorMessage: String) : UserFeedState()
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
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
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -22,13 +25,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
@@ -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 {
|
||||
@@ -56,17 +57,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
is UserFeedState.Loaded -> {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed, key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
FeedLoaded(state, accountViewModel, navController)
|
||||
}
|
||||
UserFeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
@@ -76,3 +67,24 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
state: UserFeedState.Loaded,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -28,12 +31,16 @@ class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel(
|
||||
NostrUserProfileFollowersDataSource
|
||||
)
|
||||
|
||||
class NostrHiddenAccountsFeedViewModel(): UserFeedViewModel(
|
||||
NostrHiddenAccountsDataSource
|
||||
)
|
||||
|
||||
open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
}
|
||||
@@ -55,32 +62,31 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
|
||||
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) {
|
||||
// updates the current list
|
||||
currentState.feed.value = notes
|
||||
} else {
|
||||
_feedContent.update { UserFeedState.Loaded(notes) }
|
||||
_feedContent.update { UserFeedState.Loaded(mutableStateOf(notes)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCurrentList() {
|
||||
val state = feedContent.value
|
||||
if (state is UserFeedState.Loaded) {
|
||||
_feedContent.update { UserFeedState.Loaded(state.feed) }
|
||||
}
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
synchronized(handlerWaiting) {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.map
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
|
||||
class AccountViewModel(private val account: Account): ViewModel() {
|
||||
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
|
||||
val userLiveData: LiveData<UserState> = Transformations.map(account.userProfile().live) { it }
|
||||
val accountLiveData: LiveData<AccountState> = account.live.map { it }
|
||||
|
||||
fun reactTo(note: Note) {
|
||||
account.reactTo(note)
|
||||
}
|
||||
|
||||
fun report(note: Note, type: ReportEvent.ReportType) {
|
||||
account.report(note, type)
|
||||
}
|
||||
|
||||
fun report(user: User, type: ReportEvent.ReportType) {
|
||||
account.report(user, type)
|
||||
}
|
||||
|
||||
fun boost(note: Note) {
|
||||
account.boost(note)
|
||||
}
|
||||
@@ -27,4 +37,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
|
||||
fun decrypt(note: Note): String? {
|
||||
return account.decryptContent(note)
|
||||
}
|
||||
|
||||
fun hide(user: User, ctx: Context) {
|
||||
account.hideUser(user.pubkeyHex)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
|
||||
fun show(user: User, ctx: Context) {
|
||||
account.showUser(user.pubkeyHex)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user