mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
4224ebe429 | ||
|
|
57636607d2 | ||
|
|
8771584deb | ||
|
|
9d52180550 | ||
|
|
75219f0f2d | ||
|
|
0e3b007730 | ||
|
|
ce4e3e6589 | ||
|
|
f1dd5669a1 | ||
|
|
4271ae77d2 | ||
|
|
3846ae2af5 | ||
|
|
a4885e643e | ||
|
|
3441f7987b | ||
|
|
9792733f72 | ||
|
|
bf8a0f0e76 | ||
|
|
381711b72e | ||
|
|
ff529a920e | ||
|
|
7d18f36e3e | ||
|
|
f2b913f7c8 | ||
|
|
d171552efd | ||
|
|
cae674ee99 | ||
|
|
3ee39887a8 | ||
|
|
8f45293be9 | ||
|
|
d0b6e96c2f | ||
|
|
0789752112 | ||
|
|
21b16984bb | ||
|
|
ee9422077e | ||
|
|
0667a822f1 | ||
|
|
2037b35af6 | ||
|
|
01425dd42e | ||
|
|
5667bd5140 | ||
|
|
ed0ca1ab11 | ||
|
|
f5572f7ea5 | ||
|
|
f9b86585be | ||
|
|
f2b831a119 | ||
|
|
6605c92626 | ||
|
|
4543a68615 | ||
|
|
5791511bd6 | ||
|
|
933731215c | ||
|
|
4026ae03e7 | ||
|
|
0539861008 | ||
|
|
ac437ab7ba | ||
|
|
0116bbe01b | ||
|
|
adca60f613 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
11
PRIVACY.md
Normal file
11
PRIVACY.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Amethyst Privacy Policy
|
||||
|
||||
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 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.
|
||||
|
||||
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
|
||||
15
README.md
15
README.md
@@ -11,13 +11,22 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] Notifications Feed
|
||||
- [x] Global Feed
|
||||
- [x] Reactions (like, boost, reply)
|
||||
- [x] Image Preview
|
||||
- [x] Image Preview (gifs, svgs)
|
||||
- [x] Url Preview
|
||||
- [x] View Threads
|
||||
- [ ] Private Messages
|
||||
- [ ] Communities
|
||||
- [x] Private Messages (NIP-04)
|
||||
- [x] User Profiles (follow/unfollow)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [ ] Notification Bubbles
|
||||
- [ ] Infinity Scroll
|
||||
- [ ] Dropdown to Link Users/Posts when writing
|
||||
- [ ] Identity Verification (NIP-05)
|
||||
- [ ] Event Delegation (NIP-09)
|
||||
- [ ] Filter messages from Unknown People
|
||||
- [ ] Profile Edit
|
||||
- [ ] Relay Edit
|
||||
- [ ] Account Creation / Backup Guidance
|
||||
- [ ] Message Sent feedback
|
||||
|
||||
# Development Overview
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 5
|
||||
versionName "0.5"
|
||||
versionCode 24
|
||||
versionName "0.10.6"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -22,7 +22,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ android {
|
||||
compose true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.3.2'
|
||||
kotlinCompilerExtensionVersion '1.4.0'
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@@ -48,28 +48,30 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
|
||||
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-alpha03'
|
||||
implementation 'androidx.compose.runtime:runtime-livedata:1.4.0-alpha03'
|
||||
|
||||
// Input
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
|
||||
// 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') {
|
||||
@@ -78,26 +80,36 @@ 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:4.10.0'
|
||||
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"
|
||||
|
||||
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.** { *; }
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.ui.actions.buildAnnotatedStringWithUrlHighlighting
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EUrlUserTagTransformationTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.vitorpamplona.amethyst", appContext.packageName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transformationText() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
|
||||
user.info.displayName = "Vitor Pamplona"
|
||||
|
||||
var transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
Color.Red
|
||||
)
|
||||
|
||||
assertEquals("New Hey @Vitor Pamplona", transformedText.text.text)
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N
|
||||
assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n
|
||||
assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1
|
||||
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71))
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72))
|
||||
|
||||
|
||||
assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0))
|
||||
assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4))
|
||||
assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8))
|
||||
assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9))
|
||||
|
||||
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23))
|
||||
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transformationTextTwoKeys() {
|
||||
val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"))
|
||||
user.info.displayName = "Vitor Pamplona"
|
||||
|
||||
var transformedText = buildAnnotatedStringWithUrlHighlighting(
|
||||
AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"),
|
||||
Color.Red
|
||||
)
|
||||
|
||||
assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text)
|
||||
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(11))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(12))
|
||||
assertEquals(9, transformedText.offsetMapping.originalToTransformed(13))
|
||||
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z
|
||||
assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before <space>
|
||||
assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a
|
||||
assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n
|
||||
assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d
|
||||
assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before <space>
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @
|
||||
assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n
|
||||
|
||||
assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a
|
||||
assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before <space>
|
||||
assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a
|
||||
assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n
|
||||
assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d
|
||||
assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before <space>
|
||||
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
|
||||
}
|
||||
}
|
||||
@@ -9,24 +9,29 @@
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/amethyst_logo"
|
||||
android:icon="@drawable/amethyst"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/amethyst_logo"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Amethyst"
|
||||
android:largeHeap="true"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.Amethyst">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<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="" />
|
||||
|
||||
@@ -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,50 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.DefaultChannels
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
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", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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.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()
|
||||
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()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
package com.vitorpamplona.amethyst.lnurl
|
||||
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/** based on litecoinj */
|
||||
object LnInvoiceUtil {
|
||||
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+")
|
||||
private val invoicePattern = Pattern.compile("lnbc((?<amount>\\d+)(?<multiplier>[munp])?)?1[^1\\s]+", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
/** The Bech32 character set for encoding. */
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
@@ -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")
|
||||
@@ -1,36 +1,119 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.Constants
|
||||
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.service.relays.Client
|
||||
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
|
||||
|
||||
class Account(val loggedIn: Persona) {
|
||||
var seeReplies: Boolean = true
|
||||
val DefaultChannels = setOf(
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
|
||||
)
|
||||
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
|
||||
val hiddenUsers: MutableSet<String> = mutableSetOf()
|
||||
) {
|
||||
|
||||
fun userProfile(): User {
|
||||
return LocalCache.getOrCreateUser(loggedIn.pubKey)
|
||||
}
|
||||
|
||||
fun followingChannels(): List<Channel> {
|
||||
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
fun hiddenUsers(): List<User> {
|
||||
return hiddenUsers.map { LocalCache.getOrCreateUser(it.toByteArray()) }
|
||||
}
|
||||
|
||||
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.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "+" } != null) {
|
||||
// has already liked this note
|
||||
return
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.create(it, loggedIn.privKey!!)
|
||||
val event = ReactionEvent.createLike(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun report(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
if (
|
||||
note.reactions.firstOrNull { it.author == userProfile() && it.event?.content == "⚠️"} != null
|
||||
) {
|
||||
// has already liked this note
|
||||
return
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = ReactionEvent.createWarning(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
@@ -39,6 +122,15 @@ class Account(val loggedIn: Persona) {
|
||||
fun boost(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val currentTime = Date().time / 1000
|
||||
|
||||
if (
|
||||
note.boosts.firstOrNull { it.author == userProfile() && (it?.event?.createdAt ?: 0) > currentTime - (60 * 5)} != null // 5 minute protection
|
||||
) {
|
||||
// has already bosted in the past 5mins
|
||||
return
|
||||
}
|
||||
|
||||
note.event?.let {
|
||||
val event = RepostEvent.create(it, loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
@@ -52,32 +144,68 @@ class Account(val loggedIn: Persona) {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(message: String, replyingTo: Note?) {
|
||||
fun follow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val replyToEvent = replyingTo?.event
|
||||
if (replyToEvent is TextNoteEvent) {
|
||||
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex())
|
||||
val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex())
|
||||
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = repliesTo,
|
||||
mentions = mentions,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
val event = if (lastestContactList != null) {
|
||||
ContactListEvent.create(
|
||||
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
|
||||
userProfile().relays,
|
||||
loggedIn.privKey!!)
|
||||
} else {
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = null,
|
||||
mentions = null,
|
||||
privateKey = loggedIn.privKey!!
|
||||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(
|
||||
listOf(Contact(user.pubkeyHex, null)),
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun unfollow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
if (lastestContactList != null) {
|
||||
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, replyTo: List<Note>?, mentions: List<User>?) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
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) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val signedEvent = ChannelMessageEvent.create(
|
||||
message = message,
|
||||
channel = toChannel,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) {
|
||||
@@ -95,16 +223,76 @@ class Account(val loggedIn: Persona) {
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendCreateNewChannel(name: String, about: String, picture: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val metadata = ChannelCreateEvent.ChannelData(
|
||||
name, about, picture
|
||||
)
|
||||
|
||||
val event = ChannelCreateEvent.create(
|
||||
channelInfo = metadata,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
joinChannel(event.id.toHex())
|
||||
}
|
||||
|
||||
fun joinChannel(idHex: String) {
|
||||
followingChannels.add(idHex)
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun leaveChannel(idHex: String) {
|
||||
followingChannels.remove(idHex)
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun hideUser(pubkeyHex: String) {
|
||||
hiddenUsers.add(pubkeyHex)
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun showUser(pubkeyHex: String) {
|
||||
hiddenUsers.remove(pubkeyHex)
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val metadata = ChannelCreateEvent.ChannelData(
|
||||
name, about, picture
|
||||
)
|
||||
|
||||
val event = ChannelMetadataEvent.create(
|
||||
newChannelInfo = metadata,
|
||||
originalChannelIdHex = channel.idHex,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
|
||||
followingChannels.add(event.id.toHex())
|
||||
}
|
||||
|
||||
fun decryptContent(note: Note): String? {
|
||||
val event = note.event
|
||||
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
|
||||
var pubkeyToUse = event.pubKey
|
||||
if (note.author == userProfile())
|
||||
pubkeyToUse = event.recipientPubKey!!
|
||||
|
||||
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
|
||||
val recepientPK = event.recipientPubKey
|
||||
|
||||
if (note.author == userProfile() && recepientPK != null)
|
||||
pubkeyToUse = recepientPK
|
||||
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(loggedIn.privKey!!, pubkeyToUse)
|
||||
|
||||
val retVal = Utils.decrypt(event.content, sharedSecret)
|
||||
|
||||
if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) {
|
||||
@@ -122,11 +310,54 @@ class Account(val loggedIn: Persona) {
|
||||
}
|
||||
}
|
||||
|
||||
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 isAcceptable(user: User): Boolean {
|
||||
return user !in hiddenUsers() // if user hasn't hided this author
|
||||
}
|
||||
|
||||
fun isAcceptableDirect(note: Note): Boolean {
|
||||
return note.reports.firstOrNull { it.author == userProfile() } == null // if user has not reported this post
|
||||
&& note.reports.filter { it.author in 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,14 +365,6 @@ class AccountLiveData(private val account: Account): LiveData<AccountState>(Acco
|
||||
fun refresh() {
|
||||
postValue(AccountState(account))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
}
|
||||
}
|
||||
|
||||
class AccountState(val account: Account)
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
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()
|
||||
val idDisplayHex = id.toShortenHex()
|
||||
|
||||
var creator: User? = null
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
return notes[idHex] ?: run {
|
||||
val answer = Note(idHex)
|
||||
notes.put(idHex, answer)
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.refresh()
|
||||
}
|
||||
|
||||
fun profilePicture(): String {
|
||||
if (info.picture.isNullOrBlank()) info.picture = null
|
||||
return info.picture ?: "https://robohash.org/${idHex}.png"
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(info.name, info.about)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: ChannelLiveData = ChannelLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ChannelLiveData(val channel: Channel): LiveData<ChannelState>(ChannelState(channel)) {
|
||||
fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelState(val channel: Channel)
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.regex.Pattern
|
||||
import nostr.postr.Bech32
|
||||
@@ -22,12 +22,10 @@ fun HexKey.toByteArray(): ByteArray {
|
||||
}
|
||||
|
||||
fun HexKey.toDisplayHexKey(): String {
|
||||
return this.toDisplayHex()
|
||||
return this.toShortenHex()
|
||||
}
|
||||
|
||||
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,6 +4,12 @@ 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.RepostEvent
|
||||
import java.io.ByteArrayInputStream
|
||||
@@ -14,11 +20,13 @@ import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
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
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
import nostr.postr.toNpub
|
||||
|
||||
|
||||
object LocalCache {
|
||||
@@ -28,6 +36,7 @@ object LocalCache {
|
||||
|
||||
val users = ConcurrentHashMap<HexKey, User>()
|
||||
val notes = ConcurrentHashMap<HexKey, Note>()
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateUser(pubkey: ByteArray): User {
|
||||
@@ -48,12 +57,22 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: MetadataEvent) {
|
||||
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(key: String): Channel {
|
||||
return channels[key] ?: run {
|
||||
val answer = Channel(key.toByteArray())
|
||||
channels.put(key, answer)
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun consume(event: MetadataEvent) {
|
||||
// new event
|
||||
val oldUser = getOrCreateUser(event.pubKey)
|
||||
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) {
|
||||
@@ -63,6 +82,7 @@ object LocalCache {
|
||||
}
|
||||
|
||||
oldUser.updateUserInfo(newUser, event.createdAt)
|
||||
oldUser.latestMetadata = event
|
||||
} else {
|
||||
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
}
|
||||
@@ -85,17 +105,17 @@ object LocalCache {
|
||||
|
||||
note.loadEvent(event, author, mentions, replyTo)
|
||||
|
||||
//Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
|
||||
//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)
|
||||
|
||||
// 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
|
||||
@@ -103,8 +123,6 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
UrlCachedPreviewer.preloadPreviewsFor(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
@@ -116,7 +134,7 @@ object LocalCache {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (event.createdAt > user.updatedFollowsAt) {
|
||||
//Log.d("CL", "${user.toBestDisplayName()} ${event.follows.size}")
|
||||
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
|
||||
user.updateFollows(
|
||||
event.follows.map {
|
||||
try {
|
||||
@@ -124,16 +142,29 @@ object LocalCache {
|
||||
getOrCreateUser(pubKey)
|
||||
} 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
|
||||
)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
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()
|
||||
}
|
||||
|
||||
user.latestContactList = event
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PrivateDmEvent) {
|
||||
@@ -145,7 +176,7 @@ object LocalCache {
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val recipient = event.recipientPubKey?.let { getOrCreateUser(it) }
|
||||
|
||||
Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
//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)) }
|
||||
@@ -170,7 +201,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
//Log.d("TN", "New Boost (${notes.size},${users.size}) ${note.author.toBestDisplayName()} ${formattedDateTime(event.createdAt)}")
|
||||
//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()
|
||||
@@ -183,10 +214,10 @@ 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)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
@@ -213,10 +244,10 @@ 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") {
|
||||
@@ -225,6 +256,126 @@ object LocalCache {
|
||||
it.addReaction(note)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.content == "\u26A0\uFE0F") {
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ChannelCreateEvent) {
|
||||
//Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
|
||||
// new event
|
||||
val oldChannel = getOrCreateChannel(event.id.toHex())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
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())
|
||||
note.channel = oldChannel
|
||||
note.loadEvent(event, author, emptyList(), mutableListOf())
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
} else {
|
||||
// older data, does nothing
|
||||
}
|
||||
}
|
||||
fun consume(event: ChannelMetadataEvent) {
|
||||
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
if (event.channel.isNullOrBlank()) return
|
||||
|
||||
// new event
|
||||
val oldChannel = getOrCreateChannel(event.channel)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
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())
|
||||
note.channel = oldChannel
|
||||
note.loadEvent(event, author, emptyList(), mutableListOf())
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
} else {
|
||||
//Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: ChannelMessageEvent) {
|
||||
if (event.channel.isNullOrBlank()) return
|
||||
|
||||
val channel = getOrCreateChannel(event.channel)
|
||||
|
||||
val note = channel.getOrCreateNote(event.id.toHex())
|
||||
|
||||
// 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) }
|
||||
.filter { it.event !is ChannelCreateEvent }
|
||||
.toMutableList()
|
||||
)
|
||||
|
||||
note.channel = channel
|
||||
note.loadEvent(event, author, mentions, replyTo)
|
||||
|
||||
//Log.d("CM", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content} ${formattedDateTime(event.createdAt)}")
|
||||
|
||||
// Adds notifications to users.
|
||||
mentions.forEach {
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
replyTo.forEach {
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
replyTo.forEach {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
fun consume(event: ChannelHideMessageEvent) {
|
||||
|
||||
}
|
||||
|
||||
fun consume(event: ChannelMuteUserEvent) {
|
||||
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
return users.values.filter {
|
||||
it.info.anyNameStartsWith(username)
|
||||
|| it.pubkeyHex.startsWith(username, true)
|
||||
|| it.pubkey.toNpub().startsWith(username, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
return notes.values.filter {
|
||||
(it.event is TextNoteEvent && it.event?.content?.contains(text, true) ?: false)
|
||||
|| it.idHex.startsWith(text, true)
|
||||
|| it.id.toNote().startsWith(text, true)
|
||||
}
|
||||
}
|
||||
|
||||
fun findChannelsStartingWith(text: String): List<Channel> {
|
||||
return channels.values.filter {
|
||||
it.anyNameStartsWith(text)
|
||||
|| it.idHex.startsWith(text, true)
|
||||
|| it.id.toNote().startsWith(text, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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.ui.note.toDisplayHex
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
@@ -11,6 +13,7 @@ import java.util.Collections
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.events.Event
|
||||
|
||||
@@ -18,7 +21,7 @@ class Note(val idHex: String) {
|
||||
// These fields are always available.
|
||||
// They are immutable
|
||||
val id = Hex.decode(idHex)
|
||||
val idDisplayHex = id.toDisplayHex()
|
||||
val idDisplayHex = id.toShortenHex()
|
||||
|
||||
// These fields are only available after the Text Note event is received.
|
||||
// They are immutable after that.
|
||||
@@ -32,13 +35,17 @@ class Note(val idHex: String) {
|
||||
val reactions = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||
val boosts = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||
|
||||
val reports = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadEvent(event: Event, author: User, mentions: List<User>, replyTo: MutableList<Note>) {
|
||||
this.event = event
|
||||
this.author = author
|
||||
this.mentions = mentions
|
||||
this.replyTo = replyTo
|
||||
|
||||
refreshObservers()
|
||||
invalidateData(live)
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
@@ -70,54 +77,73 @@ class Note(val idHex: String) {
|
||||
|
||||
fun addReply(note: Note) {
|
||||
if (replies.add(note))
|
||||
refreshObservers()
|
||||
invalidateData(liveReplies)
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (boosts.add(note))
|
||||
refreshObservers()
|
||||
invalidateData(liveBoosts)
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (reactions.add(note))
|
||||
refreshObservers()
|
||||
invalidateData(liveReactions)
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
if (reports.add(note))
|
||||
invalidateData(liveReports)
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
return reactions.any { it.author == user }
|
||||
return synchronized(reactions) {
|
||||
reactions.any { it.author == user }
|
||||
}
|
||||
}
|
||||
|
||||
fun isBoostedBy(user: User): Boolean {
|
||||
return boosts.any { it.author == user }
|
||||
return synchronized(boosts) {
|
||||
boosts.any { it.author == user }
|
||||
}
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: NoteLiveData = NoteLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
val liveReactions: NoteLiveData = NoteLiveData(this)
|
||||
val liveBoosts: NoteLiveData = NoteLiveData(this)
|
||||
val liveReplies: NoteLiveData = NoteLiveData(this)
|
||||
val liveReports: NoteLiveData = NoteLiveData(this)
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData(live: NoteLiveData) {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
live.refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NoteLiveData(val note: Note): LiveData<NoteState>(NoteState(note)) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
|
||||
fun refresh() {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
super.onActive()
|
||||
scope.launch {
|
||||
NostrSingleEventDataSource.add(note.idHex)
|
||||
}
|
||||
NostrSingleEventDataSource.add(note.idHex)
|
||||
}
|
||||
|
||||
override fun onInactive() {
|
||||
super.onInactive()
|
||||
scope.launch {
|
||||
NostrSingleEventDataSource.remove(note.idHex)
|
||||
}
|
||||
NostrSingleEventDataSource.remove(note.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,14 @@ import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtension
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object UrlCachedPreviewer {
|
||||
val cache = ConcurrentHashMap<String, UrlInfoItem>()
|
||||
val failures = ConcurrentHashMap<String, Throwable>()
|
||||
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
cache[url]?.let {
|
||||
@@ -18,16 +23,25 @@ object UrlCachedPreviewer {
|
||||
return
|
||||
}
|
||||
|
||||
BahaUrlPreview(url, object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache.put(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
}
|
||||
failures[url]?.let {
|
||||
callback?.onFailed(it)
|
||||
return
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}).fetchUrlPreview()
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
BahaUrlPreview(url, object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache.put(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures.put(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
|
||||
fun findUrlsInMessage(message: String): List<String> {
|
||||
@@ -46,8 +60,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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,40 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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
|
||||
|
||||
class User(val pubkey: ByteArray) {
|
||||
val pubkeyHex = pubkey.toHexKey()
|
||||
val pubkeyDisplayHex = pubkey.toDisplayHex()
|
||||
val pubkeyDisplayHex = pubkey.toShortenHex()
|
||||
|
||||
var info = UserMetadata()
|
||||
|
||||
var updatedMetadataAt: Long = 0;
|
||||
var updatedFollowsAt: Long = 0;
|
||||
|
||||
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>())
|
||||
|
||||
val followers = Collections.synchronizedSet(mutableSetOf<User>())
|
||||
var relays: Map<String, ContactListEvent.ReadWrite>? = null
|
||||
|
||||
val followers = Collections.synchronizedSet(mutableSetOf<User>())
|
||||
val messages = ConcurrentHashMap<User, MutableSet<Note>>()
|
||||
|
||||
fun toBestDisplayName(): String {
|
||||
@@ -40,13 +55,32 @@ class User(val pubkey: ByteArray) {
|
||||
return info.picture ?: "https://robohash.org/${pubkeyHex}.png"
|
||||
}
|
||||
|
||||
fun follow(user: User) {
|
||||
fun follow(user: User, followedAt: Long) {
|
||||
follows.add(user)
|
||||
user.followers.add(this)
|
||||
|
||||
invalidateData()
|
||||
user.invalidateData()
|
||||
|
||||
listeners.forEach {
|
||||
it.onFollowsChange()
|
||||
}
|
||||
}
|
||||
fun unfollow(user: User) {
|
||||
follows.remove(user)
|
||||
user.followers.remove(this)
|
||||
|
||||
invalidateData()
|
||||
user.invalidateData()
|
||||
|
||||
updateSubscribers {
|
||||
it.onFollowsChange()
|
||||
}
|
||||
}
|
||||
|
||||
fun addTaggedPost(note: Note) {
|
||||
taggedPosts.add(note)
|
||||
updateSubscribers { it.onNewPosts() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
@@ -61,36 +95,99 @@ class User(val pubkey: ByteArray) {
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
getOrCreateChannel(user).add(msg)
|
||||
live.refresh()
|
||||
updateSubscribers { it.onNewMessage() }
|
||||
}
|
||||
|
||||
fun updateFollows(newFollows: List<User>, updateAt: Long) {
|
||||
val toBeAdded = newFollows - follows
|
||||
val toBeRemoved = follows - newFollows
|
||||
|
||||
fun updateFollows(newFollows: Set<User>, updateAt: Long) {
|
||||
val toBeAdded = synchronized(follows) {
|
||||
newFollows - follows
|
||||
}
|
||||
val toBeRemoved = synchronized(follows) {
|
||||
follows - newFollows
|
||||
}
|
||||
toBeAdded.forEach {
|
||||
follow(it)
|
||||
follow(it, updateAt)
|
||||
}
|
||||
toBeRemoved.forEach {
|
||||
unfollow(it)
|
||||
}
|
||||
|
||||
updatedFollowsAt = updateAt
|
||||
}
|
||||
|
||||
live.refresh()
|
||||
fun updateRelays(relayUse: Map<String, ContactListEvent.ReadWrite>) {
|
||||
if (relays != relayUse) {
|
||||
relays = relayUse
|
||||
listeners.forEach {
|
||||
it.onRelayChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUserInfo(newUserInfo: UserMetadata, updateAt: Long) {
|
||||
info = newUserInfo
|
||||
updatedMetadataAt = updateAt
|
||||
|
||||
live.refresh()
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
fun isFollowing(user: User): Boolean {
|
||||
return synchronized(follows) {
|
||||
follows.contains(user)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 onNewPosts() = Unit
|
||||
open fun onNewMessage() = Unit
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var modelHandlerWaiting = false
|
||||
@Synchronized
|
||||
fun updateSubscribers(on: (Listener) -> Unit) {
|
||||
if (modelHandlerWaiting) return
|
||||
|
||||
modelHandlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
listeners.forEach {
|
||||
on(it)
|
||||
}
|
||||
modelHandlerWaiting = false
|
||||
}
|
||||
}
|
||||
|
||||
// UI Observers line up here.
|
||||
val live: UserLiveData = UserLiveData(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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +197,7 @@ class UserMetadata {
|
||||
var display_name: String? = null
|
||||
var displayName: String? = null
|
||||
var picture: String? = null
|
||||
var banner: String? = null
|
||||
var website: String? = null
|
||||
var about: String? = null
|
||||
var nip05: String? = null
|
||||
@@ -111,6 +209,11 @@ class UserMetadata {
|
||||
var iris: String? = null
|
||||
var main_relay: String? = null
|
||||
var twitter: String? = null
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.filter { it.startsWith(prefix, true) }.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {
|
||||
|
||||
@@ -6,5 +6,5 @@ import nostr.postr.JsonFilter
|
||||
data class Channel (
|
||||
val id: String = UUID.randomUUID().toString().substring(0,4)
|
||||
) {
|
||||
var filter: JsonFilter? = null // Inactive when null
|
||||
var filter: List<JsonFilter>? = null // Inactive when null
|
||||
}
|
||||
@@ -6,19 +6,20 @@ object Constants {
|
||||
val defaultRelays = arrayOf(
|
||||
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
|
||||
Relay("wss://relay.nostr.bg", read = true, write = true),
|
||||
//Relay("wss://brb.io", read = true, write = true),
|
||||
Relay("wss://nostr.v0l.io", read = true, write = true),
|
||||
Relay("wss://brb.io", read = true, write = true),
|
||||
Relay("wss://relay.snort.social", read = true, write = true),
|
||||
Relay("wss://nostr.rocks", read = true, write = true),
|
||||
Relay("wss://relay.damus.io", read = true, write = true),
|
||||
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
|
||||
Relay("wss://nostr.oxtr.dev", read = true, write = true),
|
||||
Relay("wss://nostr-relay.wlvs.space", read = true, write = true),
|
||||
Relay("wss://eden.nostr.land", read = true, write = true),
|
||||
//Relay("wss://nostr-2.zebedee.cloud", read = true, write = true),
|
||||
Relay("wss://nostr-pub.wellorder.net", read = true, write = true),
|
||||
Relay("wss://nostr.mom", read = true, write = true),
|
||||
Relay("wss://nostr.orangepill.dev", read = true, write = true),
|
||||
//Relay("wss://nostr-pub.semisol.dev", read = true, write = true),
|
||||
Relay("wss://nostr-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://nostr.sandwich.farm", read = true, write = true),
|
||||
Relay("wss://relay.nostr.ch", read = true, write = true)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun parseTLV(data: ByteArray): Map<Byte, MutableList<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
|
||||
}
|
||||
}
|
||||
@@ -10,67 +10,49 @@ import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrAccountDataSource: NostrDataSource("AccountData") {
|
||||
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 createAccountFilter(): JsonFilter {
|
||||
fun createAccountContactListFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind, ContactListEvent.kind),
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days
|
||||
limit = 5
|
||||
)
|
||||
}
|
||||
|
||||
val accountChannel = requestNewChannel()
|
||||
|
||||
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
|
||||
if (list1 == null && list2 == null) return true
|
||||
if (list1 == null) return false
|
||||
if (list2 == null) return false
|
||||
|
||||
return list1.size == list2.size && list1.toSet() == list2.toSet()
|
||||
fun createAccountMetadataFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 5
|
||||
)
|
||||
}
|
||||
|
||||
fun equalAuthors(list1:JsonFilter?, list2:JsonFilter?): Boolean {
|
||||
if (list1 == null && list2 == null) return true
|
||||
if (list1 == null) return false
|
||||
if (list2 == null) return false
|
||||
|
||||
return equalsIgnoreOrder(list1.authors, list2.authors)
|
||||
}
|
||||
val accountMetadataChannel = requestNewChannel()
|
||||
val accountContactListChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val user = account.userProfile()
|
||||
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
|
||||
|
||||
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 follows }
|
||||
.sortedBy { it.event!!.createdAt }
|
||||
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
// gets everthing about the user logged in
|
||||
val newAccountFilter = createAccountFilter()
|
||||
val newAccountMetadataFilter = createAccountMetadataFilter()
|
||||
accountMetadataChannel.filter = listOf(newAccountMetadataFilter).ifEmpty { null }
|
||||
|
||||
if (!equalAuthors(newAccountFilter, accountChannel.filter)) {
|
||||
accountChannel.filter = newAccountFilter
|
||||
}
|
||||
val newAccountContactListEvent = createAccountContactListFilter()
|
||||
accountContactListChannel.filter = listOf(newAccountContactListEvent).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): JsonFilter? {
|
||||
if (channel != null) {
|
||||
return JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOfNotNull(channel?.idHex)),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val messagesChannel = requestNewChannel()
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.filter = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
|
||||
object NostrChatRoomDataSource: NostrDataSource("ChatroomFeed") {
|
||||
object NostrChatRoomDataSource: NostrDataSource<Note>("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
|
||||
@@ -33,13 +33,13 @@ object NostrChatRoomDataSource: NostrDataSource("ChatroomFeed") {
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val messages = account.userProfile().messages[withUser]
|
||||
val messages = account.userProfile().messages[withUser]?.filter { account.isAcceptable(it) }
|
||||
|
||||
return messages?.sortedBy { it.event!!.createdAt } ?: emptyList()
|
||||
return messages?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
incomingChannel.filter = createMessagesToMeFilter()
|
||||
outgoingChannel.filter = createMessagesFromMeFilter()
|
||||
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
|
||||
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.PrivateDmEvent
|
||||
|
||||
object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
|
||||
object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createMessagesToMeFilter() = JsonFilter(
|
||||
@@ -18,21 +22,74 @@ object NostrChatroomListDataSource: NostrDataSource("MailBoxFeed") {
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
|
||||
fun createChannelsCreatedbyMeFilter() = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex)
|
||||
)
|
||||
|
||||
fun createMyChannelsFilter() = JsonFilter(
|
||||
kinds = listOf(ChannelCreateEvent.kind),
|
||||
ids = account.followingChannels.toList()
|
||||
)
|
||||
|
||||
fun createLastChannelInfoFilter(): List<JsonFilter> {
|
||||
return account.followingChannels.map {
|
||||
JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLastMessageOfEachChannelFilter(): List<JsonFilter> {
|
||||
return account.followingChannels.map {
|
||||
JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val incomingChannel = requestNewChannel()
|
||||
val outgoingChannel = requestNewChannel()
|
||||
|
||||
val myChannelsChannel = requestNewChannel()
|
||||
val myChannelsInfoChannel = requestNewChannel()
|
||||
val myChannelsMessagesChannel = requestNewChannel()
|
||||
|
||||
val myChannelsCreatedbyMeChannel = requestNewChannel()
|
||||
|
||||
// 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().toList().filter { account.isAcceptable(it) }
|
||||
|
||||
return messagingWith.mapNotNull {
|
||||
messages[it]?.sortedBy { it.event?.createdAt }?.last { it.event != null }
|
||||
}.sortedBy { it.event?.createdAt }.reversed()
|
||||
val privateMessages = messagingWith.mapNotNull {
|
||||
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val publicChannels = account.followingChannels().map {
|
||||
it.notes.values.filter { account.isAcceptable(it) }.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 }
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels).filterNotNull().sortedBy { it.event?.createdAt }.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
incomingChannel.filter = createMessagesToMeFilter()
|
||||
outgoingChannel.filter = createMessagesFromMeFilter()
|
||||
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 }
|
||||
|
||||
//myChannelsCreatedbyMeChannel.filter = listOf(createChannelsCreatedbyMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
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.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import java.util.Collections
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.DeletionEvent
|
||||
import nostr.postr.events.Event
|
||||
@@ -15,13 +30,29 @@ import nostr.postr.events.PrivateDmEvent
|
||||
import nostr.postr.events.RecommendRelayEvent
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
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>()
|
||||
|
||||
fun printCounter() {
|
||||
eventCounter.forEach {
|
||||
println("AAA Count ${it.key}: ${it.value}")
|
||||
}
|
||||
}
|
||||
|
||||
private val clientListener = object : Client.Listener() {
|
||||
override fun onEvent(event: Event, subscriptionId: String, relay: Relay) {
|
||||
if (subscriptionId in channelIds) {
|
||||
val key = "${debugName} ${subscriptionId} ${event.kind}"
|
||||
if (eventCounter.contains(key)) {
|
||||
eventCounter.put(key, eventCounter.get(key)!! + 1)
|
||||
} else {
|
||||
eventCounter.put(key, 1)
|
||||
}
|
||||
|
||||
//println("AAA ${debugName} ${subscriptionId} ${event.kind}")
|
||||
when (event) {
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is TextNoteEvent -> LocalCache.consume(event)
|
||||
@@ -34,6 +65,12 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
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))
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,8 +115,24 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadTop(): List<Note> {
|
||||
return feed().take(100)
|
||||
fun loadTop(): List<T> {
|
||||
val returningList = feed().take(100)
|
||||
|
||||
// prepare previews
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
loadPreviews(returningList)
|
||||
}
|
||||
|
||||
return returningList
|
||||
}
|
||||
|
||||
fun loadPreviews(list: List<T>) {
|
||||
list.forEach {
|
||||
if (it is Note) {
|
||||
UrlCachedPreviewer.preloadPreviewsFor(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(): Channel {
|
||||
@@ -95,11 +148,32 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
channelIds.remove(channel.id)
|
||||
}
|
||||
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
delay(200)
|
||||
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 }
|
||||
// saves the current content to only update if it changes
|
||||
val currentFilter = activeChannels.associate { it.id to it.filter!!.toJson() }
|
||||
val currentFilter = activeChannels.associate { it.id to it.filter!!.joinToString("|") { it.toJson() } }
|
||||
|
||||
updateChannelFilters()
|
||||
|
||||
@@ -113,12 +187,12 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
Client.close(channel.id)
|
||||
} else {
|
||||
// was active and is still active, check if it has changed.
|
||||
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
|
||||
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
|
||||
Client.close(channel.id)
|
||||
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
|
||||
Client.sendFilter(channel.id, channelsNewFilter)
|
||||
} else {
|
||||
// hasn't changed, does nothing.
|
||||
Client.sendFilterOnlyIfDisconnected(channel.id, mutableListOf(channelsNewFilter))
|
||||
Client.sendFilterOnlyIfDisconnected(channel.id, channelsNewFilter)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -126,8 +200,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
// was not active and is still not active, does nothing
|
||||
} else {
|
||||
// was not active and becomes active, sends the filter.
|
||||
if (channelsNewFilter.toJson() != currentFilter[channel.id]) {
|
||||
Client.sendFilter(channel.id, mutableListOf(channelsNewFilter))
|
||||
if (channelsNewFilter.joinToString("|") { it.toJson() } != currentFilter[channel.id]) {
|
||||
Client.sendFilter(channel.id, channelsNewFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,5 +209,5 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
abstract fun feed(): List<Note>
|
||||
abstract fun feed(): List<T>
|
||||
}
|
||||
@@ -1,47 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrGlobalDataSource: NostrDataSource("GlobalFeed") {
|
||||
val fifteenMinutes = (60*15) // 15 mins
|
||||
|
||||
object NostrGlobalDataSource: NostrDataSource<Note>("GlobalFeed") {
|
||||
lateinit var account: Account
|
||||
fun createGlobalFilter() = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind),
|
||||
since = System.currentTimeMillis() / 1000 - fifteenMinutes
|
||||
)
|
||||
kinds = listOf(TextNoteEvent.kind),
|
||||
limit = 50
|
||||
)
|
||||
|
||||
val globalFeedChannel = requestNewChannel()
|
||||
|
||||
fun equalTime(list1:Long?, list2:Long?): Boolean {
|
||||
if (list1 == null && list2 == null) return true
|
||||
if (list1 == null) return false
|
||||
if (list2 == null) return false
|
||||
|
||||
return Math.abs(list1 - list2) < (4*fifteenMinutes)
|
||||
}
|
||||
|
||||
fun equalFilters(list1:JsonFilter?, list2:JsonFilter?): Boolean {
|
||||
if (list1 == null && list2 == null) return true
|
||||
if (list1 == null) return false
|
||||
if (list2 == null) return false
|
||||
|
||||
return equalTime(list1.since, list2.since)
|
||||
}
|
||||
|
||||
override fun feed() = LocalCache.notes.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
it.event is TextNoteEvent && (it.event as TextNoteEvent).replyTos.isEmpty()
|
||||
}
|
||||
.sortedBy { it.event!!.createdAt }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val newFilter = createGlobalFilter()
|
||||
|
||||
if (!equalFilters(newFilter, globalFeedChannel.filter)) {
|
||||
globalFeedChannel.filter = newFilter
|
||||
}
|
||||
globalFeedChannel.filter = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -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,44 +3,52 @@ 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.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
object NostrHomeDataSource: NostrDataSource("HomeFeed") {
|
||||
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 = listOf(account.userProfile().pubkeyHex.substring(0, 6)).plus(
|
||||
account.userProfile().follows?.map {
|
||||
it.pubkey.toHex().substring(0, 6)
|
||||
} ?: emptyList()
|
||||
)
|
||||
fun createFollowAccountsFilter(): JsonFilter {
|
||||
val follows = account.userProfile().follows ?: emptySet()
|
||||
|
||||
if (follows.isEmpty()) return null
|
||||
val followKeys = synchronized(follows) {
|
||||
follows.map {
|
||||
it.pubkey.toHex().substring(0, 6)
|
||||
}
|
||||
}
|
||||
|
||||
val followSet = followKeys.plus(account.userProfile().pubkeyHex.substring(0, 6))
|
||||
|
||||
return JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind),
|
||||
authors = follows,
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
|
||||
authors = followSet,
|
||||
limit = 200
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,19 +72,26 @@ object NostrHomeDataSource: NostrDataSource("HomeFeed") {
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val user = account.userProfile()
|
||||
val follows = user.follows.map { it.pubkeyHex }.plus(user.pubkeyHex).toSet()
|
||||
|
||||
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 follows }
|
||||
.sortedBy { it.event!!.createdAt }
|
||||
.filter { (it.event is TextNoteEvent || it.event is RepostEvent) && it.author?.pubkeyHex in allowSet }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.event?.createdAt }
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val newFollowAccountsFilter = createFollowAccountsFilter()
|
||||
|
||||
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter)) {
|
||||
followAccountChannel.filter = newFollowAccountsFilter
|
||||
if (!equalAuthors(newFollowAccountsFilter, followAccountChannel.filter?.firstOrNull())) {
|
||||
followAccountChannel.filter = listOf(newFollowAccountsFilter).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,47 +2,29 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
|
||||
object NostrNotificationDataSource: NostrDataSource("GlobalFeed") {
|
||||
object NostrNotificationDataSource: NostrDataSource<Note>("NotificationFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
fun createGlobalFilter() = JsonFilter(
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 7 days
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex).filterNotNull())
|
||||
fun createNotificationFilter() = JsonFilter(
|
||||
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
|
||||
limit = 100
|
||||
)
|
||||
|
||||
val notificationChannel = requestNewChannel()
|
||||
|
||||
fun <T> equalsIgnoreOrder(list1:List<T>?, list2:List<T>?): Boolean {
|
||||
if (list1 == null && list2 == null) return true
|
||||
if (list1 == null) return false
|
||||
if (list2 == null) return false
|
||||
|
||||
return list1.size == list2.size && list1.toSet() == list2.toSet()
|
||||
}
|
||||
|
||||
fun equalFilters(list1:JsonFilter?, list2:JsonFilter?): Boolean {
|
||||
if (list1 == null && list2 == null) return true
|
||||
if (list1 == null) return false
|
||||
if (list2 == null) return false
|
||||
|
||||
return equalsIgnoreOrder(list1.tags?.get("p"), list2.tags?.get("p"))
|
||||
&& equalsIgnoreOrder(list1.tags?.get("e"), list2.tags?.get("e"))
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return account.userProfile().taggedPosts
|
||||
.filter { it.event != null }
|
||||
.sortedBy { it.event!!.createdAt }
|
||||
.reversed()
|
||||
val set = account.userProfile().taggedPosts
|
||||
val filtered = synchronized(set) {
|
||||
set.filter { it.event != null }.filter { account.isAcceptable(it) }
|
||||
}
|
||||
|
||||
return filtered.sortedBy { it.event?.createdAt }.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
val newFilter = createGlobalFilter()
|
||||
|
||||
if (!equalFilters(newFilter, notificationChannel.filter)) {
|
||||
notificationChannel.filter = newFilter
|
||||
}
|
||||
notificationChannel.filter = listOf(createNotificationFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import java.util.Collections
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
|
||||
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
private var eventsToWatch = setOf<String>()
|
||||
|
||||
fun createRepliesAndReactionsFilter(): JsonFilter? {
|
||||
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
|
||||
private fun createRepliesAndReactionsFilter(): JsonFilter? {
|
||||
val reactionsToWatch = eventsToWatch.map { it }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
@@ -27,18 +27,18 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): 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
|
||||
@@ -46,7 +46,8 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
|
||||
// downloads linked events to this event.
|
||||
return JsonFilter(
|
||||
ids = interestedEvents
|
||||
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,23 +55,28 @@ object NostrSingleEventDataSource: NostrDataSource("SingleEventFeed") {
|
||||
val loadEventsChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
return synchronized(eventsToWatch) {
|
||||
eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
|
||||
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
|
||||
val reactions = createRepliesAndReactionsFilter()
|
||||
val missing = createLoadEventsIfNotLoadedFilter()
|
||||
|
||||
repliesAndReactionsChannel.filter = listOfNotNull(reactions).ifEmpty { null }
|
||||
loadEventsChannel.filter = listOfNotNull(missing).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun add(eventId: String) {
|
||||
eventsToWatch.add(eventId)
|
||||
resetFilters()
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(eventId: String) {
|
||||
eventsToWatch.remove(eventId)
|
||||
resetFilters()
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,34 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import java.util.Collections
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.MetadataEvent
|
||||
|
||||
object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
|
||||
val usersToWatch = Collections.synchronizedList(mutableListOf<String>())
|
||||
object NostrSingleUserDataSource: NostrDataSource<User>("SingleUserFeed") {
|
||||
var usersToWatch = setOf<String>()
|
||||
|
||||
fun createUserFilter(): JsonFilter? {
|
||||
fun createUserFilter(): List<JsonFilter>? {
|
||||
if (usersToWatch.isEmpty()) return null
|
||||
|
||||
return JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = usersToWatch.map { it.substring(0, 8) }
|
||||
)
|
||||
return usersToWatch.map {
|
||||
JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(it),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return usersToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
override fun feed(): List<User> {
|
||||
return synchronized(usersToWatch) {
|
||||
usersToWatch.map {
|
||||
LocalCache.users[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
@@ -31,12 +37,12 @@ object NostrSingleUserDataSource: NostrDataSource("SingleUserFeed") {
|
||||
}
|
||||
|
||||
fun add(userId: String) {
|
||||
usersToWatch.add(userId)
|
||||
resetFilters()
|
||||
usersToWatch = usersToWatch.plus(userId)
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
fun remove(userId: String) {
|
||||
usersToWatch.remove(userId)
|
||||
resetFilters()
|
||||
usersToWatch = usersToWatch.minus(userId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,34 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import java.util.Collections
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
|
||||
object NostrThreadDataSource: NostrDataSource<Note>("SingleThreadFeed") {
|
||||
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
|
||||
|
||||
fun createRepliesAndReactionsFilter(): JsonFilter? {
|
||||
val reactionsToWatch = eventsToWatch.map { it.substring(0, 8) }
|
||||
val reactionsToWatch = eventsToWatch.map { it }
|
||||
|
||||
if (reactionsToWatch.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind),
|
||||
tags = mapOf("e" to reactionsToWatch)
|
||||
)
|
||||
}
|
||||
|
||||
fun createLoadEventsIfNotLoadedFilter(): JsonFilter? {
|
||||
val eventsToLoad = eventsToWatch
|
||||
.map { LocalCache.notes[it] }
|
||||
val nodes = synchronized(eventsToWatch) {
|
||||
eventsToWatch.map { LocalCache.notes[it] }
|
||||
}
|
||||
|
||||
val eventsToLoad = nodes
|
||||
.filterNotNull()
|
||||
.filter { it.event == null }
|
||||
.map { it.idHex.substring(0, 8) }
|
||||
@@ -40,31 +47,38 @@ object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
|
||||
val loadEventsChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
return synchronized(eventsToWatch) {
|
||||
eventsToWatch.map {
|
||||
LocalCache.notes[it]
|
||||
}.filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
repliesAndReactionsChannel.filter = createRepliesAndReactionsFilter()
|
||||
loadEventsChannel.filter = createLoadEventsIfNotLoadedFilter()
|
||||
repliesAndReactionsChannel.filter = listOfNotNull(createRepliesAndReactionsFilter()).ifEmpty { null }
|
||||
loadEventsChannel.filter = listOfNotNull(createLoadEventsIfNotLoadedFilter()).ifEmpty { null }
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String) {
|
||||
val note = LocalCache.notes[noteId] ?: return
|
||||
val note = LocalCache.notes[noteId]
|
||||
|
||||
val thread = mutableListOf<Note>()
|
||||
val threadSet = mutableSetOf<Note>()
|
||||
if (note != null) {
|
||||
val thread = mutableListOf<Note>()
|
||||
val threadSet = mutableSetOf<Note>()
|
||||
|
||||
val threadRoot = note.replyTo?.firstOrNull() ?: note
|
||||
val threadRoot = note.replyTo?.firstOrNull() ?: note
|
||||
|
||||
loadDown(threadRoot, thread, threadSet)
|
||||
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() }
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
val order = compareByDescending<Note> { it.replyLevelSignature() }
|
||||
|
||||
eventsToWatch.clear()
|
||||
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
|
||||
eventsToWatch.clear()
|
||||
eventsToWatch.addAll(thread.sortedWith(order).map { it.idHex })
|
||||
} else {
|
||||
eventsToWatch.clear()
|
||||
eventsToWatch.add(noteId)
|
||||
}
|
||||
|
||||
resetFilters()
|
||||
}
|
||||
@@ -74,7 +88,9 @@ object NostrThreadDataSource: NostrDataSource("SingleThreadFeed") {
|
||||
thread.add(note)
|
||||
threadSet.add(note)
|
||||
|
||||
note.replies.forEach {
|
||||
synchronized(note.replies) {
|
||||
note.replies.toList()
|
||||
}.forEach {
|
||||
loadDown(it, thread, threadSet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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.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.getOrCreateUser(userId.toByteArray())
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createUserInfoFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
|
||||
fun createUserPostsFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
limit = 100
|
||||
)
|
||||
}
|
||||
|
||||
val userInfoChannel = requestNewChannel()
|
||||
val notesChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = user?.notes ?: return emptyList()
|
||||
val sortedNotes = synchronized(notes) {
|
||||
notes.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }
|
||||
}
|
||||
return sortedNotes.reversed()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
userInfoChannel.filter = listOf(createUserInfoFilter()).ifEmpty { null }
|
||||
notesChannel.filter = listOf(createUserPostsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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) {
|
||||
user = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createFollowersFilter() = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
tags = mapOf("p" to listOf(user!!.pubkeyHex))
|
||||
)
|
||||
|
||||
val followerChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<User> {
|
||||
val followers = user?.followers ?: emptyList()
|
||||
|
||||
return synchronized(followers) {
|
||||
followers.filter { account.isAcceptable(it) }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followerChannel.filter = listOf(createFollowersFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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) {
|
||||
user = LocalCache.users[userId]
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createFollowFilter(): JsonFilter {
|
||||
return JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
|
||||
val followChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<User> {
|
||||
val follows = user?.follows ?: emptyList()
|
||||
|
||||
return synchronized(follows) {
|
||||
follows.filter { account.isAcceptable(it) }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followChannel.filter = listOf(createFollowFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
|
||||
class ChannelCreateEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val channelInfo: ChannelData
|
||||
|
||||
init {
|
||||
try {
|
||||
channelInfo = MetadataEvent.gson.fromJson(content, ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
throw Error("can't parse $content", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 40
|
||||
|
||||
fun create(channelInfo: ChannelData?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelCreateEvent {
|
||||
val content = if (channelInfo != null)
|
||||
gson.toJson(channelInfo)
|
||||
else
|
||||
""
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags = emptyList<List<String>>()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
|
||||
data class ChannelData(var name: String?, var about: String?, var picture: String?)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelHideMessageEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val eventsToHide: List<String>
|
||||
|
||||
init {
|
||||
eventsToHide = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 43
|
||||
|
||||
fun create(reason: String, messagesToHide: List<String>?, mentions: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelHideMessageEvent {
|
||||
val content = reason
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags =
|
||||
messagesToHide?.map {
|
||||
listOf("e", it)
|
||||
} ?: emptyList()
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelMessageEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val channel: String?
|
||||
@Transient val replyTos: List<String>
|
||||
@Transient val mentions: List<String>
|
||||
|
||||
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) }
|
||||
mentions = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 42
|
||||
|
||||
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags = mutableListOf(
|
||||
listOf("e", channel, "", "root")
|
||||
)
|
||||
replyTos?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
mentions?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import java.util.Date
|
||||
import nostr.postr.ContactMetaData
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelMetadataEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val channel: String?
|
||||
@Transient val channelInfo: ChannelCreateEvent.ChannelData
|
||||
|
||||
init {
|
||||
channel = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
try {
|
||||
channelInfo = MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
} catch (e: Exception) {
|
||||
throw Error("can't parse $content", e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 41
|
||||
|
||||
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannelIdHex: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
|
||||
val content = if (newChannelInfo != null)
|
||||
gson.toJson(newChannelInfo)
|
||||
else
|
||||
""
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags = listOf( listOf("e", originalChannelIdHex, "", "root") )
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import java.util.Date
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.events.MetadataEvent
|
||||
import nostr.postr.toHex
|
||||
|
||||
class ChannelMuteUserEvent (
|
||||
id: ByteArray,
|
||||
pubKey: ByteArray,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: ByteArray
|
||||
): Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
@Transient val usersToMute: List<String>
|
||||
|
||||
init {
|
||||
usersToMute = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 44
|
||||
|
||||
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
|
||||
val content = reason
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags =
|
||||
usersToMute?.map {
|
||||
listOf("p", it)
|
||||
} ?: emptyList()
|
||||
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return ChannelMuteUserEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,15 @@ class ReactionEvent (
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
fun create(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
val content = "+"
|
||||
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)
|
||||
}
|
||||
|
||||
fun create(content: String, originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val tags = listOf( listOf("e", originalNote.id.toHex()), listOf("p", originalNote.pubKey.toHex()))
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
|
||||
@@ -23,29 +23,20 @@ object Client: RelayPool.Listener {
|
||||
* something.
|
||||
**/
|
||||
var lenient: Boolean = false
|
||||
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
|
||||
internal var relays = Constants.defaultRelays
|
||||
internal val subscriptions = ConcurrentHashMap<String, MutableList<JsonFilter>>()
|
||||
private var listeners = setOf<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
|
||||
}
|
||||
|
||||
fun requestAndWatch(
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
|
||||
) {
|
||||
subscriptions[subscriptionId] = filters
|
||||
RelayPool.requestAndWatch()
|
||||
}
|
||||
|
||||
fun sendFilter(
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
|
||||
filters: List<JsonFilter> = listOf(JsonFilter())
|
||||
) {
|
||||
subscriptions[subscriptionId] = filters
|
||||
RelayPool.sendFilter(subscriptionId)
|
||||
@@ -53,10 +44,10 @@ object Client: RelayPool.Listener {
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: MutableList<JsonFilter> = mutableListOf(JsonFilter())
|
||||
filters: List<JsonFilter> = listOf(JsonFilter())
|
||||
) {
|
||||
subscriptions[subscriptionId] = filters
|
||||
RelayPool.sendFilterOnlyIfDisconnected(subscriptionId)
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: Event) {
|
||||
@@ -90,13 +81,22 @@ object Client: RelayPool.Listener {
|
||||
}
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
|
||||
fun unsubscribe(listener: Listener): Boolean {
|
||||
return listeners.remove(listener)
|
||||
fun unsubscribe(listener: Listener) {
|
||||
listeners = listeners.minus(listener)
|
||||
}
|
||||
|
||||
fun allSubscriptions(): List<String> {
|
||||
return synchronized(subscriptions) {
|
||||
subscriptions.keys.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getSubscriptionFilters(subId: String): List<JsonFilter> {
|
||||
return subscriptions[subId] ?: emptyList()
|
||||
}
|
||||
|
||||
abstract class Listener {
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import java.util.Collections
|
||||
import nostr.postr.JsonFilter
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import nostr.postr.events.ContactListEvent
|
||||
import nostr.postr.events.Event
|
||||
import nostr.postr.toNpub
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
@@ -11,32 +13,38 @@ 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 listeners = Collections.synchronizedSet(HashSet<Listener>())
|
||||
private var listeners = setOf<Listener>()
|
||||
private var socket: WebSocket? = null
|
||||
|
||||
var eventDownloadCounter = 0
|
||||
var eventUploadCounter = 0
|
||||
var errorCounter = 0
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
|
||||
fun unregister(listener: Listener) {
|
||||
listeners = listeners.minus(listener)
|
||||
}
|
||||
|
||||
fun isConnected(): Boolean {
|
||||
return socket != null
|
||||
}
|
||||
|
||||
fun unregister(listener: Listener) = listeners.remove(listener)
|
||||
|
||||
fun requestAndWatch(reconnectTs: Long? = null) {
|
||||
fun requestAndWatch() {
|
||||
val request = Request.Builder().url(url).build()
|
||||
val listener = object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
// Sends everything.
|
||||
Client.subscriptions.forEach {
|
||||
sendFilter(requestId = it.key, reconnectTs = reconnectTs)
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT) }
|
||||
}
|
||||
@@ -48,6 +56,7 @@ class Relay(
|
||||
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) }
|
||||
}
|
||||
@@ -87,48 +96,55 @@ class Relay(
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
t.printStackTrace()
|
||||
errorCounter++
|
||||
|
||||
socket?.close(1000, "Normal close")
|
||||
// Failures disconnect the relay.
|
||||
socket = null
|
||||
Log.w("Relay", "Relay onFailure ${url}, ${response?.message}")
|
||||
//t.printStackTrace()
|
||||
listeners.forEach {
|
||||
it.onError(this@Relay, "", Error("WebSocket Failure. Response: ${response}. Exception: ${t.message}", t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket = httpClient.newWebSocket(request, listener)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
//httpClient.dispatcher.executorService.shutdown()
|
||||
socket?.close(1000, "Normal close")
|
||||
socket = null
|
||||
}
|
||||
|
||||
fun sendFilter(requestId: String, reconnectTs: Long? = null) {
|
||||
if (socket == null) {
|
||||
requestAndWatch(reconnectTs)
|
||||
} else {
|
||||
val filters = if (reconnectTs != null) {
|
||||
Client.subscriptions[requestId]?.let {
|
||||
it.map { filter ->
|
||||
JsonFilter(filter.ids, filter.authors, filter.kinds, filter.tags, since = reconnectTs)
|
||||
}
|
||||
} ?: error("No filter(s) found.")
|
||||
fun sendFilter(requestId: String) {
|
||||
if (read) {
|
||||
if (socket == null) {
|
||||
requestAndWatch()
|
||||
} else {
|
||||
Client.subscriptions[requestId] ?: error("No filter(s) found.")
|
||||
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)
|
||||
}
|
||||
}
|
||||
val request = """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]"""
|
||||
//println("FILTERSSENT " + """["REQ","$requestId",${filters.joinToString(",") { it.toJson() }}]""")
|
||||
socket!!.send(request)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(requestId: String, reconnectTs: Long? = null) {
|
||||
fun sendFilterOnlyIfDisconnected() {
|
||||
if (socket == null) {
|
||||
requestAndWatch(reconnectTs)
|
||||
requestAndWatch()
|
||||
}
|
||||
}
|
||||
|
||||
fun send(signedEvent: Event) {
|
||||
if (write)
|
||||
if (write) {
|
||||
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
|
||||
eventUploadCounter++
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String){
|
||||
|
||||
@@ -3,14 +3,18 @@ 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
|
||||
|
||||
/**
|
||||
* RelayPool manages the connection to multiple Relays and lets consumers deal with simple events.
|
||||
*/
|
||||
object RelayPool: Relay.Listener {
|
||||
private val relays = Collections.synchronizedList(ArrayList<Relay>())
|
||||
private val listeners = Collections.synchronizedSet(HashSet<Listener>())
|
||||
private var relays = listOf<Relay>()
|
||||
private var listeners = setOf<Listener>()
|
||||
|
||||
fun availableRelays(): Int {
|
||||
return relays.size
|
||||
@@ -20,6 +24,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) }
|
||||
@@ -29,7 +37,8 @@ object RelayPool: Relay.Listener {
|
||||
}
|
||||
|
||||
fun unloadRelays() {
|
||||
relays.toList().forEach { removeRelay(it) }
|
||||
relays.forEach { it.unregister(this) }
|
||||
relays = listOf()
|
||||
}
|
||||
|
||||
fun requestAndWatch() {
|
||||
@@ -40,8 +49,8 @@ object RelayPool: Relay.Listener {
|
||||
relays.forEach { it.sendFilter(subscriptionId) }
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected(subscriptionId: String) {
|
||||
relays.forEach { it.sendFilterOnlyIfDisconnected(subscriptionId) }
|
||||
fun sendFilterOnlyIfDisconnected() {
|
||||
relays.forEach { it.sendFilterOnlyIfDisconnected() }
|
||||
}
|
||||
|
||||
fun send(signedEvent: Event) {
|
||||
@@ -61,19 +70,17 @@ object RelayPool: Relay.Listener {
|
||||
relays += relay
|
||||
}
|
||||
|
||||
fun removeRelay(relay: Relay): Boolean {
|
||||
fun removeRelay(relay: Relay) {
|
||||
relay.unregister(this)
|
||||
return relays.remove(relay)
|
||||
relays = relays.minus(relay)
|
||||
}
|
||||
|
||||
fun getRelays(): List<Relay> = relays
|
||||
|
||||
fun register(listener: Listener) {
|
||||
listeners.add(listener)
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
|
||||
fun unregister(listener: Listener): Boolean {
|
||||
return listeners.remove(listener)
|
||||
fun unregister(listener: Listener) {
|
||||
listeners = listeners.minus(listener)
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
@@ -109,7 +116,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,7 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -8,34 +10,60 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.KeyStorage
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
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) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
setContent {
|
||||
AmethystTheme {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
|
||||
val accountViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
|
||||
AccountStateViewModel(LocalPreferences(applicationContext))
|
||||
}
|
||||
|
||||
AccountScreen(accountViewModel)
|
||||
AccountScreen(accountViewModel, startingPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,20 +73,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Client.connect()
|
||||
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
ServiceManager.pause()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrNotificationDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
Client.disconnect()
|
||||
super.onPause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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.LocalTextStyle
|
||||
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.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
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
|
||||
val postViewModel: NewChannelViewModel = viewModel()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
postViewModel.load(account, channel)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
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(context)
|
||||
onClose()
|
||||
},
|
||||
postViewModel.channelName.value.text.isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Channel Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.channelName.value,
|
||||
onValueChange = { postViewModel.channelName.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "My Awesome Group",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Picture Url") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = postViewModel.channelPicture.value,
|
||||
onValueChange = { postViewModel.channelPicture.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "http://mygroup.com/logo.jpg",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = "Description") },
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||
value = postViewModel.channelDescription.value,
|
||||
onValueChange = { postViewModel.channelDescription.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "About us.. ",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
maxLines = 10
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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
|
||||
|
||||
val channelName = mutableStateOf(TextFieldValue())
|
||||
val channelPicture = mutableStateOf(TextFieldValue())
|
||||
val channelDescription = mutableStateOf(TextFieldValue())
|
||||
|
||||
|
||||
fun load(account: Account, channel: Channel?) {
|
||||
this.account = account
|
||||
if (channel != null) {
|
||||
originalChannel = channel
|
||||
channelName.value = TextFieldValue(channel.info.name ?: "")
|
||||
channelPicture.value = TextFieldValue(channel.info.picture ?: "")
|
||||
channelDescription.value = TextFieldValue(channel.info.about ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
channelName.value = TextFieldValue()
|
||||
channelPicture.value = TextFieldValue()
|
||||
channelDescription.value = TextFieldValue()
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,20 @@ 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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
@@ -18,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
|
||||
@@ -29,34 +36,43 @@ import androidx.compose.ui.graphics.Color
|
||||
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
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.VideoView
|
||||
import com.vitorpamplona.amethyst.ui.components.imageExtension
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import com.vitorpamplona.amethyst.ui.components.videoExtension
|
||||
import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserLine
|
||||
import kotlinx.coroutines.delay
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account) {
|
||||
val postViewModel: NewPostViewModel = viewModel<NewPostViewModel>().apply {
|
||||
this.replyingTo = replyingTo
|
||||
this.account = account
|
||||
}
|
||||
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account) {
|
||||
val postViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
postViewModel.load(account, baseReplyTo)
|
||||
|
||||
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) {
|
||||
@@ -99,30 +115,20 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
postViewModel.message.isNotBlank()
|
||||
postViewModel.message.text.isNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
if (replyingTo != null && replyingTo.event is TextNoteEvent) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val replyList = replyingTo.replyTo!!.plus(replyingTo).joinToString(", ", "", "", 2) { it.idDisplayHex }
|
||||
val withList = replyingTo.mentions!!.plus(replyingTo.author!!).joinToString(", ", "", "", 2) { it.toBestDisplayName() }
|
||||
|
||||
Text(
|
||||
"in reply to ${replyList} with ${withList}",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, "✖ ") {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = postViewModel.message,
|
||||
onValueChange = {
|
||||
postViewModel.message = it
|
||||
postViewModel.urlPreview = postViewModel.findUrlInMessage()
|
||||
postViewModel.updateMessage(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
@@ -150,30 +156,54 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
|
||||
val userSuggestions = postViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
) {
|
||||
itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item ->
|
||||
UserLine(item) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val myUrlPreview = postViewModel.urlPreview
|
||||
if (myUrlPreview != null) {
|
||||
Column(modifier = Modifier.padding(top = 5.dp)) {
|
||||
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview, false)
|
||||
if (isValidURL(myUrlPreview)) {
|
||||
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
AsyncImage(
|
||||
model = myUrlPreview,
|
||||
contentDescription = myUrlPreview,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
VideoView(myUrlPreview)
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
|
||||
UrlPreview("https://$myUrlPreview", myUrlPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,8 +229,9 @@ fun CloseButton(onCancel: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PostButton(onPost: () -> Unit = {}, isActive: Boolean) {
|
||||
fun PostButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
@@ -215,3 +246,46 @@ fun PostButton(onPost: () -> Unit = {}, isActive: Boolean) {
|
||||
Text(text = "Post", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = "Create", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_search),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,22 +8,95 @@ import android.provider.MediaStore
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import nostr.postr.toNpub
|
||||
|
||||
class NewPostViewModel: ViewModel() {
|
||||
var account: Account? = null
|
||||
var replyingTo: Note? = null
|
||||
private var account: Account? = null
|
||||
private var originalNote: Note? = null
|
||||
|
||||
var message by mutableStateOf("")
|
||||
var mentions by mutableStateOf<List<User>?>(null)
|
||||
var replyTos by mutableStateOf<MutableList<Note>?>(null)
|
||||
|
||||
var message by mutableStateOf(TextFieldValue(""))
|
||||
var urlPreview by mutableStateOf<String?>(null)
|
||||
|
||||
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||
var userSuggestionAnchor: TextRange? = null
|
||||
|
||||
fun load(account: Account, replyingTo: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
this.replyTos = (replyNote.replyTo ?: mutableListOf()).plus(replyNote).toMutableList()
|
||||
replyNote.author?.let { replyUser ->
|
||||
val currentMentions = replyNote.mentions ?: emptyList()
|
||||
if (currentMentions.contains(replyUser)) {
|
||||
this.mentions = currentMentions
|
||||
} else {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
this.account = account
|
||||
}
|
||||
|
||||
fun addUserToMentionsIfNotInAndReturnIndex(user: User): Int {
|
||||
val replyToSize = replyTos?.size ?: 0
|
||||
|
||||
var myMentions = mentions
|
||||
if (myMentions == null) {
|
||||
mentions = listOf(user)
|
||||
return replyToSize + 0 // position of the user
|
||||
}
|
||||
|
||||
val index = myMentions.indexOf(user)
|
||||
|
||||
if (index >= 0) return replyToSize + index
|
||||
|
||||
myMentions = myMentions.plus(user)
|
||||
mentions = myMentions
|
||||
return replyToSize + myMentions.indexOf(user)
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
account?.sendPost(message, replyingTo)
|
||||
message = ""
|
||||
// Moves @npub to mentions
|
||||
val newMessage = message.text.split('\n').map { paragraph: String ->
|
||||
paragraph.split(' ').map { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key)
|
||||
|
||||
val index = addUserToMentionsIfNotInAndReturnIndex(user)
|
||||
|
||||
val newWord = "#[${index}]"
|
||||
|
||||
newWord + restOfWord
|
||||
} else {
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// if it can't parse the key, don't try to change.
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
|
||||
account?.sendPost(newMessage, replyTos, mentions)
|
||||
message = TextFieldValue("")
|
||||
urlPreview = null
|
||||
}
|
||||
|
||||
@@ -36,22 +109,56 @@ class NewPostViewModel: ViewModel() {
|
||||
|
||||
img?.let {
|
||||
ImageUploader.uploadImage(img) {
|
||||
message = message + "\n\n" + it
|
||||
message = TextFieldValue(message.text + "\n\n" + it)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
message = ""
|
||||
message = TextFieldValue("")
|
||||
urlPreview = null
|
||||
}
|
||||
|
||||
fun findUrlInMessage(): String? {
|
||||
return message.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
|
||||
paragraph.split(' ').firstOrNull { word: String ->
|
||||
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFromReplyList(it: User) {
|
||||
mentions = mentions?.minus(it)
|
||||
}
|
||||
|
||||
fun updateMessage(it: TextFieldValue) {
|
||||
message = it
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
if (it.selection.collapsed) {
|
||||
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
userSuggestionAnchor = it.selection
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
} else {
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun autocompleteWithUser(item: User) {
|
||||
userSuggestionAnchor?.let {
|
||||
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkey.toNpub()} "
|
||||
|
||||
message = TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
|
||||
)
|
||||
userSuggestionAnchor = null
|
||||
userSuggestions = emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
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() {
|
||||
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(onNewRelay: (NewRelayListViewModel.Relay) -> Unit) {
|
||||
var url by remember { mutableStateOf<String>("") }
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.util.Patterns
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
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 kotlin.math.roundToInt
|
||||
|
||||
data class RangesChanges(val original: TextRange, val modified: TextRange)
|
||||
|
||||
class UrlUserTagTransformation(val color: Color) : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
return buildAnnotatedStringWithUrlHighlighting(text, color)
|
||||
}
|
||||
}
|
||||
|
||||
fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color): TransformedText {
|
||||
val substitutions = mutableListOf<RangesChanges>()
|
||||
|
||||
val newText = buildAnnotatedString {
|
||||
val builderBefore = StringBuilder() // important to correctly measure Tag start and end
|
||||
val builderAfter = StringBuilder() // important to correctly measure Tag start and end
|
||||
append(
|
||||
text.split('\n').map { paragraph: String ->
|
||||
paragraph.split(' ').map { word: String ->
|
||||
try {
|
||||
if (word.startsWith("@npub") && word.length >= 64) {
|
||||
val keyB32 = word.substring(0, 64)
|
||||
val restOfWord = word.substring(64)
|
||||
|
||||
val startIndex = builderBefore.toString().length
|
||||
|
||||
builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph
|
||||
|
||||
val endIndex = startIndex + keyB32.length
|
||||
|
||||
val key = decodePublicKey(keyB32.removePrefix("@"))
|
||||
val user = LocalCache.getOrCreateUser(key)
|
||||
|
||||
val newWord = "@${user.toBestDisplayName()}"
|
||||
val startNew = builderAfter.toString().length
|
||||
|
||||
builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph
|
||||
|
||||
substitutions.add(
|
||||
RangesChanges(
|
||||
TextRange(startIndex, endIndex),
|
||||
TextRange(startNew, startNew + newWord.length)
|
||||
)
|
||||
)
|
||||
newWord + restOfWord
|
||||
} else {
|
||||
builderBefore.append(word + " ")
|
||||
builderAfter.append(word + " ")
|
||||
word
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// if it can't parse the key, don't try to change.
|
||||
builderBefore.append(word + " ")
|
||||
builderAfter.append(word + " ")
|
||||
word
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}.joinToString("\n")
|
||||
)
|
||||
|
||||
val newText = toAnnotatedString()
|
||||
|
||||
newText.split("\\s+".toRegex()).filter { word ->
|
||||
Patterns.WEB_URL.matcher(word).matches()
|
||||
}.forEach {
|
||||
val startIndex = text.indexOf(it)
|
||||
val endIndex = startIndex + it.length
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = color,
|
||||
textDecoration = TextDecoration.None
|
||||
),
|
||||
start = startIndex, end = endIndex
|
||||
)
|
||||
}
|
||||
|
||||
substitutions.forEach {
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = color,
|
||||
textDecoration = TextDecoration.None
|
||||
),
|
||||
start = it.modified.start, end = it.modified.end
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val numberOffsetTranslator = object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
val inInsideRange = substitutions.filter { offset > it.original.start && offset < it.original.end }.firstOrNull()
|
||||
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange = (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat())
|
||||
return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).roundToInt()
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end }
|
||||
|
||||
if (lastRangeThrough != null) {
|
||||
return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end)
|
||||
} else {
|
||||
return offset
|
||||
}
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
val inInsideRange = substitutions.filter { offset > it.modified.start && offset < it.modified.end }.firstOrNull()
|
||||
|
||||
if (inInsideRange != null) {
|
||||
val percentInRange = (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat())
|
||||
return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).roundToInt()
|
||||
}
|
||||
|
||||
val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end }
|
||||
|
||||
if (lastRangeThrough != null) {
|
||||
return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end)
|
||||
} else {
|
||||
return offset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
newText,
|
||||
numberOffsetTranslator
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.vitorpamplona.amethyst.buttons
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewChannelButton(account: Account) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost)
|
||||
NewChannelView({ wantsToPost = false }, account = account)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { wantsToPost = true },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = "New Channel",
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
fun ClickableEmail(email: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$email "),
|
||||
onClick = { runCatching { context.sendMail(email) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.sendMail(to: String, subject: String? = null) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.type = "vnd.android.cursor.item/email" // or "message/rfc822"
|
||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
|
||||
if (subject != null)
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// TODO: Handle case where no email app is available
|
||||
} catch (t: Throwable) {
|
||||
// TODO: Handle potential other type of exceptions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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.Note
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
|
||||
@Composable
|
||||
fun ClickableNoteTag(
|
||||
baesNote: Note,
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baesNote.id.toNote().toShortenHex()} "),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
fun ClickablePhone(phone: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$phone "),
|
||||
onClick = { runCatching { context.dial(phone) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
)
|
||||
}
|
||||
|
||||
fun Context.dial(phone: String) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", phone, null))
|
||||
startActivity(intent)
|
||||
} catch (t: Throwable) {
|
||||
// TODO: Handle potential exceptions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@Composable
|
||||
fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$urlText "),
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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.User
|
||||
|
||||
@Composable
|
||||
fun ClickableUserTag(
|
||||
user: User,
|
||||
navController: NavController
|
||||
) {
|
||||
val innerUserState by user.live.observeAsState()
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
|
||||
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
@@ -27,10 +27,16 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String) {
|
||||
val amount = LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
val amount = try {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -70,14 +76,16 @@ fun InvoicePreview(lnInvoice: String) {
|
||||
|
||||
Divider()
|
||||
|
||||
Text(
|
||||
text = "${amount.toInt()} sats",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
)
|
||||
amount?.let {
|
||||
Text(
|
||||
text = "${amount.toInt()} sats",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
|
||||
@@ -3,30 +3,27 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import android.util.Patterns
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
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.toNote
|
||||
import com.vitorpamplona.amethyst.ui.note.toDisplayHex
|
||||
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
|
||||
|
||||
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp)$")
|
||||
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$")
|
||||
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm)$")
|
||||
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
|
||||
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
|
||||
@@ -46,9 +43,8 @@ fun isValidURL(url: String?): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@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,18 +58,25 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
|
||||
} else if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].toLowerCase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
ExtendedImageView(word)
|
||||
ZoomableImageView(word)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
VideoView(word)
|
||||
} else {
|
||||
UrlPreview(word, word)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
UrlPreview("https://$word", word)
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, navController)
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,26 +101,23 @@ fun TagLink(word: String, tags: List<List<String>>, navController: NavController
|
||||
return Text(text = "$word ")
|
||||
}
|
||||
|
||||
if (index > 0 && index < tags.size) {
|
||||
if (index >= 0 && index < tags.size) {
|
||||
if (tags[index][0] == "p") {
|
||||
val user = LocalCache.users[tags[index][1]]
|
||||
if (user != null) {
|
||||
val innerUserState by user.live.observeAsState()
|
||||
Text(
|
||||
"@${innerUserState?.user?.toBestDisplayName()} "
|
||||
)
|
||||
ClickableUserTag(user, navController)
|
||||
} else {
|
||||
Text(text = "${tags[index][1].toShortenHex()} ")
|
||||
}
|
||||
} else if (tags[index][0] == "e") {
|
||||
val note = LocalCache.notes[tags[index][1]]
|
||||
if (note != null) {
|
||||
val innerNoteState by note.live.observeAsState()
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerNoteState?.note?.id?.toNote()?.toDisplayHex()} "),
|
||||
onClick = { navController.navigate("Note/${note.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
ClickableNoteTag(note, navController)
|
||||
} else {
|
||||
Text(text = "${tags[index][1].toShortenHex()} ")
|
||||
}
|
||||
} else
|
||||
Text(text = "$word ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,21 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.baha.url.preview.IUrlPreviewCallback
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
UrlCachedPreviewer.previewInfo(url, object : IUrlPreviewCallback {
|
||||
@@ -59,50 +35,15 @@ fun UrlPreview(url: String, urlText: String, showUrlIfError: Boolean = true) {
|
||||
Crossfade(targetState = urlPreviewState) { state ->
|
||||
when (state) {
|
||||
is UrlPreviewState.Loaded -> {
|
||||
Row(
|
||||
modifier = Modifier.clickable { runCatching { uri.openUri(url) } }
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
model = state.previewInfo.image,
|
||||
contentDescription = "Profile Image",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = state.previewInfo.title,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 10.dp, end = 10.dp, top= 10.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = state.previewInfo.description,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
UrlPreviewCard(url, state.previewInfo)
|
||||
}
|
||||
else -> {
|
||||
if (showUrlIfError) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("$urlText "),
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
)
|
||||
ClickableUrl(urlText, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.baha.url.preview.UrlInfoItem
|
||||
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
url: String,
|
||||
previewInfo: UrlInfoItem
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { runCatching { uri.openUri(url) } }
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
) {
|
||||
Column {
|
||||
AsyncImage(
|
||||
model = previewInfo.image,
|
||||
contentDescription = "Profile Image",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.title,
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = previewInfo.description,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,6 @@ fun ZoomableAsyncImage(imageUrl: String) {
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = "Profile Image",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -21,15 +23,20 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import nostr.postr.toNpub
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun ExtendedImageView(word: String) {
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
|
||||
fun ZoomableImageView(word: String) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
@@ -44,8 +51,9 @@ fun ExtendedImageView(word: String) {
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
.clickable(
|
||||
onClick = { dialogOpen = true }
|
||||
.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) },
|
||||
)
|
||||
)
|
||||
|
||||
@@ -47,10 +47,25 @@ fun AppBottomBar(navController: NavHostController) {
|
||||
selected = currentRoute == item.route,
|
||||
onClick = {
|
||||
if (currentRoute != item.route) {
|
||||
navController.navigate(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.navigate(item.route){
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start) { inclusive = item.route == Route.Home.route }
|
||||
restoreState = true
|
||||
}
|
||||
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,16 +4,23 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel,
|
||||
nextPage: String? = null
|
||||
) {
|
||||
NavHost(navController, startDestination = Route.Home.route) {
|
||||
Routes.forEach {
|
||||
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, navController))
|
||||
composable(it.route, it.arguments, content = it.buildScreen(accountViewModel, accountStateViewModel, navController))
|
||||
}
|
||||
}
|
||||
|
||||
if (nextPage != null) {
|
||||
navController.navigate(nextPage)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
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
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -19,18 +24,40 @@ 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.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.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
|
||||
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 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
|
||||
@@ -38,18 +65,17 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
when (currentRoute(navController)) {
|
||||
Route.Profile.route,
|
||||
Route.Lists.route,
|
||||
Route.Topics.route,
|
||||
Route.Bookmarks.route,
|
||||
Route.Moments.route -> TopBarWithBackButton(navController)
|
||||
//Route.Profile.route -> TopBarWithBackButton(navController)
|
||||
else -> MainTopBar(scaffoldState, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@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().live.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
@@ -58,33 +84,94 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
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.subscriptions.map { "${it.key} ${it.value.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())
|
||||
}
|
||||
) {
|
||||
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
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_amethyst),
|
||||
null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
@@ -97,19 +184,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)
|
||||
.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
|
||||
) {
|
||||
|
||||
@@ -8,11 +8,11 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
@@ -34,29 +34,26 @@ 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.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val bottomNavigations = listOf(
|
||||
Route.Profile,
|
||||
Route.Lists,
|
||||
//Route.Topics,
|
||||
Route.Bookmarks,
|
||||
//Route.Moments
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DrawerContent(navController: NavHostController,
|
||||
scaffoldState: ScaffoldState,
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel) {
|
||||
|
||||
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().live.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
|
||||
Surface(
|
||||
@@ -65,19 +62,35 @@ fun DrawerContent(navController: NavHostController,
|
||||
) {
|
||||
Column() {
|
||||
Box {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = "Profile Banner",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
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 = 125.dp)
|
||||
.padding(top = 100.dp),
|
||||
scaffoldState,
|
||||
navController
|
||||
)
|
||||
}
|
||||
Divider(
|
||||
@@ -85,6 +98,7 @@ fun DrawerContent(navController: NavHostController,
|
||||
modifier = Modifier.padding(top = 20.dp)
|
||||
)
|
||||
ListContent(
|
||||
accountUser,
|
||||
navController,
|
||||
scaffoldState,
|
||||
modifier = Modifier
|
||||
@@ -97,16 +111,28 @@ fun DrawerContent(navController: NavHostController,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier) {
|
||||
fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
AsyncImage(
|
||||
model = accountUser?.profilePicture() ?: "https://robohash.org/ohno.png",
|
||||
contentDescription = "Profile Image",
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${accountUser?.pubkeyHex}.png"),
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(100.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.border(3.dp, MaterialTheme.colors.background, CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = {
|
||||
accountUser?.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
})
|
||||
)
|
||||
Text(
|
||||
accountUser?.bestDisplayName() ?: "",
|
||||
@@ -130,32 +156,43 @@ fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier) {
|
||||
|
||||
@Composable
|
||||
fun ListContent(
|
||||
accountUser: User?,
|
||||
navController: NavHostController,
|
||||
scaffoldState: ScaffoldState,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountStateViewModel
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
LazyColumn() {
|
||||
items(items = bottomNavigations) {
|
||||
NavigationRow(navController, scaffoldState, it)
|
||||
}
|
||||
item {
|
||||
if (accountUser != null)
|
||||
NavigationRow(navController,
|
||||
scaffoldState,
|
||||
"User/${accountUser.pubkeyHex}",
|
||||
Route.Profile.icon,
|
||||
"Profile"
|
||||
)
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 15.dp),
|
||||
modifier = Modifier.padding(bottom = 15.dp),
|
||||
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),
|
||||
@@ -170,31 +207,37 @@ fun ListContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState, route: Route) {
|
||||
fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState, route: String, icon: Int, title: String) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val currentRoute = currentRoute(navController)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 15.dp, horizontal = 25.dp)
|
||||
.clickable(onClick = {
|
||||
if (currentRoute != route.route) {
|
||||
navController.navigate(route.route)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
}),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = {
|
||||
if (currentRoute != route) {
|
||||
navController.navigate(route)
|
||||
}
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
})
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(route.icon), null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
text = route.route,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 15.dp, horizontal = 25.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(icon), null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
text = title,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,8 +10,11 @@ import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import com.vitorpamplona.amethyst.R
|
||||
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.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.ProfileScreen
|
||||
@@ -24,26 +27,32 @@ sealed class Route(
|
||||
val route: String,
|
||||
val icon: Int,
|
||||
val arguments: List<NamedNavArgument> = emptyList(),
|
||||
val buildScreen: (AccountViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
|
||||
val buildScreen: (AccountViewModel, AccountStateViewModel, NavController) -> @Composable (NavBackStackEntry) -> Unit
|
||||
) {
|
||||
object Home : Route("Home", R.drawable.ic_home, buildScreen = { acc, nav -> { _ -> HomeScreen(acc, nav) } })
|
||||
object Search : Route("Search", R.drawable.ic_search, buildScreen = { acc, nav -> { _ -> SearchScreen(acc, nav) }})
|
||||
object Notification : Route("Notification", R.drawable.ic_notifications,buildScreen = { acc, nav -> { _ -> NotificationScreen(acc, nav) }})
|
||||
object Message : Route("Message", R.drawable.ic_dm, buildScreen = { acc, nav -> { _ -> ChatroomListScreen(acc, nav) }})
|
||||
object Profile : Route("Profile", R.drawable.ic_profile, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
|
||||
object Lists : Route("Lists", R.drawable.ic_lists, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
|
||||
object Topics : Route("Topics", R.drawable.ic_topics, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
|
||||
object Bookmarks : Route("Bookmarks", R.drawable.ic_bookmarks, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
|
||||
object Moments : Route("Moments", R.drawable.ic_moments, buildScreen = { acc, nav -> { _ -> ProfileScreen(acc) }})
|
||||
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 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 } ),
|
||||
buildScreen = { acc, accSt, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) }}
|
||||
)
|
||||
|
||||
object Note : Route("Note/{id}", R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
|
||||
buildScreen = { acc, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) }}
|
||||
buildScreen = { acc, accSt, nav -> { ThreadScreen(it.arguments?.getString("id"), acc, nav) }}
|
||||
)
|
||||
|
||||
object Room : Route("Room/{id}", R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
|
||||
buildScreen = { acc, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) }}
|
||||
buildScreen = { acc, accSt, nav -> { ChatroomScreen(it.arguments?.getString("id"), acc, nav) }}
|
||||
)
|
||||
|
||||
object Channel : Route("Channel/{id}", R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType } ),
|
||||
buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) }}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,14 +65,10 @@ val Routes = listOf(
|
||||
|
||||
//drawer
|
||||
Route.Profile,
|
||||
Route.Lists,
|
||||
Route.Topics,
|
||||
Route.Bookmarks,
|
||||
Route.Moments,
|
||||
|
||||
//inner
|
||||
Route.Note,
|
||||
Route.Room
|
||||
Route.Room,
|
||||
Route.Channel,
|
||||
Route.Filters
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -20,6 +21,7 @@ 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.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
|
||||
@@ -66,11 +68,17 @@ fun BoostSetCompose(likeSetCard: BoostSetCard, isInnerNote: Boolean = false, acc
|
||||
|
||||
AsyncImage(
|
||||
model = userState?.user?.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${userState?.user?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
.height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
userState?.let {
|
||||
navController.navigate("User/${it.user.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.material.Divider
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -15,11 +18,19 @@ 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.painter.Painter
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
@@ -27,11 +38,49 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().live.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} else if (note.channel != null) {
|
||||
val authorState by note.author!!.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
|
||||
val channelState by note.channel!!.live.observeAsState()
|
||||
val channel = channelState?.channel
|
||||
|
||||
val description = if (note.event is ChannelCreateEvent) {
|
||||
"Channel created"
|
||||
} else if (note.event is ChannelMetadataEvent) {
|
||||
"Channel Information changed to "
|
||||
} else {
|
||||
note.event?.content
|
||||
}
|
||||
channel?.let { channel ->
|
||||
ChannelName(
|
||||
channelPicture = channel.profilePicture(),
|
||||
channelPicturePlaceholder = null,
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${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/${channel.idHex}") })
|
||||
}
|
||||
|
||||
} else {
|
||||
val authorState by note.author!!.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
@@ -49,50 +98,81 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { navController.navigate("Room/${userToComposeOn?.pubkeyHex}") }
|
||||
)
|
||||
userToComposeOn?.let { user ->
|
||||
ChannelName(
|
||||
channelPicture = user.profilePicture(),
|
||||
channelPicturePlaceholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
|
||||
channelTitle = { UsernameDisplay(user, it) },
|
||||
channelLastTime = note.event?.createdAt,
|
||||
channelLastContent = accountViewModel.decrypt(note),
|
||||
onClick = { navController.navigate("Room/${user.pubkeyHex}") })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelName(
|
||||
channelPicture: String,
|
||||
channelPicturePlaceholder: Painter?,
|
||||
channelTitle: @Composable (Modifier) -> Unit,
|
||||
channelLastTime: Long?,
|
||||
channelLastContent: String?,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.clickable(onClick = onClick) ) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp)
|
||||
) {
|
||||
Row(
|
||||
|
||||
AsyncImage(
|
||||
model = channelPicture,
|
||||
placeholder = channelPicturePlaceholder,
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp)
|
||||
) {
|
||||
.width(55.dp)
|
||||
.height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
AsyncImage(
|
||||
model = userToComposeOn?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (userToComposeOn != null)
|
||||
UserDisplay(userToComposeOn)
|
||||
Column(modifier = Modifier.padding(start = 10.dp),
|
||||
verticalArrangement = Arrangement.SpaceAround) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
) {
|
||||
channelTitle(Modifier.weight(1f))
|
||||
|
||||
channelLastTime?.let {
|
||||
Text(
|
||||
timeAgo(note.event?.createdAt),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
timeAgo(channelLastTime),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f)
|
||||
)
|
||||
}
|
||||
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
if (eventContent != null)
|
||||
RichTextViewer(eventContent.take(100), note.event?.tags, note, accountViewModel, navController)
|
||||
else
|
||||
RichTextViewer("Referenced event not found", note.event?.tags, note, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
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)
|
||||
)
|
||||
else
|
||||
Text(
|
||||
"Referenced event not found",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
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
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
@@ -13,106 +22,190 @@ 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.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.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.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
|
||||
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(20.dp, 20.dp, 3.dp, 20.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(20.dp, 20.dp, 20.dp, 3.dp)
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
|
||||
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, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().live.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} else {
|
||||
val authorState by note.author!!.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
|
||||
Column(modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { navController.navigate("User/${note.idHex}") }
|
||||
)
|
||||
) {
|
||||
var backgroundBubbleColor: Color
|
||||
var alignment: Arrangement.Horizontal
|
||||
var shape: Shape
|
||||
var backgroundBubbleColor: Color
|
||||
var alignment: Arrangement.Horizontal
|
||||
var shape: Shape
|
||||
|
||||
if (author == accountUser) {
|
||||
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
alignment = Arrangement.End
|
||||
shape = ChatBubbleShapeMe
|
||||
} else {
|
||||
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
|
||||
alignment = Arrangement.Start
|
||||
shape = ChatBubbleShapeThem
|
||||
}
|
||||
if (author == accountUser) {
|
||||
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
alignment = Arrangement.End
|
||||
shape = ChatBubbleShapeMe
|
||||
} else {
|
||||
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
|
||||
alignment = Arrangement.Start
|
||||
shape = ChatBubbleShapeThem
|
||||
}
|
||||
|
||||
Column() {
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp)
|
||||
modifier = Modifier.fillMaxWidth(1f).padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
),
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f)
|
||||
) {
|
||||
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = shape
|
||||
shape = shape,
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
if (eventContent != null)
|
||||
RichTextViewer(
|
||||
eventContent,
|
||||
note.event?.tags,
|
||||
note,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
else
|
||||
RichTextViewer(
|
||||
"Could Not decrypt the message",
|
||||
note.event?.tags,
|
||||
note,
|
||||
accountViewModel,
|
||||
navController
|
||||
|
||||
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(25.dp)
|
||||
.height(25.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
author?.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
Text(
|
||||
" ${author?.toBestDisplayName()}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.clickable(onClick = {
|
||||
author?.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
innerQuote = true,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val event = note.event
|
||||
if (event is ChannelCreateEvent) {
|
||||
Text(text = "${note.author?.toBestDisplayName()} created " +
|
||||
"${event.channelInfo.name ?: ""} with " +
|
||||
"description of '${event.channelInfo.about ?: ""}', " +
|
||||
"and picture '${event.channelInfo.picture ?: ""}'")
|
||||
} else if (event is ChannelMetadataEvent) {
|
||||
Text(text = "${note.author?.toBestDisplayName()} changed " +
|
||||
"chat name to '${event.channelInfo.name ?: ""}', " +
|
||||
"description to '${event.channelInfo.about ?: ""}', " +
|
||||
"and picture to '${event.channelInfo.picture ?: ""}'")
|
||||
} else {
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
|
||||
if (eventContent != null)
|
||||
RichTextViewer(
|
||||
eventContent,
|
||||
note.event?.tags,
|
||||
navController
|
||||
)
|
||||
else
|
||||
RichTextViewer(
|
||||
"Could Not decrypt the message",
|
||||
note.event?.tags,
|
||||
navController
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
timeAgoLong(note.event?.createdAt),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -20,6 +21,7 @@ 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.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
|
||||
@@ -30,7 +32,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
|
||||
val noteState by likeSetCard.note.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
if (note?.event == null) {
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
Column(modifier = modifier) {
|
||||
@@ -66,11 +68,17 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, modifier: Modifier = Modifier, isIn
|
||||
|
||||
AsyncImage(
|
||||
model = userState?.user?.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${userState?.user?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
.width(35.dp).height(35.dp)
|
||||
.height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
userState?.let {
|
||||
navController.navigate("User/${it.user.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,13 +28,16 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
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.RepostEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||
@@ -42,20 +48,41 @@ import nostr.postr.toNpub
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
|
||||
val noteState by baseNote.live.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(modifier, isInnerNote)
|
||||
BlankNote(modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true },
|
||||
), isInnerNote)
|
||||
} else if (account?.isAcceptable(note) == false) {
|
||||
HiddenNote(modifier.combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true },
|
||||
), isInnerNote)
|
||||
} else {
|
||||
val authorState by note.author!!.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
|
||||
Column(modifier =
|
||||
modifier.combinedClickable(
|
||||
onClick = { navController.navigate("Note/${note.idHex}") },
|
||||
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 },
|
||||
)
|
||||
) {
|
||||
@@ -73,9 +100,15 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
author?.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// boosted picture
|
||||
@@ -84,12 +117,18 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
AsyncImage(
|
||||
model = boostedPosts[0].author?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${boostedPosts[0].author?.pubkeyHex}.png"),
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
.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}")
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -98,12 +137,13 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (author != null)
|
||||
UserDisplay(author)
|
||||
UsernameDisplay(author, 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(
|
||||
@@ -115,14 +155,18 @@ 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)
|
||||
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 ->
|
||||
NoteCompose(
|
||||
note,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
modifier = Modifier,
|
||||
isInnerNote = true,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
@@ -141,9 +185,11 @@ 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)
|
||||
RichTextViewer(eventContent, note.event?.tags, navController)
|
||||
|
||||
ReactionsRowState(note, accountViewModel)
|
||||
if (note.event !is ChannelMessageEvent) {
|
||||
ReactionsRow(note, accountViewModel)
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
@@ -161,12 +207,13 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
@Composable
|
||||
fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded,
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.event?.content ?: "")); onDismiss() }) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) {
|
||||
Text("Copy Text")
|
||||
}
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.author?.pubkey?.toNpub() ?: "")); onDismiss() }) {
|
||||
@@ -179,5 +226,12 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
|
||||
Text("Broadcast")
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = { accountViewModel.report(note); onDismiss() }) {
|
||||
Text("Report Post")
|
||||
}
|
||||
DropdownMenuItem(onClick = { note.author?.let { accountViewModel.hide(it, context) }; onDismiss() }) {
|
||||
Text("Hide User")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import nostr.postr.toHex
|
||||
|
||||
fun ByteArray.toDisplayHex(): String {
|
||||
return toHex().toDisplayHex()
|
||||
fun ByteArray.toShortenHex(): String {
|
||||
return toHex().toShortenHex()
|
||||
}
|
||||
|
||||
fun String.toDisplayHex(): String {
|
||||
fun String.toShortenHex(): String {
|
||||
return replaceRange(6, length-6, ":")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,65 @@ 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.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) }
|
||||
|
||||
var wantsToReplyTo by remember {
|
||||
mutableStateOf<Note?>(null)
|
||||
}
|
||||
@@ -35,105 +68,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 = { }
|
||||
) {
|
||||
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)
|
||||
}
|
||||
@@ -1,61 +1,145 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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>?) {
|
||||
fun ReplyInformation(replyTo: MutableList<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) {
|
||||
FlowRow() {
|
||||
/*
|
||||
if (replyTo != null && replyTo.isNotEmpty()) {
|
||||
Text(
|
||||
" in reply to ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
replyTo.toSet().forEachIndexed { idx, note ->
|
||||
val innerNoteState by note.live.observeAsState()
|
||||
Text(
|
||||
"${innerNoteState?.note?.idDisplayHex}${if (idx < replyTo.size - 1) ", " else ""}",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
if (mentions != null && mentions.isNotEmpty()) {
|
||||
Text(
|
||||
"replying to ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
mentions.toSet().forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live.observeAsState()
|
||||
if (replyTo != null && replyTo.isNotEmpty()) {
|
||||
Text(
|
||||
"${innerUserState?.user?.toBestDisplayName()}",
|
||||
"replying to ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
if (idx < mentions.size - 2) {
|
||||
Text(
|
||||
", ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
} else if (idx < mentions.size - 1) {
|
||||
Text(
|
||||
" and ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
mentions.toSet().forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live.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 < mentions.size - 2) {
|
||||
Text(
|
||||
", ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
} else if (idx < mentions.size - 1) {
|
||||
Text(
|
||||
" and ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ReplyInformationChannel(replyTo: MutableList<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: MutableList<Note>?,
|
||||
mentions: List<User>?,
|
||||
channel: Channel,
|
||||
prefix: String = "",
|
||||
onUserTagClick: (User) -> Unit,
|
||||
onChannelTagClick: (Channel) -> Unit
|
||||
) {
|
||||
FlowRow() {
|
||||
if (mentions != null && mentions.isNotEmpty()) {
|
||||
if (replyTo != null && replyTo.isNotEmpty()) {
|
||||
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) }
|
||||
)
|
||||
|
||||
Text(
|
||||
"replying to ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
mentions.toSet().forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live.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 < mentions.size - 2) {
|
||||
Text(
|
||||
", ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
} else if (idx < mentions.size - 1) {
|
||||
Text(
|
||||
" and ",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ fun timeAgo(mills: Long?): String {
|
||||
return " • " + humanReadable
|
||||
.replace(" hr. ago", "h")
|
||||
.replace(" min. ago", "m")
|
||||
.replace(" days. ago", "d")
|
||||
.replace(" days ago", "d")
|
||||
}
|
||||
|
||||
fun timeAgoLong(mills: Long?): String {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.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.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
|
||||
|
||||
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}") }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp)
|
||||
) {
|
||||
|
||||
AsyncImage(
|
||||
model = user.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UsernameDisplay(user)
|
||||
}
|
||||
|
||||
Text(
|
||||
user.info.about ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
if (account?.isAcceptable(user) == false) {
|
||||
ShowUserButton {
|
||||
account.showUser(user.pubkeyHex)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
} else if (account?.userProfile()?.isFollowing(user) == true) {
|
||||
UnfollowButton { account.unfollow(user) }
|
||||
} else {
|
||||
FollowButton { account?.follow(user) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,21 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
@Composable
|
||||
fun UserDisplay(user: User) {
|
||||
fun UsernameDisplay(user: User, weight: Modifier = Modifier) {
|
||||
if (user.bestUsername() != null || user.bestDisplayName() != null) {
|
||||
if (user.bestDisplayName().isNullOrBlank()) {
|
||||
Text(
|
||||
"@${(user.bestUsername() ?: "")}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = weight
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
@@ -22,12 +27,18 @@ fun UserDisplay(user: User) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,14 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.compose.animation.Crossfade
|
||||
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.ExperimentalLifecycleComposeApi
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@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 ->
|
||||
@@ -20,10 +19,10 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel) {
|
||||
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,8 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.DefaultChannels
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
@@ -12,100 +16,72 @@ 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 { login(it) }
|
||||
localPreferences.loadFromEncryptedStorage()?.let {
|
||||
login(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun login(key: String) {
|
||||
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||
|
||||
login(
|
||||
val account =
|
||||
if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes())
|
||||
Account(Persona(privKey = key.bechToBytes()))
|
||||
} else if (key.startsWith("npub")) {
|
||||
Persona(pubKey = key.bechToBytes())
|
||||
Account(Persona(pubKey = key.bechToBytes()))
|
||||
} else if (pattern.matcher(key).matches()) {
|
||||
// Evaluate NIP-5
|
||||
Persona()
|
||||
Account(Persona())
|
||||
} else {
|
||||
Persona(Hex.decode(key))
|
||||
Account(Persona(Hex.decode(key)))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun login(person: Persona) {
|
||||
val loggedIn = Account(person)
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
|
||||
if (person.privKey != null)
|
||||
_accountContent.update { AccountState.LoggedIn ( loggedIn ) }
|
||||
else
|
||||
_accountContent.update { AccountState.LoggedInViewOnly ( Account(person) ) }
|
||||
|
||||
saveToEncryptedStorage(person)
|
||||
|
||||
NostrAccountDataSource.account = loggedIn
|
||||
NostrHomeDataSource.account = loggedIn
|
||||
NostrNotificationDataSource.account = loggedIn
|
||||
NostrChatroomListDataSource.account = loggedIn
|
||||
|
||||
NostrAccountDataSource.start()
|
||||
NostrGlobalDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrNotificationDataSource.start()
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
NostrThreadDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
login(account)
|
||||
}
|
||||
|
||||
fun newKey() {
|
||||
login(Persona())
|
||||
val account = Account(Persona())
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
login(account)
|
||||
}
|
||||
|
||||
fun login(account: Account) {
|
||||
if (account.loggedIn.privKey != null)
|
||||
_accountContent.update { AccountState.LoggedIn ( account ) }
|
||||
else
|
||||
_accountContent.update { AccountState.LoggedInViewOnly ( account ) }
|
||||
|
||||
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(login: Persona) {
|
||||
encryptedPreferences.edit().apply {
|
||||
login.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||
login.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Persona? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString("nostr_privkey", null)
|
||||
val pubKey = getString("nostr_pubkey", null)
|
||||
|
||||
if (pubKey != null) {
|
||||
return Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray())
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
localPreferences.clearEncryptedStorage()
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,15 @@ 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() {
|
||||
@@ -18,6 +21,8 @@ class LikeSetCard(val note: Note, val likeEvents: List<Note>): Card() {
|
||||
override fun createdAt(): Long {
|
||||
return createdAt
|
||||
}
|
||||
|
||||
override fun id() = note.idHex + createdAt
|
||||
}
|
||||
|
||||
class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
|
||||
@@ -26,6 +31,8 @@ class BoostSetCard(val note: Note, val boostEvents: List<Note>): Card() {
|
||||
override fun createdAt(): Long {
|
||||
return createdAt
|
||||
}
|
||||
|
||||
override fun id() = note.idHex + createdAt
|
||||
}
|
||||
|
||||
sealed class CardFeedState {
|
||||
|
||||
@@ -8,12 +8,12 @@ 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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
@@ -23,10 +23,9 @@ import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@Composable
|
||||
fun CardFeedView(viewModel: CardFeedViewModel, 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 +66,7 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed) { index, item ->
|
||||
itemsIndexed(state.feed, key = { _, item -> item.id() }) { 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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -8,26 +10,30 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CardFeedViewModel(val dataSource: NostrDataSource): ViewModel() {
|
||||
class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
private var lastNotes: List<Note>? = null
|
||||
|
||||
fun refresh() {
|
||||
// For some reason, view Model Scope doesn't call
|
||||
viewModelScope.launch {
|
||||
refreshSuspend()
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSuspend() {
|
||||
private fun refreshSuspended() {
|
||||
val notes = dataSource.loadTop()
|
||||
|
||||
val lastNotesCopy = lastNotes
|
||||
@@ -75,22 +81,33 @@ class CardFeedViewModel(val dataSource: NostrDataSource): ViewModel() {
|
||||
}
|
||||
|
||||
fun updateFeed(notes: List<Card>) {
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { CardFeedState.Empty }
|
||||
} else {
|
||||
_feedContent.update { CardFeedState.Loaded(notes) }
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { CardFeedState.Empty }
|
||||
} else {
|
||||
_feedContent.update { CardFeedState.Loaded(notes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCurrentList() {
|
||||
val state = feedContent.value
|
||||
if (state is CardFeedState.Loaded) {
|
||||
_feedContent.update { CardFeedState.Loaded(state.feed) }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
refresh()
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
@@ -13,18 +13,16 @@ 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.ExperimentalLifecycleComposeApi
|
||||
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
|
||||
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@Composable
|
||||
fun ChatroomFeedView(userId: String, viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
@@ -63,17 +61,14 @@ fun ChatroomFeedView(userId: String, viewModel: FeedViewModel, accountViewModel:
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
reverseLayout = true,
|
||||
state = listState
|
||||
) {
|
||||
var previousDate: String = ""
|
||||
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
|
||||
itemsIndexed(state.feed, key = { index, item -> if (index == 0) index else item.idHex }) { index, item ->
|
||||
ChatroomMessageCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
listState.animateScrollToItem(state.feed.size-1, 0)
|
||||
}
|
||||
}
|
||||
FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
|
||||
@@ -8,20 +8,22 @@ 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.ExperimentalLifecycleComposeApi
|
||||
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.ui.note.ChatroomCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@Composable
|
||||
fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
@@ -30,6 +32,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
@@ -65,7 +68,7 @@ fun ChatroomListFeedView(viewModel: FeedViewModel, accountViewModel: AccountView
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed, key = { _, item -> item.idHex }) { index, item ->
|
||||
itemsIndexed(state.feed, key = { index, item -> item.idHex }) { index, item ->
|
||||
ChatroomCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -21,7 +22,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.swiperefresh.SwipeRefresh
|
||||
@@ -29,10 +29,9 @@ import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@Composable
|
||||
fun FeedView(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)
|
||||
@@ -41,7 +40,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
viewModel.hardRefresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,98 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.remember
|
||||
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.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||
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.model.RepostEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
|
||||
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
|
||||
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.messages[it.author]?.firstOrNull { me == it.author } != null
|
||||
}
|
||||
}
|
||||
}
|
||||
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.messages[it.author]?.firstOrNull { me == it.author } == null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource) {
|
||||
override fun newListFromDataSource(): List<Note> {
|
||||
// Filter: no replies
|
||||
return dataSource.feed().filter {
|
||||
it.event is RepostEvent || it.replyTo == null || it.replyTo?.size == 0
|
||||
}.take(100)
|
||||
}
|
||||
}
|
||||
|
||||
class NostrHomeRepliesFeedViewModel: FeedViewModel(NostrHomeDataSource) {}
|
||||
|
||||
|
||||
class FeedViewModel(val dataSource: NostrDataSource): ViewModel() {
|
||||
abstract class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
// For some reason, view Model Scope doesn't call
|
||||
viewModelScope.launch {
|
||||
refreshSuspend()
|
||||
}
|
||||
open fun newListFromDataSource(): List<Note> {
|
||||
return dataSource.loadTop()
|
||||
}
|
||||
|
||||
fun refreshSuspend() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,15 +104,23 @@ class FeedViewModel(val dataSource: NostrDataSource): ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCurrentList() {
|
||||
val state = feedContent.value
|
||||
if (state is FeedState.Loaded) {
|
||||
_feedContent.update { FeedState.Loaded(state.feed) }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
refresh()
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -28,25 +30,24 @@ 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.ExperimentalLifecycleComposeApi
|
||||
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.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRowState
|
||||
import com.vitorpamplona.amethyst.ui.note.UserDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalLifecycleComposeApi::class)
|
||||
@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)
|
||||
@@ -165,20 +166,28 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp)) {
|
||||
Row(modifier = Modifier.padding(start = 12.dp, end = 12.dp)) {
|
||||
Row(modifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
.clickable(onClick = {
|
||||
author?.let {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
})
|
||||
) {
|
||||
// Draws the boosted picture outside the boosted card.
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (author != null)
|
||||
UserDisplay(author)
|
||||
UsernameDisplay(author)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -194,9 +203,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),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
sealed class UserFeedState {
|
||||
object Loading : UserFeedState()
|
||||
class Loaded(val feed: List<User>) : UserFeedState()
|
||||
object Empty : UserFeedState()
|
||||
class FeedError(val errorMessage: String) : UserFeedState()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user