mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-03 21:36:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -7,6 +7,7 @@
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
/.idea/androidTestResultsUserPreferences.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
|
||||
+11
@@ -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
|
||||
@@ -18,11 +18,15 @@ Amethyst brings the best social network to your Android phone. Just insert your
|
||||
- [x] User Profiles (follow/unfollow)
|
||||
- [x] Public Chats (NIP-28)
|
||||
- [ ] Notification Bubbles
|
||||
- [ ] Dropdown to Link Users/Posts when writting
|
||||
- [ ] 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
|
||||
|
||||
|
||||
+9
-9
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 7
|
||||
versionName "0.7"
|
||||
versionCode 21
|
||||
versionName "0.10.3"
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0-alpha04'
|
||||
implementation 'androidx.activity:activity-compose:1.6.1'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
@@ -61,16 +61,16 @@ dependencies {
|
||||
implementation "androidx.compose.material:material-icons-extended:1.3.1"
|
||||
|
||||
// Input
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0-alpha04'
|
||||
|
||||
// Swipe Refresh
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.24.13-rc'
|
||||
implementation 'com.google.accompanist:accompanist-swiperefresh:0.29.0-alpha'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:2.2.2"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.0'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.7.1'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
@@ -79,9 +79,9 @@ 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'
|
||||
|
||||
Vendored
+7
-1
@@ -18,4 +18,10 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
# For the Secp256k1 library
|
||||
-keep class fr.acinq.secp256k1.jni.** { *; }
|
||||
# For the NostrPostr library
|
||||
-keep class nostr.postr.** { *; }
|
||||
-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,17 +9,16 @@
|
||||
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" />
|
||||
|
||||
+2
-2
@@ -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,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.lnurl
|
||||
|
||||
import androidx.compose.ui.text.toLowerCase
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
@@ -128,7 +129,7 @@ object LnInvoiceUtil {
|
||||
}
|
||||
|
||||
private fun multiplier(multiplier: String): BigDecimal {
|
||||
return when (multiplier) {
|
||||
return when (multiplier.toLowerCase()) {
|
||||
"m" -> BigDecimal("0.001")
|
||||
"u" -> BigDecimal("0.000001")
|
||||
"n" -> BigDecimal("0.000000001")
|
||||
|
||||
@@ -2,24 +2,40 @@ 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
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb" // -> Anigma's Nostr
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
|
||||
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
|
||||
)
|
||||
|
||||
class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet()) {
|
||||
var seeReplies: Boolean = true
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
val followingChannels: MutableSet<String> = DefaultChannels.toMutableSet(),
|
||||
val hiddenUsers: MutableSet<String> = DefaultChannels.toMutableSet()
|
||||
) {
|
||||
|
||||
fun userProfile(): User {
|
||||
return LocalCache.getOrCreateUser(loggedIn.pubKey)
|
||||
@@ -29,14 +45,52 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
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
|
||||
}
|
||||
@@ -48,6 +102,23 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun boost(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -67,12 +138,19 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
fun follow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().lastestContactList
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
val event = if (lastestContactList != null) {
|
||||
ContactListEvent.create(lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), lastestContactList.relayUse, loggedIn.privKey!!)
|
||||
ContactListEvent.create(
|
||||
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)),
|
||||
userProfile().relays,
|
||||
loggedIn.privKey!!)
|
||||
} else {
|
||||
val relays = Constants.defaultRelays.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) }
|
||||
ContactListEvent.create(listOf(Contact(user.pubkeyHex, null)), relays, loggedIn.privKey!!)
|
||||
ContactListEvent.create(
|
||||
listOf(Contact(user.pubkeyHex, null)),
|
||||
relays,
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
}
|
||||
|
||||
Client.send(event)
|
||||
@@ -82,40 +160,31 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
fun unfollow(user: User) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val lastestContactList = userProfile().lastestContactList
|
||||
val lastestContactList = userProfile().latestContactList
|
||||
if (lastestContactList != null) {
|
||||
val event = ContactListEvent.create(lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, lastestContactList.relayUse, loggedIn.privKey!!)
|
||||
val event = ContactListEvent.create(
|
||||
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
|
||||
userProfile().relays,
|
||||
loggedIn.privKey!!)
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(message: String, replyingTo: Note?) {
|
||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<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 repliesToHex = replyTo?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = repliesTo,
|
||||
mentions = mentions,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
} else {
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = null,
|
||||
mentions = null,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
val signedEvent = TextNoteEvent.create(
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
|
||||
fun sendChannelMeesage(message: String, toChannel: String, replyingTo: Note? = null) {
|
||||
@@ -145,6 +214,61 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
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)
|
||||
}
|
||||
|
||||
fun leaveChannel(idHex: String) {
|
||||
followingChannels.remove(idHex)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -175,11 +299,54 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
|
||||
}
|
||||
}
|
||||
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
return userProfile().relays?.map { Relay(it.key, it.value.read, it.value.write) }?.toTypedArray()
|
||||
}
|
||||
|
||||
init {
|
||||
userProfile().subscribe(object: User.Listener() {
|
||||
override fun onRelayChange() {
|
||||
Client.disconnect()
|
||||
Client.connect(activeRelays() ?: Constants.defaultRelays)
|
||||
RelayPool.requestAndWatch()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: AccountLiveData = AccountLiveData(this)
|
||||
|
||||
private fun refreshObservers() {
|
||||
live.refresh()
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
live.refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun 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 ReactionEvent
|
||||
|| (note.event is ReactionEvent && note.replyTo?.firstOrNull { isAcceptableDirect(note) } == null)
|
||||
) // is not a reaction about a blocked post
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,14 +354,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)
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -28,9 +29,10 @@ class Channel(val id: ByteArray) {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateChannelInfo(channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
info = channelInfo
|
||||
updatedMetadataAt = updatedAt
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.refresh()
|
||||
}
|
||||
@@ -40,6 +42,11 @@ class Channel(val id: ByteArray) {
|
||||
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)
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ fun HexKey.toDisplayHexKey(): String {
|
||||
}
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
} else if (key.startsWith("npub")) {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -19,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 {
|
||||
@@ -65,11 +68,11 @@ object LocalCache {
|
||||
|
||||
|
||||
fun consume(event: MetadataEvent) {
|
||||
//Log.d("MT", "New User ${users.size} ${event.contactMetaData.name}")
|
||||
|
||||
// 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) {
|
||||
@@ -79,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)}")
|
||||
}
|
||||
@@ -101,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
|
||||
@@ -119,8 +123,6 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
UrlCachedPreviewer.preloadPreviewsFor(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ object LocalCache {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (event.createdAt > user.updatedFollowsAt) {
|
||||
Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
|
||||
//Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}")
|
||||
user.updateFollows(
|
||||
event.follows.map {
|
||||
try {
|
||||
@@ -140,18 +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
|
||||
)
|
||||
|
||||
user.lastestContactList = event
|
||||
}
|
||||
try {
|
||||
if (event.content.isNotEmpty()) {
|
||||
val relays: Map<String, ContactListEvent.ReadWrite> =
|
||||
Event.gson.fromJson(
|
||||
event.content,
|
||||
object : TypeToken<Map<String, ContactListEvent.ReadWrite>>() {}.type
|
||||
)
|
||||
user.updateRelays(relays)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
refreshObservers()
|
||||
user.latestContactList = event
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PrivateDmEvent) {
|
||||
@@ -188,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()
|
||||
@@ -201,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
|
||||
@@ -231,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") {
|
||||
@@ -243,13 +256,30 @@ 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) {
|
||||
oldChannel.updateChannelInfo(event.channelInfo, event.createdAt)
|
||||
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
|
||||
}
|
||||
@@ -260,8 +290,17 @@ object LocalCache {
|
||||
|
||||
// new event
|
||||
val oldChannel = getOrCreateChannel(event.channel)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
if (event.createdAt > oldChannel.updatedMetadataAt) {
|
||||
oldChannel.updateChannelInfo(event.channelInfo, event.createdAt)
|
||||
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)}")
|
||||
}
|
||||
@@ -279,7 +318,12 @@ object LocalCache {
|
||||
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
val mentions = Collections.synchronizedList(event.mentions.map { getOrCreateUser(decodePublicKey(it)) })
|
||||
val replyTo = Collections.synchronizedList(event.replyTos.map { getOrCreateNote(it) }.toMutableList())
|
||||
val replyTo = Collections.synchronizedList(
|
||||
event.replyTos
|
||||
.map { channel.getOrCreateNote(it) }
|
||||
.filter { it.event !is ChannelCreateEvent }
|
||||
.toMutableList()
|
||||
)
|
||||
|
||||
note.channel = channel
|
||||
note.loadEvent(event, author, mentions, replyTo)
|
||||
@@ -288,10 +332,10 @@ object LocalCache {
|
||||
|
||||
// Adds notifications to users.
|
||||
mentions.forEach {
|
||||
it.taggedPosts.add(note)
|
||||
it.addTaggedPost(note)
|
||||
}
|
||||
replyTo.forEach {
|
||||
it.author?.taggedPosts?.add(note)
|
||||
it.author?.addTaggedPost(note)
|
||||
}
|
||||
|
||||
// Counts the replies
|
||||
@@ -299,8 +343,6 @@ object LocalCache {
|
||||
it.addReply(note)
|
||||
}
|
||||
|
||||
UrlCachedPreviewer.preloadPreviewsFor(note)
|
||||
|
||||
refreshObservers()
|
||||
}
|
||||
|
||||
@@ -312,6 +354,30 @@ object LocalCache {
|
||||
|
||||
}
|
||||
|
||||
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) ?: 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.
|
||||
val live: LocalCacheLiveData = LocalCacheLiveData(this)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.toShortenHex
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +35,8 @@ 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>) {
|
||||
@@ -40,7 +45,7 @@ class Note(val idHex: String) {
|
||||
this.mentions = mentions
|
||||
this.replyTo = replyTo
|
||||
|
||||
refreshObservers()
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
@@ -72,54 +77,68 @@ class Note(val idHex: String) {
|
||||
|
||||
fun addReply(note: Note) {
|
||||
if (replies.add(note))
|
||||
refreshObservers()
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (boosts.add(note))
|
||||
refreshObservers()
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (reactions.add(note))
|
||||
refreshObservers()
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
fun addReport(note: Note) {
|
||||
if (reports.add(note))
|
||||
invalidateData()
|
||||
}
|
||||
|
||||
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()
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = false
|
||||
@Synchronized
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting) return
|
||||
|
||||
handlerWaiting = true
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
delay(100)
|
||||
live.refresh()
|
||||
handlerWaiting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class 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,6 +8,10 @@ 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>()
|
||||
@@ -18,16 +22,19 @@ object UrlCachedPreviewer {
|
||||
return
|
||||
}
|
||||
|
||||
BahaUrlPreview(url, object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache.put(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
}
|
||||
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) {
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}).fetchUrlPreview()
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
|
||||
fun findUrlsInMessage(message: String): List<String> {
|
||||
|
||||
@@ -2,10 +2,19 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
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()
|
||||
@@ -16,14 +25,17 @@ class User(val pubkey: ByteArray) {
|
||||
var updatedMetadataAt: Long = 0;
|
||||
var updatedFollowsAt: Long = 0;
|
||||
|
||||
var lastestContactList: ContactListEvent? = null
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
val notes = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||
val follows = Collections.synchronizedSet(mutableSetOf<User>())
|
||||
|
||||
val taggedPosts = Collections.synchronizedSet(mutableSetOf<Note>())
|
||||
|
||||
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 {
|
||||
@@ -43,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
|
||||
@@ -64,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,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
|
||||
}
|
||||
@@ -7,7 +7,7 @@ object Constants {
|
||||
Relay("wss://nostr.bitcoiner.social", read = true, write = true),
|
||||
Relay("wss://relay.nostr.bg", read = true, write = true),
|
||||
Relay("wss://brb.io", read = true, write = true),
|
||||
Relay("wss://nostr.v0l.io", read = true, write = true),
|
||||
Relay("wss://relay.snort.social", read = true, write = true),
|
||||
Relay("wss://nostr.rocks", read = true, write = true),
|
||||
Relay("wss://relay.damus.io", read = true, write = true),
|
||||
Relay("wss://nostr.fmt.wiz.biz", read = true, write = true),
|
||||
|
||||
@@ -13,64 +13,46 @@ import nostr.postr.events.TextNoteEvent
|
||||
object NostrAccountDataSource: NostrDataSource<Note>("AccountData") {
|
||||
lateinit var account: Account
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.observeForever(cacheListener)
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.removeObserver(cacheListener)
|
||||
}
|
||||
|
||||
fun 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 }
|
||||
}
|
||||
}
|
||||
@@ -1,31 +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(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(channel?.idHex).filterNotNull()),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
|
||||
)
|
||||
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?.sortedBy { it.event!!.createdAt } ?: emptyList()
|
||||
return channel?.notes?.values?.filter { account.isAcceptable(it) }?.sortedBy { it.event?.createdAt } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.filter = createMessagesToChannelFilter()
|
||||
messagesChannel.filter = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,13 @@ object NostrChatRoomDataSource: NostrDataSource<Note>("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 } ?: emptyList()
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
incomingChannel.filter = createMessagesToMeFilter()
|
||||
outgoingChannel.filter = createMessagesFromMeFilter()
|
||||
incomingChannel.filter = listOf(createMessagesToMeFilter()).ifEmpty { null }
|
||||
outgoingChannel.filter = listOf(createMessagesFromMeFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
+43
-18
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -21,21 +22,35 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("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 createMyChannelsInfoFilter() = JsonFilter(
|
||||
kinds = listOf(ChannelMetadataEvent.kind),
|
||||
tags = mapOf("e" to 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 createMessagesToMyChannelsFilter() = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to account.followingChannels.toList()),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 1), // 24 hours
|
||||
)
|
||||
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()
|
||||
@@ -44,27 +59,37 @@ object NostrChatroomListDataSource: NostrDataSource<Note>("MailBoxFeed") {
|
||||
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) }
|
||||
|
||||
val privateMessages = messagingWith.mapNotNull {
|
||||
messages[it]?.sortedBy { it.event?.createdAt }?.last { it.event != null }
|
||||
messages[it]?.sortedBy { it.event?.createdAt }?.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
val publicChannels = account.followingChannels().map {
|
||||
it.notes.values.sortedBy { it.event?.createdAt }.last { it.event != null }
|
||||
it.notes.values.filter { account.isAcceptable(it) }.sortedBy { it.event?.createdAt }.lastOrNull { it.event != null }
|
||||
}
|
||||
|
||||
return (privateMessages + publicChannels).sortedBy { it.event?.createdAt }.reversed()
|
||||
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()
|
||||
myChannelsChannel.filter = createMyChannelsFilter()
|
||||
myChannelsInfoChannel.filter = createMyChannelsInfoFilter()
|
||||
myChannelsMessagesChannel.filter = createMessagesToMyChannelsFilter()
|
||||
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,6 +1,10 @@
|
||||
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
|
||||
@@ -11,6 +15,13 @@ 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
|
||||
@@ -23,9 +34,25 @@ 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)
|
||||
@@ -89,7 +116,23 @@ abstract class NostrDataSource<T>(val debugName: String) {
|
||||
}
|
||||
|
||||
fun loadTop(): List<T> {
|
||||
return feed().take(100)
|
||||
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 {
|
||||
@@ -105,11 +148,32 @@ abstract class NostrDataSource<T>(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()
|
||||
|
||||
@@ -123,12 +187,12 @@ abstract class NostrDataSource<T>(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 {
|
||||
@@ -136,8 +200,8 @@ abstract class NostrDataSource<T>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +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<Note>("GlobalFeed") {
|
||||
val fifteenMinutes = (60*15) // 15 mins
|
||||
|
||||
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,8 +3,12 @@ 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
|
||||
@@ -12,35 +16,39 @@ import nostr.postr.toHex
|
||||
object NostrHomeDataSource: NostrDataSource<Note>("HomeFeed") {
|
||||
lateinit var account: Account
|
||||
|
||||
private val cacheListener: (UserState) -> Unit = {
|
||||
resetFilters()
|
||||
object cacheListener: User.Listener() {
|
||||
override fun onFollowsChange() {
|
||||
resetFilters()
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.observeForever(cacheListener)
|
||||
account.userProfile().subscribe(cacheListener)
|
||||
super.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
super.stop()
|
||||
if (this::account.isInitialized)
|
||||
account.userProfile().live.removeObserver(cacheListener)
|
||||
account.userProfile().unsubscribe(cacheListener)
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): JsonFilter? {
|
||||
val follows = 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<Note>("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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-30
@@ -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<Note>("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 }
|
||||
}
|
||||
}
|
||||
+62
@@ -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
|
||||
}
|
||||
}
|
||||
+22
-16
@@ -9,10 +9,10 @@ import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
object NostrSingleEventDataSource: NostrDataSource<Note>("SingleEventFeed") {
|
||||
val eventsToWatch = Collections.synchronizedList(mutableListOf<String>())
|
||||
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<Note>("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<Note>("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<Note>("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<Note>("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<Note>("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,20 +2,24 @@ 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<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)
|
||||
)
|
||||
}
|
||||
@@ -40,14 +44,16 @@ object NostrThreadDataSource: NostrDataSource<Note>("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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -8,6 +9,7 @@ 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) {
|
||||
@@ -19,7 +21,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
||||
return JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7)
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +29,7 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
||||
return JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 4)
|
||||
limit = 100
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,11 +37,15 @@ object NostrUserProfileDataSource: NostrDataSource<Note>("UserProfileFeed") {
|
||||
val notesChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return user?.notes?.sortedBy { it.event?.createdAt }?.reversed() ?: emptyList()
|
||||
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 = createUserInfoFilter()
|
||||
notesChannel.filter = createUserPostsFilter()
|
||||
userInfoChannel.filter = listOf(createUserInfoFilter()).ifEmpty { null }
|
||||
notesChannel.filter = listOf(createUserPostsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFollowerFeed") {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
@@ -15,17 +17,20 @@ object NostrUserProfileFollowersDataSource: NostrDataSource<User>("UserProfileFo
|
||||
|
||||
fun createFollowersFilter() = JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 7 days
|
||||
tags = mapOf("p" to listOf(user!!.pubkeyHex).filterNotNull())
|
||||
tags = mapOf("p" to listOf(user!!.pubkeyHex))
|
||||
)
|
||||
|
||||
val followerChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.followers?.toList() ?: emptyList()
|
||||
val followers = user?.followers ?: emptyList()
|
||||
|
||||
return synchronized(followers) {
|
||||
followers.filter { account.isAcceptable(it) }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followerChannel.filter = createFollowersFilter()
|
||||
followerChannel.filter = listOf(createFollowersFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -1,11 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import nostr.postr.JsonFilter
|
||||
import nostr.postr.events.ContactListEvent
|
||||
|
||||
object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFollowsFeed") {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
@@ -17,17 +19,21 @@ object NostrUserProfileFollowsDataSource: NostrDataSource<User>("UserProfileFoll
|
||||
return JsonFilter(
|
||||
kinds = listOf(ContactListEvent.kind),
|
||||
authors = listOf(user!!.pubkeyHex),
|
||||
since = System.currentTimeMillis() / 1000 - (60 * 60 * 24 * 7), // 4 days
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
|
||||
val followChannel = requestNewChannel()
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.follows?.toList() ?: emptyList()
|
||||
val follows = user?.follows ?: emptyList()
|
||||
|
||||
return synchronized(follows) {
|
||||
follows.filter { account.isAcceptable(it) }.toList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
followChannel.filter = createFollowFilter()
|
||||
followChannel.filter = listOf(createFollowFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,14 @@ class ChannelMetadataEvent (
|
||||
companion object {
|
||||
const val kind = 41
|
||||
|
||||
fun create(newChannelInfo: ChannelCreateEvent.ChannelData?, originalChannel: ChannelCreateEvent, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMetadataEvent {
|
||||
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", originalChannel.id.toHex(), "", "root") )
|
||||
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)
|
||||
|
||||
@@ -21,7 +21,7 @@ class ChannelMuteUserEvent (
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 43
|
||||
const val kind = 44
|
||||
|
||||
fun create(reason: String, usersToMute: List<String>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMuteUserEvent {
|
||||
val content = reason
|
||||
|
||||
@@ -25,6 +25,10 @@ class ReactionEvent (
|
||||
companion object {
|
||||
const val kind = 7
|
||||
|
||||
fun createWarning(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("\u26A0\uFE0F", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
fun createLike(originalNote: Event, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ReactionEvent {
|
||||
return create("+", originalNote, privateKey, createdAt)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,9 @@ import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.KeyStorage
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.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.EncryptedStorage
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
@@ -43,7 +34,9 @@ class MainActivity : ComponentActivity() {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
}.build()
|
||||
}
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
setContent {
|
||||
@@ -52,7 +45,7 @@ class MainActivity : ComponentActivity() {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
|
||||
val accountViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel(KeyStorage().encryptedPreferences(applicationContext))
|
||||
AccountStateViewModel(LocalPreferences(applicationContext))
|
||||
}
|
||||
|
||||
AccountScreen(accountViewModel)
|
||||
@@ -65,23 +58,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Client.connect()
|
||||
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
NostrAccountDataSource.stop()
|
||||
NostrHomeDataSource.stop()
|
||||
NostrChatroomListDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrUserProfileFollowersDataSource.stop()
|
||||
NostrUserProfileFollowsDataSource.stop()
|
||||
ServiceManager.pause()
|
||||
|
||||
NostrGlobalDataSource.stop()
|
||||
NostrNotificationDataSource.stop()
|
||||
NostrSingleEventDataSource.stop()
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
Client.disconnect()
|
||||
super.onPause()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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
|
||||
|
||||
@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
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
),
|
||||
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,19 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.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.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
@@ -18,6 +23,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 +35,40 @@ 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.input.KeyboardCapitalization
|
||||
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 +111,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 +152,53 @@ fun NewPostView(onClose: () -> Unit, replyingTo: Note? = null, account: Account)
|
||||
.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent
|
||||
)
|
||||
),
|
||||
visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
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 +224,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 +241,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("lud06", 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,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
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,10 @@ fun RichTextViewer(content: String, tags: List<List<String>>?, note: Note, accou
|
||||
} 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) {
|
||||
|
||||
@@ -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)
|
||||
@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 ZoomableImageView(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,18 @@ 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
|
||||
) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,36 +24,58 @@ 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
|
||||
|
||||
@Composable
|
||||
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
|
||||
|
||||
when (currentRoute(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)
|
||||
.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
|
||||
) {
|
||||
|
||||
@@ -37,6 +37,7 @@ 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
|
||||
@@ -49,7 +50,10 @@ fun DrawerContent(navController: NavHostController,
|
||||
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(
|
||||
@@ -64,14 +68,18 @@ fun DrawerContent(navController: NavHostController,
|
||||
model = banner,
|
||||
contentDescription = "Profile Image",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth().height(150.dp)
|
||||
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)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -110,8 +118,10 @@ fun ProfileContent(accountUser: User?, modifier: Modifier = Modifier, scaffoldSt
|
||||
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)
|
||||
.width(100.dp)
|
||||
.height(100.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.border(3.dp, MaterialTheme.colors.background, CircleShape)
|
||||
.background(MaterialTheme.colors.background)
|
||||
@@ -152,6 +162,8 @@ fun ListContent(
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountStateViewModel
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = modifier) {
|
||||
LazyColumn() {
|
||||
item {
|
||||
@@ -168,14 +180,19 @@ fun ListContent(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
Column(modifier = modifier.padding(horizontal = 25.dp)) {
|
||||
Text(
|
||||
text = "Settings",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = W500
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = { accountViewModel.logOff() }),
|
||||
) {
|
||||
Row(modifier = Modifier.clickable(onClick = {
|
||||
navController.navigate(Route.Filters.route)
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
})) {
|
||||
Text(
|
||||
text = "Security Filters",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = W500
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.clickable(onClick = { accountViewModel.logOff() })) {
|
||||
Text(
|
||||
text = "Log out",
|
||||
modifier = Modifier.padding(vertical = 15.dp),
|
||||
@@ -205,7 +222,8 @@ fun NavigationRow(navController: NavHostController, scaffoldState: ScaffoldState
|
||||
})
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 15.dp, horizontal = 25.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
@@ -10,9 +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
|
||||
@@ -25,31 +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 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, nav -> { ProfileScreen(it.arguments?.getString("id"), acc, nav) }}
|
||||
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, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, nav) }}
|
||||
buildScreen = { acc, accSt, nav -> { ChannelScreen(it.arguments?.getString("id"), acc, accSt, nav) }}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,7 +67,8 @@ val Routes = listOf(
|
||||
Route.Profile,
|
||||
Route.Note,
|
||||
Route.Room,
|
||||
Route.Channel
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -21,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
|
||||
@@ -67,6 +68,7 @@ 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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -16,21 +17,27 @@ 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.font.FontWeight
|
||||
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.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
|
||||
import nostr.postr.events.TextNoteEvent
|
||||
|
||||
@Composable
|
||||
fun ChatroomCompose(baseNote: Note, 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
|
||||
|
||||
if (note?.event == null) {
|
||||
@@ -42,52 +49,31 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
val channelState by note.channel!!.live.observeAsState()
|
||||
val channel = channelState?.channel
|
||||
|
||||
Column(modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { navController.navigate("Channel/${channel?.idHex}") }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp)
|
||||
) {
|
||||
|
||||
AsyncImage(
|
||||
model = channel?.profilePicture(),
|
||||
contentDescription = "Public Channel Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${channel?.info?.name}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Text(
|
||||
timeAgo(note.event?.createdAt),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
if (eventContent != null)
|
||||
RichTextViewer("${author?.toBestDisplayName()}: " + 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
|
||||
)
|
||||
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
|
||||
)
|
||||
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 {
|
||||
@@ -107,51 +93,80 @@ fun ChatroomCompose(baseNote: Note, accountViewModel: AccountViewModel, navContr
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { navController.navigate("Room/${userToComposeOn?.pubkeyHex}") }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp)
|
||||
) {
|
||||
|
||||
AsyncImage(
|
||||
model = userToComposeOn?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (userToComposeOn != null)
|
||||
UserDisplay(userToComposeOn)
|
||||
|
||||
Text(
|
||||
timeAgo(note.event?.createdAt),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
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)
|
||||
) {
|
||||
|
||||
AsyncImage(
|
||||
model = channelPicture,
|
||||
placeholder = channelPicturePlaceholder,
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
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(channelLastTime),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (channelLastContent != null)
|
||||
Text(
|
||||
channelLastContent,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
else
|
||||
Text(
|
||||
"Referenced event not found",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.52f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
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
|
||||
@@ -30,23 +32,32 @@ 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) }
|
||||
@@ -57,56 +68,61 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
val authorState by note.author!!.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Column() {
|
||||
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
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = alignment,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 5.dp,
|
||||
bottom = 5.dp
|
||||
).combinedClickable(
|
||||
onClick = { },
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
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),
|
||||
) {
|
||||
|
||||
if (author != accountUser && note.event is ChannelMessageEvent) {
|
||||
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = alignment
|
||||
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)
|
||||
.width(25.dp)
|
||||
.height(25.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable(onClick = {
|
||||
author?.let {
|
||||
@@ -127,45 +143,74 @@ fun ChatroomMessageCompose(baseNote: Note, accountViewModel: AccountViewModel, n
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val eventContent = accountViewModel.decrypt(note)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (eventContent != null)
|
||||
RichTextViewer(
|
||||
eventContent,
|
||||
note.event?.tags,
|
||||
note,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
else
|
||||
RichTextViewer(
|
||||
"Could Not decrypt the message",
|
||||
note.event?.tags,
|
||||
note,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
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,
|
||||
note,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
else
|
||||
RichTextViewer(
|
||||
"Could Not decrypt the message",
|
||||
note.event?.tags,
|
||||
note,
|
||||
accountViewModel,
|
||||
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)
|
||||
}
|
||||
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,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
|
||||
@@ -31,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) {
|
||||
@@ -67,6 +68,7 @@ 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).height(35.dp)
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -27,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
|
||||
@@ -44,20 +48,39 @@ 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}")
|
||||
} else {
|
||||
note.channel?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true },
|
||||
)
|
||||
) {
|
||||
@@ -75,6 +98,7 @@ 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).height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
@@ -91,6 +115,7 @@ 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).height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
@@ -107,21 +132,16 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)
|
||||
.clickable(onClick = {
|
||||
note?.let {
|
||||
navController.navigate("Note/${note.idHex}")
|
||||
}
|
||||
})
|
||||
) {
|
||||
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(
|
||||
@@ -133,7 +153,11 @@ 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) {
|
||||
@@ -161,7 +185,9 @@ fun NoteCompose(baseNote: Note, modifier: Modifier = Modifier, isInnerNote: Bool
|
||||
if (eventContent != null)
|
||||
RichTextViewer(eventContent, note.event?.tags, note, accountViewModel, navController)
|
||||
|
||||
ReactionsRowState(note, accountViewModel)
|
||||
if (note.event !is ChannelMessageEvent) {
|
||||
ReactionsRowState(note, accountViewModel)
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
@@ -179,12 +205,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() }) {
|
||||
@@ -197,5 +224,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,54 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.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(note: Note, account: Account, accountViewModel: AccountViewModel) {
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
var wantsToReplyTo by remember {
|
||||
mutableStateOf<Note?>(null)
|
||||
}
|
||||
@@ -35,105 +56,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 = note }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = grayTint,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(note.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(note) }
|
||||
) {
|
||||
if (note.isBoostedBy(account.userProfile())) {
|
||||
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(note.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(note) }
|
||||
) {
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(24.dp)),
|
||||
onClick = { }
|
||||
) {
|
||||
if (note.isReactedBy(account.userProfile())) {
|
||||
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(note.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/${note.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(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
|
||||
fun showCount(size: Int?): String {
|
||||
|
||||
@@ -16,5 +16,5 @@ fun ReactionsRowState(baseNote: Note, accountViewModel: AccountViewModel) {
|
||||
|
||||
if (account == null || note == null) return
|
||||
|
||||
ReactionsRow(note, account, accountViewModel::boost, accountViewModel::reactTo)
|
||||
ReactionsRow(note, account, accountViewModel)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,21 +16,29 @@ import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.FollowButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.ShowUserButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.UnfollowButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
|
||||
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}") }
|
||||
@@ -46,6 +54,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
|
||||
AsyncImage(
|
||||
model = user.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${user.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(55.dp).height(55.dp)
|
||||
@@ -54,20 +63,27 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UserDisplay(user)
|
||||
UsernameDisplay(user)
|
||||
}
|
||||
|
||||
Text(
|
||||
user.info.about?.take(100) ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
user.info.about ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
if (accountState?.account?.userProfile()?.follows?.contains(user) == true) {
|
||||
UnfollowButton { accountState?.account?.unfollow(user) }
|
||||
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 { accountState?.account?.follow(user) }
|
||||
FollowButton { account?.follow(user) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
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
|
||||
@@ -13,22 +16,28 @@ import com.vitorpamplona.amethyst.service.NostrNotificationDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.bechToBytes
|
||||
import nostr.postr.toHex
|
||||
|
||||
class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPreferences): ViewModel() {
|
||||
class AccountStateViewModel(private val localPreferences: LocalPreferences): ViewModel() {
|
||||
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
|
||||
val accountContent = _accountContent.asStateFlow()
|
||||
|
||||
init {
|
||||
// pulls account from storage.
|
||||
loadFromEncryptedStorage()?.let {
|
||||
localPreferences.loadFromEncryptedStorage()?.let {
|
||||
login(it)
|
||||
}
|
||||
}
|
||||
@@ -48,14 +57,14 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
|
||||
Account(Persona(Hex.decode(key)))
|
||||
}
|
||||
|
||||
saveToEncryptedStorage(account)
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
|
||||
login(account)
|
||||
}
|
||||
|
||||
fun newKey() {
|
||||
val account = Account(Persona())
|
||||
saveToEncryptedStorage(account)
|
||||
localPreferences.saveToEncryptedStorage(account)
|
||||
login(account)
|
||||
}
|
||||
|
||||
@@ -65,53 +74,14 @@ class AccountStateViewModel(private val encryptedPreferences: EncryptedSharedPre
|
||||
else
|
||||
_accountContent.update { AccountState.LoggedInViewOnly ( account ) }
|
||||
|
||||
NostrAccountDataSource.account = account
|
||||
NostrHomeDataSource.account = account
|
||||
NostrNotificationDataSource.account = account
|
||||
NostrChatroomListDataSource.account = account
|
||||
|
||||
NostrAccountDataSource.start()
|
||||
NostrGlobalDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrNotificationDataSource.start()
|
||||
NostrSingleEventDataSource.start()
|
||||
NostrSingleUserDataSource.start()
|
||||
NostrThreadDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start(account)
|
||||
}
|
||||
}
|
||||
|
||||
fun logOff() {
|
||||
_accountContent.update { AccountState.LoggedOff }
|
||||
|
||||
clearEncryptedStorage()
|
||||
}
|
||||
|
||||
fun clearEncryptedStorage() {
|
||||
encryptedPreferences.edit().apply {
|
||||
remove("nostr_privkey")
|
||||
remove("nostr_pubkey")
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun saveToEncryptedStorage(account: Account) {
|
||||
encryptedPreferences.edit().apply {
|
||||
account.loggedIn.privKey?.let { putString("nostr_privkey", it.toHex()) }
|
||||
account.loggedIn.pubKey.let { putString("nostr_pubkey", it.toHex()) }
|
||||
account.followingChannels.let { putStringSet("following_channels", account.followingChannels) }
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun loadFromEncryptedStorage(): Account? {
|
||||
encryptedPreferences.apply {
|
||||
val privKey = getString("nostr_privkey", null)
|
||||
val pubKey = getString("nostr_pubkey", null)
|
||||
val followingChannels = getStringSet("following_channels", DefaultChannels)?.toMutableSet() ?: DefaultChannels.toMutableSet()
|
||||
|
||||
if (pubKey != null) {
|
||||
return Account(Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), followingChannels)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
localPreferences.clearEncryptedStorage()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -24,7 +25,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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,7 +10,11 @@ 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
|
||||
@@ -21,13 +27,13 @@ class CardFeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
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<Note>): 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 {
|
||||
|
||||
@@ -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
|
||||
@@ -30,7 +31,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@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)
|
||||
@@ -39,7 +40,7 @@ fun FeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navCo
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
viewModel.hardRefresh()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,84 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
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 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
|
||||
|
||||
class NostrChannelFeedViewModel: FeedViewModel(NostrChannelDataSource)
|
||||
class NostrChatRoomFeedViewModel: FeedViewModel(NostrChatRoomDataSource)
|
||||
class NostrHomeFeedViewModel: FeedViewModel(NostrHomeDataSource)
|
||||
class NostrGlobalFeedViewModel: FeedViewModel(NostrGlobalDataSource)
|
||||
class NostrThreadFeedViewModel: FeedViewModel(NostrThreadDataSource)
|
||||
class NostrUserProfileFeedViewModel: FeedViewModel(NostrUserProfileDataSource)
|
||||
|
||||
class FeedViewModel(val dataSource: NostrDataSource<Note>): ViewModel() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +90,23 @@ class FeedViewModel(val dataSource: NostrDataSource<Note>): 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 {
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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
|
||||
@@ -40,7 +41,7 @@ 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.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.timeAgoLong
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@@ -176,6 +177,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|
||||
// 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).height(55.dp)
|
||||
@@ -185,7 +187,7 @@ fun NoteMaster(baseNote: Note, accountViewModel: AccountViewModel, navController
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (author != null)
|
||||
UserDisplay(author)
|
||||
UsernameDisplay(author)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -22,7 +23,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
val swipeRefreshState = rememberSwipeRefreshState(isRefreshing)
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
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
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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
|
||||
@@ -22,18 +29,22 @@ class NostrUserProfileFollowersUserFeedViewModel(): UserFeedViewModel(
|
||||
NostrUserProfileFollowersDataSource
|
||||
)
|
||||
|
||||
class NostrHiddenAccountsFeedViewModel(): UserFeedViewModel(
|
||||
NostrHiddenAccountsDataSource
|
||||
)
|
||||
|
||||
open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
// 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 oldNotesState = feedContent.value
|
||||
@@ -47,22 +58,33 @@ open class UserFeedViewModel(val dataSource: NostrDataSource<User>): ViewModel()
|
||||
}
|
||||
|
||||
fun updateFeed(notes: List<User>) {
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { UserFeedState.Empty }
|
||||
} else {
|
||||
_feedContent.update { UserFeedState.Loaded(notes) }
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
if (notes.isEmpty()) {
|
||||
_feedContent.update { UserFeedState.Empty }
|
||||
} else {
|
||||
_feedContent.update { UserFeedState.Loaded(notes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshCurrentList() {
|
||||
val state = feedContent.value
|
||||
if (state is UserFeedState.Loaded) {
|
||||
_feedContent.update { UserFeedState.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 {
|
||||
|
||||
+17
-1
@@ -1,21 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
|
||||
class AccountViewModel(private val account: Account): ViewModel() {
|
||||
val accountLiveData: LiveData<AccountState> = Transformations.map(account.live) { it }
|
||||
val userLiveData: LiveData<UserState> = Transformations.map(account.userProfile().live) { it }
|
||||
|
||||
fun reactTo(note: Note) {
|
||||
account.reactTo(note)
|
||||
}
|
||||
|
||||
fun report(note: Note) {
|
||||
account.report(note)
|
||||
}
|
||||
|
||||
fun boost(note: Note) {
|
||||
account.boost(note)
|
||||
}
|
||||
@@ -27,4 +33,14 @@ class AccountViewModel(private val account: Account): ViewModel() {
|
||||
fun decrypt(note: Note): String? {
|
||||
return account.decryptContent(note)
|
||||
}
|
||||
|
||||
fun hide(user: User, ctx: Context) {
|
||||
account.hideUser(user.pubkeyHex)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
|
||||
fun show(user: User, ctx: Context) {
|
||||
account.showUser(user.pubkeyHex)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -10,36 +9,53 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextField
|
||||
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.platform.ClipboardManager
|
||||
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.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.note.UserDisplay
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
|
||||
@@ -51,95 +67,209 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, navCon
|
||||
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
|
||||
val channel = channelState?.channel ?: return
|
||||
|
||||
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChannelDataSource ) }
|
||||
val feedViewModel: NostrChannelFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
channel?.let {
|
||||
ChannelHeader(
|
||||
it,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
ChannelHeader(
|
||||
channel, account,
|
||||
accountStateViewModel = accountStateViewModel,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxHeight().padding(vertical = 0.dp).weight(1f, true)
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 0.dp)
|
||||
.weight(1f, true)
|
||||
) {
|
||||
ChatroomFeedView(feedViewModel, accountViewModel, navController)
|
||||
}
|
||||
|
||||
//LAST ROW
|
||||
Row(modifier = Modifier.padding(10.dp).fillMaxWidth(),
|
||||
Row(modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
TextField(
|
||||
value = newPost.value,
|
||||
onValueChange = { newPost.value = it },
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier.weight(1f, true).padding(end = 10.dp),
|
||||
modifier = Modifier
|
||||
.weight(1f, true)
|
||||
.padding(end = 10.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "reply here.. ",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
account.sendChannelMeesage(newPost.value.text, channel.idHex)
|
||||
newPost.value = TextFieldValue("")
|
||||
},
|
||||
newPost.value.text.isNotBlank()
|
||||
trailingIcon = {
|
||||
PostButton(
|
||||
onPost = {
|
||||
account.sendChannelMeesage(newPost.value.text, channel.idHex)
|
||||
newPost.value = TextFieldValue("")
|
||||
},
|
||||
newPost.value.text.isNotBlank(),
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ChannelHeader(baseChannel: Channel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel: AccountStateViewModel, navController: NavController) {
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channel = channelState?.channel
|
||||
|
||||
Column(modifier =
|
||||
Modifier
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column() {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
AsyncImage(
|
||||
model = channel?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp).height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
AsyncImage(
|
||||
model = channel?.profilePicture(),
|
||||
placeholder = null,
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp)
|
||||
.height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${channel?.info?.name}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Column(modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${channel?.info?.name}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${channel?.info?.about}",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"${channel?.info?.about}",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
channel?.let { NoteCopyButton(it) }
|
||||
|
||||
channel?.let {
|
||||
if (channel.creator == account.userProfile()) {
|
||||
EditButton(account, it)
|
||||
} else {
|
||||
if (account.followingChannels.contains(channel.idHex)) {
|
||||
LeaveButton(account,channel, navController)
|
||||
} else {
|
||||
JoinButton(account,channel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp),
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun NoteCopyButton(
|
||||
note: Channel
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = { clipboardManager.setText(AnnotatedString(note.id.toNote())) },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
),
|
||||
) {
|
||||
Text(text = "note", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditButton(account: Account, channel: Channel) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost)
|
||||
NewChannelView({ wantsToPost = false }, account = account, channel)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = { wantsToPost = true },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = "Edit", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun JoinButton(account: Account, channel: Channel, navController: NavController) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
account.joinChannel(channel.idHex)
|
||||
LocalPreferences(context).saveToEncryptedStorage(account)
|
||||
navController.navigate(Route.Message.route)
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = "Join", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LeaveButton(account: Account, channel: Channel, navController: NavController) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
account.leaveChannel(channel.idHex)
|
||||
LocalPreferences(context).saveToEncryptedStorage(account)
|
||||
navController.navigate(Route.Message.route)
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = "Leave", color = Color.White)
|
||||
}
|
||||
}
|
||||
+75
-9
@@ -3,30 +3,96 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Tab
|
||||
import androidx.compose.material.TabRow
|
||||
import androidx.compose.material.TabRowDefaults
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account by accountViewModel.accountLiveData.observeAsState()
|
||||
val pagerState = rememberPagerState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
if (account != null) {
|
||||
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatroomListDataSource ) }
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
},
|
||||
) {
|
||||
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
|
||||
text = {
|
||||
Text(text = "Known")
|
||||
}
|
||||
)
|
||||
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
|
||||
text = {
|
||||
Text(text = "New Requests")
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalPager(count = 2, state = pagerState) {
|
||||
when (pagerState.currentPage) {
|
||||
0 -> TabKnown(accountViewModel, navController)
|
||||
1 -> TabNew(accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabKnown(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
|
||||
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNew(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
ChatroomListFeedView(feedViewModel, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextField
|
||||
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
|
||||
@@ -29,10 +30,11 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrChatRoomDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
import com.vitorpamplona.amethyst.ui.note.UserDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
@@ -45,7 +47,11 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
|
||||
NostrChatRoomDataSource.loadMessagesBetween(account, userId)
|
||||
|
||||
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrChatRoomDataSource ) }
|
||||
val feedViewModel: NostrChatRoomFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
NostrChatRoomDataSource.withUser?.let {
|
||||
@@ -67,27 +73,29 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
TextField(
|
||||
value = newPost.value,
|
||||
onValueChange = { newPost.value = it },
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
modifier = Modifier.weight(1f, true).padding(end = 10.dp),
|
||||
modifier = Modifier.weight(1f, true),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "reply here.. ",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
account.sendPrivateMeesage(newPost.value.text, userId)
|
||||
newPost.value = TextFieldValue("")
|
||||
},
|
||||
newPost.value.text.isNotBlank()
|
||||
trailingIcon = {
|
||||
PostButton(
|
||||
onPost = {
|
||||
account.sendPrivateMeesage(newPost.value.text, userId)
|
||||
newPost.value = TextFieldValue("")
|
||||
},
|
||||
newPost.value.text.isNotBlank(),
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -100,33 +108,33 @@ fun ChatroomHeader(baseUser: User, accountViewModel: AccountViewModel, navContro
|
||||
val authorState by baseUser.live.observeAsState()
|
||||
val author = authorState?.user
|
||||
|
||||
Column(modifier =
|
||||
Modifier
|
||||
.padding(12.dp)
|
||||
.clickable(
|
||||
onClick = { navController.navigate("User/${author?.pubkeyHex}") }
|
||||
)
|
||||
Column(modifier = Modifier.clickable(
|
||||
onClick = { navController.navigate("User/${author?.pubkeyHex}") }
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp).height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
AsyncImage(
|
||||
model = author?.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${author?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(35.dp).height(35.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (author != null)
|
||||
UserDisplay(author)
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (author != null)
|
||||
UsernameDisplay(author)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 12.dp, start = 12.dp, end = 12.dp),
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Tab
|
||||
import androidx.compose.material.TabRow
|
||||
import androidx.compose.material.TabRowDefaults
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.service.NostrHiddenAccountsDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun FiltersScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
|
||||
if (account != null) {
|
||||
NostrHiddenAccountsDataSource.account = account
|
||||
|
||||
val feedViewModel: NostrHiddenAccountsFeedViewModel = viewModel()
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
|
||||
val pagerState = rememberPagerState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
indicator = { tabPositions ->
|
||||
TabRowDefaults.Indicator(
|
||||
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
},
|
||||
) {
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
|
||||
text = {
|
||||
Text(text = "Blocked Users")
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalPager(count = 1, state = pagerState) {
|
||||
when (pagerState.currentPage) {
|
||||
0 -> UserFeedView(feedViewModel, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,28 +4,35 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import java.lang.System.currentTimeMillis
|
||||
|
||||
@Composable
|
||||
fun HomeScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val feedViewModel: NostrHomeFeedViewModel = viewModel()
|
||||
|
||||
if (accountState != null) {
|
||||
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrHomeDataSource ) }
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vitorpamplona.amethyst.buttons.NewChannelButton
|
||||
import com.vitorpamplona.amethyst.buttons.NewNoteButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
|
||||
@@ -49,7 +50,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
scaffoldState = scaffoldState
|
||||
) {
|
||||
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
|
||||
AppNavigation(navController, accountViewModel)
|
||||
AppNavigation(navController, accountViewModel, accountStateViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,4 +74,20 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRoute(navController) == Route.Message.route) {
|
||||
Crossfade(targetState = accountState) { state ->
|
||||
when (state) {
|
||||
is AccountState.LoggedInViewOnly -> {
|
||||
// Does nothing.
|
||||
}
|
||||
is AccountState.LoggedOff -> {
|
||||
// Does nothing.
|
||||
}
|
||||
is AccountState.LoggedIn -> {
|
||||
NewChannelButton(state.account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -15,18 +16,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account by accountViewModel.accountLiveData.observeAsState()
|
||||
val feedViewModel: CardFeedViewModel = viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
|
||||
|
||||
if (account != null) {
|
||||
val feedViewModel: CardFeedViewModel =
|
||||
viewModel { CardFeedViewModel( NostrNotificationDataSource ) }
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,10 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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
|
||||
@@ -38,6 +41,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -46,36 +50,39 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.pagerTabIndicatorOffset
|
||||
import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.toNote
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.toNpub
|
||||
|
||||
data class TabRowItem(
|
||||
val title: String,
|
||||
val screen: @Composable () -> Unit,
|
||||
)
|
||||
import nostr.postr.toNsec
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by accountViewModel.userLiveData.observeAsState()
|
||||
val accountUserState by account.userProfile().live.observeAsState()
|
||||
val accountUser = accountUserState?.user
|
||||
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val ctx = LocalContext.current.applicationContext
|
||||
|
||||
if (userId != null && account != null && accountUser != null) {
|
||||
if (userId != null && accountUser != null) {
|
||||
DisposableEffect(account) {
|
||||
NostrUserProfileDataSource.loadUserProfile(userId)
|
||||
NostrUserProfileFollowersDataSource.loadUserProfile(userId)
|
||||
@@ -105,14 +112,18 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
model = banner,
|
||||
contentDescription = "Profile Image",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth().height(125.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(125.dp)
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = "Profile Banner",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth().height(125.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(125.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,6 +136,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
verticalAlignment = Alignment.Bottom) {
|
||||
AsyncImage(
|
||||
model = user.profilePicture(),
|
||||
placeholder = rememberAsyncImagePainter("https://robohash.org/${user?.pubkeyHex}.png"),
|
||||
contentDescription = "Profile Image",
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
@@ -138,12 +150,21 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
|
||||
MessageButton(user, navController)
|
||||
|
||||
NPubCopyButton(clipboardManager, user)
|
||||
if (accountUser == user && account.isWriteable()) {
|
||||
NSecCopyButton(account)
|
||||
}
|
||||
|
||||
NPubCopyButton(user)
|
||||
|
||||
if (accountUser == user) {
|
||||
EditButton()
|
||||
EditButton(account)
|
||||
} else {
|
||||
if (accountUser.follows?.contains(user) == true) {
|
||||
if (account?.isAcceptable(user) == false) {
|
||||
ShowUserButton {
|
||||
account.showUser(user.pubkeyHex)
|
||||
LocalPreferences(ctx).saveToEncryptedStorage(account)
|
||||
}
|
||||
} else if (accountUser.isFollowing(user)) {
|
||||
UnfollowButton { account.unfollow(user) }
|
||||
} else {
|
||||
FollowButton { account.follow(user) }
|
||||
@@ -160,7 +181,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
Text(" @${user.bestUsername()}", color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f))
|
||||
Text(
|
||||
"${user.info.about}",
|
||||
color = Color.White,
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
|
||||
)
|
||||
|
||||
@@ -222,7 +243,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
fun TabNotes(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrUserProfileDataSource ) }
|
||||
val feedViewModel: NostrUserProfileFeedViewModel = viewModel()
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
@@ -260,11 +281,31 @@ fun TabFollowers(user: User, accountViewModel: AccountViewModel, navController:
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NSecCopyButton(
|
||||
account: Account
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = { account.loggedIn.privKey?.let { clipboardManager.setText(AnnotatedString(it.toNsec())) } },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
),
|
||||
) {
|
||||
Text(text = "nsec", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NPubCopyButton(
|
||||
clipboardManager: ClipboardManager,
|
||||
user: User
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = { clipboardManager.setText(AnnotatedString(user.pubkey.toNpub())) },
|
||||
@@ -293,16 +334,23 @@ private fun MessageButton(user: User, navController: NavController) {
|
||||
painter = painterResource(R.drawable.ic_dm),
|
||||
"Send a Direct Message",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditButton() {
|
||||
private fun EditButton(account: Account) {
|
||||
var wantsToEdit by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToEdit)
|
||||
NewUserMetadataView({ wantsToEdit = false }, account)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {},
|
||||
onClick = { wantsToEdit = true },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
@@ -343,4 +391,18 @@ fun FollowButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ShowUserButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.padding(start = 3.dp),
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = "Unblock", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,251 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrGlobalDataSource ) }
|
||||
val feedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.refresh()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
SearchBar(accountViewModel, navController)
|
||||
FeedView(feedViewModel, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val searchValue = remember { mutableStateOf(TextFieldValue("")) }
|
||||
val searchResults = remember { mutableStateOf<List<User>>(emptyList()) }
|
||||
val searchResultsNotes = remember { mutableStateOf<List<Note>>(emptyList()) }
|
||||
val searchResultsChannels = remember { mutableStateOf<List<Channel>>(emptyList()) }
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
val isTrailingIconVisible by remember {
|
||||
derivedStateOf {
|
||||
searchValue.value.text.isNotBlank()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
}
|
||||
}
|
||||
|
||||
//LAST ROW
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextField(
|
||||
value = searchValue.value,
|
||||
onValueChange = {
|
||||
searchValue.value = it
|
||||
|
||||
if (it.text.removePrefix("npub").removePrefix("note").length >= 4)
|
||||
onlineSearch.search(it.text)
|
||||
|
||||
searchResults.value = LocalCache.findUsersStartingWith(it.text)
|
||||
searchResultsNotes.value = LocalCache.findNotesStartingWith(it.text)
|
||||
searchResultsChannels.value = LocalCache.findChannelsStartingWith(it.text)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_search),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f, true)
|
||||
.defaultMinSize(minHeight = 20.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "npub, hex, username ",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (isTrailingIconVisible) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
searchValue.value = TextFieldValue("")
|
||||
searchResults.value = emptyList()
|
||||
searchResultsChannels.value = emptyList()
|
||||
searchResultsNotes.value = emptyList()
|
||||
|
||||
onlineSearch.clear()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Clear"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (searchValue.value.text.isNotBlank()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
) {
|
||||
itemsIndexed(searchResults.value, key = { _, item -> "u"+item.pubkeyHex }) { index, item ->
|
||||
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
|
||||
itemsIndexed(searchResultsChannels.value, key = { _, item -> "c"+item.idHex }) { index, item ->
|
||||
ChannelName(
|
||||
channelPicture = item.profilePicture(),
|
||||
channelPicturePlaceholder = null,
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
onClick = { navController.navigate("Channel/${item.idHex}") })
|
||||
}
|
||||
|
||||
itemsIndexed(searchResultsNotes.value, key = { _, item -> "n"+item.idHex }) { index, item ->
|
||||
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserLine(
|
||||
baseUser: User,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val userState by baseUser.live.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
Column(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
) {
|
||||
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?.take(100) ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navControl
|
||||
if (account != null && noteId != null) {
|
||||
NostrThreadDataSource.loadThread(noteId)
|
||||
|
||||
val feedViewModel: FeedViewModel = viewModel { FeedViewModel( NostrThreadDataSource ) }
|
||||
val feedViewModel: NostrThreadFeedViewModel = viewModel()
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
|
||||
@@ -70,7 +70,7 @@ fun LoginPage(accountViewModel: AccountStateViewModel) {
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
|
||||
Image(
|
||||
painterResource(id = R.drawable.amethyst_logo),
|
||||
painterResource(id = R.drawable.amethyst),
|
||||
contentDescription = "App Logo",
|
||||
modifier = Modifier.size(300.dp),
|
||||
contentScale = ContentScale.Inside
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,30 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user