mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
705309cb8a | ||
|
|
89bdc9dd58 | ||
|
|
ac0103a53e | ||
|
|
b2c31d3b46 | ||
|
|
931bd58c51 | ||
|
|
6a7d6a843d | ||
|
|
eaae672a30 | ||
|
|
ccfbfeffe2 | ||
|
|
eb62da8d26 | ||
|
|
d334c6b2e1 | ||
|
|
90f0e4aad1 | ||
|
|
fabf52ffd7 | ||
|
|
c499c4baec | ||
|
|
4bd19c3e3d | ||
|
|
6a47f5aa3b | ||
|
|
03d1754498 | ||
|
|
e9374370b9 | ||
|
|
88a5e309bd | ||
|
|
cff8684347 | ||
|
|
b9654d164f | ||
|
|
c10db10430 | ||
|
|
50f3f7f176 | ||
|
|
49c3334509 | ||
|
|
e75f70985a | ||
|
|
e956f51f19 | ||
|
|
165678f118 | ||
|
|
d32d2da280 | ||
|
|
d6a6a52821 | ||
|
|
dfb697975f | ||
|
|
c0c0fd9e6d | ||
|
|
5bdcdc6f4a | ||
|
|
102ab63673 | ||
|
|
b34c1f98d5 | ||
|
|
74b05e3d6a | ||
|
|
972bce426a | ||
|
|
3de57d6ae2 | ||
|
|
5695a82c20 | ||
|
|
e171120836 | ||
|
|
abdfe531f7 | ||
|
|
ca90866877 | ||
|
|
450fa2b70d | ||
|
|
93c9ffeb02 | ||
|
|
2e5e546e7d | ||
|
|
00e7add001 | ||
|
|
e28cd2212e | ||
|
|
8f38353271 | ||
|
|
a41ee653d8 | ||
|
|
839be06f62 | ||
|
|
bbef06de59 | ||
|
|
515159f18c | ||
|
|
e7b7ebbbdf | ||
|
|
5965d9a3bb | ||
|
|
31959721b1 | ||
|
|
8e16daaaa3 | ||
|
|
e9fd47a536 | ||
|
|
6191ef9a6b | ||
|
|
05aa12ebac | ||
|
|
42428c5e0f | ||
|
|
1a3685c5ab | ||
|
|
fff892e1c8 | ||
|
|
99c30816c6 | ||
|
|
92c61f317b | ||
|
|
b0e50e0c00 | ||
|
|
f065dcfc5a | ||
|
|
91f34000f5 | ||
|
|
93e2af3c1f | ||
|
|
6072e5977e | ||
|
|
8575ea018d | ||
|
|
ed919ae531 | ||
|
|
3eb832c4e0 | ||
|
|
a57ca2a79d | ||
|
|
07a681c002 | ||
|
|
cb561d432c | ||
|
|
d942c12627 | ||
|
|
a466f33b4e | ||
|
|
3744f90e87 | ||
|
|
1cc0f4bf80 | ||
|
|
dec409c209 | ||
|
|
c664397b11 | ||
|
|
bc1b71e9bf | ||
|
|
a42606af0d | ||
|
|
b301e1d79b | ||
|
|
5ab94e8a1c | ||
|
|
7ad982ec71 | ||
|
|
030064e112 | ||
|
|
d9a392d2c1 | ||
|
|
97cd1c5e30 | ||
|
|
24a6d8a155 | ||
|
|
ea185147b3 | ||
|
|
f2db2b88c2 | ||
|
|
6ea0972151 | ||
|
|
093705f3b6 | ||
|
|
1972297755 | ||
|
|
ac00694690 |
@@ -13,13 +13,14 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 186
|
||||
versionName "0.53.2"
|
||||
versionCode 199
|
||||
versionName "0.55.4"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary true
|
||||
}
|
||||
resConfigs("ar", "cs", "eo", "es", "fa", "fr", "hu", "night", "nl", "pt-rBR", "ru", "sv-rSE", "ta", "tr", "uk", "zh", "sh-rHK", "zh-rTW" )
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -128,7 +129,7 @@ dependencies {
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11'
|
||||
|
||||
// Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both)
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.1'
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
|
||||
// link preview
|
||||
@@ -140,6 +141,8 @@ dependencies {
|
||||
|
||||
// view videos
|
||||
implementation 'com.google.android.exoplayer:exoplayer:2.18.7'
|
||||
// important for proxy / tor
|
||||
implementation 'com.google.android.exoplayer:extension-okhttp:2.18.7'
|
||||
|
||||
// Load images from the web.
|
||||
implementation "io.coil-kt:coil-compose:$coil_version"
|
||||
@@ -151,8 +154,6 @@ dependencies {
|
||||
// create blurhash
|
||||
implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
|
||||
|
||||
// Rendering clickable text
|
||||
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
|
||||
// Permission to upload pictures:
|
||||
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
|
||||
|
||||
@@ -175,7 +176,7 @@ dependencies {
|
||||
playImplementation 'com.google.mlkit:translate:17.0.1'
|
||||
|
||||
// PushNotifications
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
|
||||
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
|
||||
playImplementation 'com.google.firebase:firebase-messaging-ktx'
|
||||
|
||||
// Charts
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
@@ -10,7 +11,7 @@ fun TranslatableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
android:largeHeap="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
tools:targetApi="33">
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -30,6 +31,7 @@ import java.util.Locale
|
||||
private const val DEBUG_PLAINTEXT_PREFERENCES = false
|
||||
private const val DEBUG_PREFERENCES_NAME = "debug_prefs"
|
||||
|
||||
@Immutable
|
||||
data class AccountInfo(
|
||||
val npub: String,
|
||||
val hasPrivKey: Boolean = false
|
||||
|
||||
@@ -39,14 +39,14 @@ object NotificationCache {
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NotificationState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
@@ -19,14 +26,33 @@ import com.vitorpamplona.amethyst.service.relays.Client
|
||||
object ServiceManager {
|
||||
private var account: Account? = null
|
||||
|
||||
fun start(account: Account) {
|
||||
fun start(account: Account, context: Context) {
|
||||
this.account = account
|
||||
start()
|
||||
start(context)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
fun start(context: Context) {
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClient.start(account)
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(context).components {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(context.applicationContext)
|
||||
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import android.content.Context
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
|
||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor
|
||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
object VideoCache {
|
||||
|
||||
@@ -31,7 +32,14 @@ object VideoCache {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
} else {
|
||||
cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(HttpClient.getHttpClient())
|
||||
)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
@@ -89,10 +89,6 @@ class Account(
|
||||
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
fun hiddenUsers(): List<User> {
|
||||
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
|
||||
}
|
||||
|
||||
fun isWriteable(): Boolean {
|
||||
return loggedIn.privKey != null
|
||||
}
|
||||
@@ -567,16 +563,15 @@ class Account(
|
||||
LocalCache.consume(signedEvent, null)
|
||||
}
|
||||
|
||||
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
|
||||
fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) {
|
||||
if (!isWriteable()) return
|
||||
val user = LocalCache.users[toUser] ?: return
|
||||
|
||||
val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
|
||||
val signedEvent = PrivateDmEvent.create(
|
||||
recipientPubKey = user.pubkey(),
|
||||
publishedRecipientPubKey = user.pubkey(),
|
||||
recipientPubKey = toUser.pubkey(),
|
||||
publishedRecipientPubKey = toUser.pubkey(),
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
@@ -1004,10 +999,10 @@ class Account(
|
||||
}
|
||||
|
||||
private fun updateContactListTo(newContactList: ContactListEvent?) {
|
||||
if (newContactList?.unverifiedFollowKeySet().isNullOrEmpty()) return
|
||||
if (newContactList == null || newContactList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupContactList?.id != newContactList?.id) {
|
||||
if (backupContactList?.id != newContactList.id) {
|
||||
backupContactList = newContactList
|
||||
saveable.invalidateData()
|
||||
}
|
||||
@@ -1135,7 +1130,9 @@ class Account(
|
||||
|
||||
// saves contact list for the next time.
|
||||
userProfile().live().follows.observeForever {
|
||||
updateContactListTo(userProfile().latestContactList)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
updateContactListTo(userProfile().latestContactList)
|
||||
}
|
||||
}
|
||||
|
||||
// imports transient blocks due to spam.
|
||||
@@ -1166,14 +1163,14 @@ class Account(
|
||||
|
||||
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Default) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Default)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,6 +18,8 @@ class AntiSpamFilter {
|
||||
|
||||
@Synchronized
|
||||
fun isSpam(event: Event, relay: Relay?): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
val idHex = event.id
|
||||
|
||||
// if short message, ok
|
||||
@@ -28,6 +32,7 @@ class AntiSpamFilter {
|
||||
val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode()
|
||||
|
||||
if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) {
|
||||
Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}")
|
||||
Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}")
|
||||
|
||||
// Log down offenders
|
||||
@@ -55,14 +60,14 @@ class AntiSpamFilter {
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(AntiSpamState(cache))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
@@ -9,6 +10,7 @@ import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Stable
|
||||
class Channel(val idHex: String) {
|
||||
var creator: User? = null
|
||||
var info = ChannelCreateEvent.ChannelData(null, null, null)
|
||||
@@ -33,6 +35,10 @@ class Channel(val idHex: String) {
|
||||
notes.remove(note.idHex)
|
||||
}
|
||||
|
||||
fun removeNote(noteHex: String) {
|
||||
notes.remove(noteHex)
|
||||
}
|
||||
|
||||
fun updateChannelInfo(creator: User, channelInfo: ChannelCreateEvent.ChannelData, updatedAt: Long) {
|
||||
this.creator = creator
|
||||
this.info = channelInfo
|
||||
@@ -74,14 +80,14 @@ class Channel(val idHex: String) {
|
||||
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
@@ -13,7 +12,6 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
@@ -23,12 +21,6 @@ import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object LocalCache {
|
||||
val metadataParser by lazy {
|
||||
jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
}
|
||||
|
||||
val antiSpam = AntiSpamFilter()
|
||||
|
||||
val users = ConcurrentHashMap<HexKey, User>(5000)
|
||||
@@ -40,6 +32,8 @@ object LocalCache {
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateUser(key)
|
||||
}
|
||||
@@ -48,6 +42,8 @@ object LocalCache {
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateUser(key: HexKey): User {
|
||||
// checkNotInMainThread()
|
||||
|
||||
return users[key] ?: run {
|
||||
val answer = User(key)
|
||||
users.put(key, answer)
|
||||
@@ -56,6 +52,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun checkGetOrCreateNote(key: String): Note? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (ATag.isATag(key)) {
|
||||
return checkGetOrCreateAddressableNote(key)
|
||||
}
|
||||
@@ -67,6 +65,8 @@ object LocalCache {
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateNote(idHex: String): Note {
|
||||
checkNotInMainThread()
|
||||
|
||||
return notes[idHex] ?: run {
|
||||
val answer = Note(idHex)
|
||||
notes.put(idHex, answer)
|
||||
@@ -75,6 +75,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun checkGetOrCreateChannel(key: String): Channel? {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (isValidHexNpub(key)) {
|
||||
return getOrCreateChannel(key)
|
||||
}
|
||||
@@ -93,6 +95,8 @@ object LocalCache {
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateChannel(key: String): Channel {
|
||||
checkNotInMainThread()
|
||||
|
||||
return channels[key] ?: run {
|
||||
val answer = Channel(key)
|
||||
channels.put(key, answer)
|
||||
@@ -115,33 +119,35 @@ object LocalCache {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
|
||||
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
|
||||
checkNotInMainThread()
|
||||
|
||||
// we can't use naddr here because naddr might include relay info and
|
||||
// the preferred relay should not be part of the index.
|
||||
return addressables[key.toTag()] ?: run {
|
||||
val answer = AddressableNote(key)
|
||||
answer.author = checkGetOrCreateUser(key.pubKeyHex)
|
||||
addressables.put(key.toTag(), answer)
|
||||
answer
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
|
||||
val note = getOrCreateAddressableNoteInternal(key)
|
||||
// Loads the user outside a Syncronized block to avoid blocking
|
||||
if (note.author == null) {
|
||||
note.author = checkGetOrCreateUser(key.pubKeyHex)
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
fun consume(event: MetadataEvent) {
|
||||
// new event
|
||||
val oldUser = getOrCreateUser(event.pubKey)
|
||||
if (oldUser.info == null || event.createdAt > oldUser.info!!.updatedMetadataAt) {
|
||||
val newUser = try {
|
||||
metadataParser.readValue(
|
||||
ByteArrayInputStream(event.content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} ${event.content}")
|
||||
return
|
||||
val newUserMetadata = event.contactMetaData()
|
||||
if (newUserMetadata != null) {
|
||||
oldUser.updateUserInfo(newUserMetadata, event)
|
||||
}
|
||||
|
||||
oldUser.updateUserInfo(newUser, event)
|
||||
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
@@ -285,12 +291,26 @@ object LocalCache {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: RelaySetEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consume(event: AudioTrackEvent) {
|
||||
@@ -298,11 +318,13 @@ object LocalCache {
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BadgeDefinitionEvent) {
|
||||
@@ -357,6 +379,32 @@ object LocalCache {
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: AppDefinitionEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: AppRecommendationEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun consume(event: RecommendRelayEvent) {
|
||||
// // Log.d("RR", event.toJson())
|
||||
@@ -429,8 +477,10 @@ object LocalCache {
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
val channel = deleteNote.channel()
|
||||
channel?.removeNote(deleteNote)
|
||||
deleteNote.channelHex()?.let {
|
||||
val channel = checkGetOrCreateChannel(it)
|
||||
channel?.removeNote(deleteNote)
|
||||
}
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
@@ -505,15 +555,6 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.content == "!" || // nostr_console hide.
|
||||
event.content == "\u26A0\uFE0F" // Warning sign
|
||||
) {
|
||||
// Counts the replies
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
@@ -541,9 +582,15 @@ object LocalCache {
|
||||
mentions.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
}
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
} else {
|
||||
repliesTo.forEach {
|
||||
it.addReport(note)
|
||||
}
|
||||
|
||||
mentions.forEach {
|
||||
// doesn't add to reports, but triggers recounts
|
||||
it.liveSet?.reports?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
refreshObservers(note)
|
||||
@@ -839,6 +886,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findUsersStartingWith(username: String): List<User> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = decodePublicKeyAsHexOrNull(username)
|
||||
|
||||
if (key != null && users[key] != null) {
|
||||
@@ -853,6 +902,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findNotesStartingWith(text: String): List<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
@@ -878,6 +929,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun findChannelsStartingWith(text: String): List<Channel> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = try {
|
||||
Nip19.uriToRoute(text)?.hex ?: Hex.decode(text).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
@@ -906,6 +959,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
channels.forEach { it ->
|
||||
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
|
||||
|
||||
@@ -924,6 +979,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved = account.hiddenUsers.map {
|
||||
(users[it]?.notes ?: emptySet())
|
||||
}.flatten()
|
||||
@@ -951,6 +1008,8 @@ object LocalCache {
|
||||
}
|
||||
|
||||
fun pruneContactLists(userAccount: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
var removingContactList = 0
|
||||
users.values.forEach {
|
||||
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
|
||||
@@ -969,11 +1028,22 @@ object LocalCache {
|
||||
live.invalidateData(newNote)
|
||||
}
|
||||
|
||||
fun consume(event: Event, relay: Relay?) {
|
||||
if (!event.hasValidSignature()) return
|
||||
fun verifyAndConsume(event: Event, relay: Relay?) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (!event.hasValidSignature()) {
|
||||
try {
|
||||
event.checkSignature()
|
||||
} catch (e: Exception) {
|
||||
Log.w("Event failed retest ${event.kind}", e.message ?: "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
when (event) {
|
||||
is AppDefinitionEvent -> consume(event)
|
||||
is AppRecommendationEvent -> consume(event)
|
||||
is AudioTrackEvent -> consume(event)
|
||||
is BadgeAwardEvent -> consume(event)
|
||||
is BadgeDefinitionEvent -> consume(event)
|
||||
@@ -993,7 +1063,7 @@ object LocalCache {
|
||||
is HighlightEvent -> consume(event, relay)
|
||||
is LnZapEvent -> {
|
||||
event.zapRequest?.let {
|
||||
consume(it, relay)
|
||||
verifyAndConsume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
@@ -1007,10 +1077,11 @@ object LocalCache {
|
||||
is PeopleListEvent -> consume(event)
|
||||
is ReactionEvent -> consume(event)
|
||||
is RecommendRelayEvent -> consume(event)
|
||||
is RelaySetEvent -> consume(event)
|
||||
is ReportEvent -> consume(event, relay)
|
||||
is RepostEvent -> {
|
||||
event.containedPost()?.let {
|
||||
consume(it, relay)
|
||||
verifyAndConsume(it, relay)
|
||||
}
|
||||
consume(event)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
@@ -73,10 +74,6 @@ open class Note(val idHex: String) {
|
||||
?: (event as? ChannelCreateEvent)?.id
|
||||
}
|
||||
|
||||
fun channel(): Channel? {
|
||||
return channelHex()?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
open fun address(): ATag? = null
|
||||
|
||||
open fun createdAt() = event?.createdAt()
|
||||
@@ -183,6 +180,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
@Synchronized
|
||||
fun addZap(zapRequest: Note, zap: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapRequest !in zaps.keys) {
|
||||
zaps = zaps + Pair(zapRequest, zap)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
@@ -194,6 +192,7 @@ open class Note(val idHex: String) {
|
||||
|
||||
@Synchronized
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
checkNotInMainThread()
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
@@ -414,14 +413,14 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
|
||||
@Immutable
|
||||
data class RelaySetupInfo(
|
||||
val url: String,
|
||||
val read: Boolean,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
@@ -35,6 +36,8 @@ class ThreadAssembler {
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun findThreadFor(noteId: String): Set<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val (result, elapsed) = measureTimedValue {
|
||||
val note = if (noteId.contains(":")) {
|
||||
val aTag = ATag.parse(noteId, null)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object UrlCachedPreviewer {
|
||||
var cache = mapOf<String, UrlInfoItem>()
|
||||
@@ -15,6 +12,8 @@ object UrlCachedPreviewer {
|
||||
private set
|
||||
|
||||
fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) {
|
||||
checkNotInMainThread()
|
||||
|
||||
cache[url]?.let {
|
||||
callback?.onComplete(it)
|
||||
return
|
||||
@@ -25,22 +24,19 @@ object UrlCachedPreviewer {
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache = cache + Pair(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
BahaUrlPreview(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache = cache + Pair(url, urlInfo)
|
||||
callback?.onComplete(urlInfo)
|
||||
}
|
||||
).fetchUrlPreview()
|
||||
}
|
||||
|
||||
override fun onFailed(throwable: Throwable) {
|
||||
failures = failures + Pair(url, throwable)
|
||||
callback?.onFailed(throwable)
|
||||
}
|
||||
}
|
||||
).fetchUrlPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
@@ -185,6 +186,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
@Synchronized
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
checkNotInMainThread()
|
||||
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
@@ -194,6 +197,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
@Synchronized
|
||||
fun addMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg !in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages + msg
|
||||
@@ -203,6 +208,8 @@ class User(val pubkeyHex: String) {
|
||||
|
||||
@Synchronized
|
||||
fun removeMessage(user: User, msg: Note) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val privateChatroom = getOrCreatePrivateChatroom(user)
|
||||
if (msg in privateChatroom.roomMessages) {
|
||||
privateChatroom.roomMessages = privateChatroom.roomMessages - msg
|
||||
@@ -249,15 +256,11 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
fun isFollowing(user: User): Boolean {
|
||||
return latestContactList?.unverifiedFollowKeySet()?.toSet()?.let {
|
||||
return user.pubkeyHex in it
|
||||
} ?: false
|
||||
return latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtag(tag: String): Boolean {
|
||||
return latestContactList?.unverifiedFollowTagSet()?.toSet()?.let {
|
||||
return tag in it
|
||||
} ?: false
|
||||
return latestContactList?.isTaggedHash(tag) ?: false
|
||||
}
|
||||
|
||||
fun isFollowingHashtagCached(tag: String): Boolean {
|
||||
@@ -272,11 +275,17 @@ class User(val pubkeyHex: String) {
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun isFollowingCached(userHex: String): Boolean {
|
||||
return latestContactList?.verifiedFollowKeySet?.let {
|
||||
return userHex in it
|
||||
} ?: false
|
||||
}
|
||||
|
||||
fun transientFollowCount(): Int? {
|
||||
return latestContactList?.unverifiedFollowKeySet()?.size
|
||||
}
|
||||
|
||||
fun transientFollowerCount(): Int {
|
||||
suspend fun transientFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
@@ -292,7 +301,7 @@ class User(val pubkeyHex: String) {
|
||||
return latestContactList?.verifiedFollowKeySet?.size
|
||||
}
|
||||
|
||||
fun cachedFollowerCount(): Int {
|
||||
suspend fun cachedFollowerCount(): Int {
|
||||
return LocalCache.users.values.count { it.latestContactList?.isTaggedUser(pubkeyHex) ?: false }
|
||||
}
|
||||
|
||||
@@ -353,6 +362,7 @@ class UserLiveSet(u: User) {
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class RelayInfo(
|
||||
val url: String,
|
||||
var lastEvent: Long,
|
||||
@@ -388,9 +398,13 @@ class UserMetadata {
|
||||
var updatedMetadataAt: Long = 0
|
||||
var latestMetadata: MetadataEvent? = null
|
||||
|
||||
fun anyName(): String? {
|
||||
return display_name ?: displayName ?: name ?: username
|
||||
}
|
||||
|
||||
fun anyNameStartsWith(prefix: String): Boolean {
|
||||
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
|
||||
.any { it.startsWith(prefix, true) }
|
||||
.any { it.contains(prefix, true) }
|
||||
}
|
||||
|
||||
fun lnAddress(): String? {
|
||||
@@ -400,14 +414,14 @@ class UserMetadata {
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(UserState(user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActive() {
|
||||
|
||||
@@ -20,6 +20,8 @@ class BlurHashFetcher(
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
checkNotInMainThread()
|
||||
|
||||
val encodedHash = data.toString().removePrefix("bluehash:")
|
||||
val hash = URLDecoder.decode(encodedHash, "utf-8")
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
|
||||
fun checkNotInMainThread() {
|
||||
if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread")
|
||||
}
|
||||
|
||||
fun isMainThread() = Looper.myLooper() == Looper.getMainLooper()
|
||||
|
||||
class OnMainThreadException(str: String) : RuntimeException(str)
|
||||
@@ -33,6 +33,8 @@ class Nip05Verifier() {
|
||||
}
|
||||
|
||||
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(nip05)
|
||||
|
||||
if (url == null) {
|
||||
@@ -70,6 +72,9 @@ class Nip05Verifier() {
|
||||
}
|
||||
|
||||
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
// check fails on tests
|
||||
// checkNotInMainThread()
|
||||
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchNip05Json(
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -12,9 +11,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(account: Account, channelId: String) {
|
||||
fun loadMessagesBetween(account: Account, channel: Channel) {
|
||||
this.account = account
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
this.channel = channel
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
@@ -12,9 +11,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") {
|
||||
lateinit var account: Account
|
||||
var withUser: User? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
fun loadMessagesBetween(accountIn: Account, user: User) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.users[userId]
|
||||
withUser = user
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
eventCounter = eventCounter + Pair(key, Counter(1))
|
||||
}
|
||||
|
||||
LocalCache.consume(event, relay)
|
||||
LocalCache.verifyAndConsume(event, relay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,16 +103,16 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateFilters() {
|
||||
bundler.invalidate()
|
||||
bundler.invalidate() {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
@@ -123,6 +123,8 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
fun resetFiltersSuspend() {
|
||||
checkNotInMainThread()
|
||||
|
||||
// saves the channels that are currently active
|
||||
val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null }
|
||||
// saves the current content to only update if it changes
|
||||
|
||||
@@ -48,14 +48,14 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
limit = 100
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -29,7 +29,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind,
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
PollNoteEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
|
||||
PeopleListEvent.kind, BookmarkListEvent.kind
|
||||
),
|
||||
tags = mapOf("a" to listOf(aTag.toTag())),
|
||||
since = it.lastReactionsDownloadTime
|
||||
@@ -113,16 +114,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(
|
||||
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
|
||||
ReactionEvent.kind, RepostEvent.kind,
|
||||
LnZapEvent.kind, LnZapRequestEvent.kind,
|
||||
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
|
||||
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
|
||||
PrivateDmEvent.kind,
|
||||
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
|
||||
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
|
||||
),
|
||||
ids = interestedEvents.toList()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -81,9 +81,9 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind, AppRecommendationEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.lnurl
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -45,6 +46,8 @@ class LightningAddressResolver() {
|
||||
}
|
||||
|
||||
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val url = assembleUrl(lnaddress)
|
||||
|
||||
if (url == null) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
import java.util.regex.Pattern
|
||||
@@ -98,7 +99,7 @@ object LnInvoiceUtil {
|
||||
* @return invoice amount in bitcoins, zero if the invoice has no amount
|
||||
* @throws RuntimeException if invoice format is incorrect
|
||||
*/
|
||||
fun getAmount(invoice: String): BigDecimal {
|
||||
private fun getAmount(invoice: String): BigDecimal {
|
||||
try {
|
||||
decodeUnlimitedLength(invoice) // checksum must match
|
||||
} catch (e: AddressFormatException) {
|
||||
@@ -121,6 +122,8 @@ object LnInvoiceUtil {
|
||||
}
|
||||
|
||||
fun getAmountInSats(invoice: String): BigDecimal {
|
||||
checkNotInMainThread()
|
||||
|
||||
return getAmount(invoice).multiply(BigDecimal(100000000))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@Immutable
|
||||
class AppDefinitionEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun appMetaData() = try {
|
||||
MetadataEvent.metadataParser.readValue(
|
||||
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
|
||||
null
|
||||
}
|
||||
|
||||
fun supportedKinds() = tags.filter { it.size > 1 && it[0] == "k" }.mapNotNull {
|
||||
runCatching { it[1].toInt() }.getOrNull()
|
||||
}
|
||||
|
||||
fun publishedAt() = tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 31990
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
|
||||
@Immutable
|
||||
class AppRecommendationEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
fun recommendations() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
|
||||
ATag.parse(it[1], it.getOrNull(2))
|
||||
}
|
||||
|
||||
fun forKind() = runCatching { dTag().toInt() }.getOrNull()
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
companion object {
|
||||
const val kind = 31989
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@ class ChannelMessageEvent(
|
||||
sig: HexKey
|
||||
) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun channel() = tags.firstOrNull { it[0] == "e" && it.size > 3 && it[3] == "root" }?.getOrNull(1) ?: tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun channel() = tags.firstOrNull {
|
||||
it.size > 3 && it[0] == "e" && it[3] == "root"
|
||||
}?.get(1) ?: tags.firstOrNull {
|
||||
it.size > 1 && it[0] == "e"
|
||||
}?.get(1)
|
||||
|
||||
override fun replyTos() = tags.filter { it.firstOrNull() == "e" && it.getOrNull(1) != channel() }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -16,7 +16,7 @@ class ChannelMetadataEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun channel() = tags.firstOrNull { it.firstOrNull() == "e" }?.getOrNull(1)
|
||||
fun channel() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
fun channelInfo() =
|
||||
try {
|
||||
MetadataEvent.gson.fromJson(content, ChannelCreateEvent.ChannelData::class.java)
|
||||
|
||||
@@ -23,14 +23,12 @@ class ContactListEvent(
|
||||
// This function is only used by the user logged in
|
||||
// But it is used all the time.
|
||||
val verifiedFollowKeySet: Set<HexKey> by lazy {
|
||||
tags.filter { it[0] == "p" }.mapNotNull {
|
||||
it.getOrNull(1)?.let { unverifiedHex: String ->
|
||||
try {
|
||||
decodePublicKey(unverifiedHex).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull {
|
||||
try {
|
||||
decodePublicKey(it[1]).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
Log.w("ContactListEvent", "Can't parse tags as a follows: ${it[1]}", e)
|
||||
null
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ open class Event(
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
|
||||
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
|
||||
}
|
||||
|
||||
private class EventDeserializer : JsonDeserializer<Event> {
|
||||
@@ -212,7 +212,6 @@ open class Event(
|
||||
companion object {
|
||||
private val secp256k1 = Secp256k1.get()
|
||||
|
||||
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
|
||||
val gson: Gson = GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.registerTypeAdapter(Event::class.java, EventSerializer())
|
||||
@@ -228,6 +227,8 @@ open class Event(
|
||||
fun fromJson(json: JsonElement, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
|
||||
fun Event.getRefinedEvent(lenient: Boolean = false): Event = when (kind) {
|
||||
AppDefinitionEvent.kind -> AppDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AppRecommendationEvent.kind -> AppRecommendationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
AudioTrackEvent.kind -> AudioTrackEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -257,6 +258,7 @@ open class Event(
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient)
|
||||
RelaySetEvent.kind -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReportEvent.kind -> ReportEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
RepostEvent.kind -> RepostEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
TextNoteEvent.kind -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -281,7 +283,7 @@ open class Event(
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return sha256.digest(rawEventJson.toByteArray())
|
||||
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
|
||||
}
|
||||
|
||||
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.*
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.Utils
|
||||
import java.nio.charset.Charset
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import javax.crypto.BadPaddingException
|
||||
@@ -133,7 +134,7 @@ class LnZapRequestEvent(
|
||||
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
|
||||
var str = privkey + id + createdAt.toString()
|
||||
var strbyte = str.toByteArray(Charset.forName("utf-8"))
|
||||
return sha256.digest(strbyte)
|
||||
return MessageDigest.getInstance("SHA-256").digest(strbyte)
|
||||
}
|
||||
|
||||
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.google.gson.Gson
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.UserMetadata
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
data class ContactMetaData(
|
||||
val name: String,
|
||||
val picture: String,
|
||||
val about: String,
|
||||
val nip05: String?
|
||||
)
|
||||
|
||||
abstract class IdentityClaim(
|
||||
var identity: String,
|
||||
var proof: String
|
||||
@@ -145,9 +140,13 @@ class MetadataEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun contactMetaData() = try {
|
||||
gson.fromJson(content, ContactMetaData::class.java)
|
||||
metadataParser.readValue(
|
||||
ByteArrayInputStream(content.toByteArray(Charsets.UTF_8)),
|
||||
UserMetadata::class.java
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MetadataEvent", "Can't parse $content", e)
|
||||
e.printStackTrace()
|
||||
Log.w("MT", "Content Parse Error ${e.localizedMessage} $content")
|
||||
null
|
||||
}
|
||||
|
||||
@@ -164,8 +163,10 @@ class MetadataEvent(
|
||||
const val kind = 0
|
||||
val gson = Gson()
|
||||
|
||||
fun create(contactMetaData: ContactMetaData, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
return create(gson.toJson(contactMetaData), identities, privateKey, createdAt = createdAt)
|
||||
val metadataParser by lazy {
|
||||
jacksonObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.readerFor(UserMetadata::class.java)
|
||||
}
|
||||
|
||||
fun create(contactMetaData: String, identities: List<IdentityClaim>, privateKey: ByteArray, createdAt: Long = Date().time / 1000): MetadataEvent {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
@Immutable
|
||||
class RelaySetEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig), AddressableEvent {
|
||||
|
||||
override fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
override fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun relays() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
|
||||
fun description() = tags.firstOrNull() { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 30022
|
||||
|
||||
fun create(
|
||||
relays: List<String>,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): RelaySetEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
relays.forEach {
|
||||
tags.add(listOf("r", it))
|
||||
}
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val id = generateId(pubKey, createdAt, kind, tags, "")
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RelaySetEvent(id.toHexKey(), pubKey, createdAt, tags, "", sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,21 @@ package com.vitorpamplona.amethyst.service.model.zaps
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEventInterface
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
|
||||
object UserZaps {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<Pair<Note, Note>> {
|
||||
fun forProfileFeed(zaps: Map<Note, Note?>?): List<ZapReqResponse> {
|
||||
if (zaps == null) return emptyList()
|
||||
|
||||
return (
|
||||
zaps
|
||||
.filter { it.value != null }
|
||||
.toList()
|
||||
.sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() }
|
||||
.mapNotNull { entry ->
|
||||
entry.value?.let {
|
||||
ZapReqResponse(entry.key, it)
|
||||
}
|
||||
}
|
||||
.sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() }
|
||||
.reversed()
|
||||
) as List<Pair<Note, Note>>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
@@ -15,6 +16,7 @@ object Nip19 {
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)([\\S]*)", Pattern.CASE_INSENSITIVE)
|
||||
|
||||
@Immutable
|
||||
data class Return(
|
||||
val type: Type,
|
||||
val hex: String,
|
||||
|
||||
@@ -13,15 +13,22 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
fun consume(event: Event) {
|
||||
// adds to database
|
||||
LocalCache.consume(event, null)
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
// adds to database
|
||||
LocalCache.verifyAndConsume(event, null)
|
||||
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
when (event) {
|
||||
is PrivateDmEvent -> notify(event)
|
||||
is LnZapEvent -> notify(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +61,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
|
||||
private fun notify(event: LnZapEvent) {
|
||||
val noteZapEvent = LocalCache.notes[event.id] ?: return
|
||||
|
||||
val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import coil.ImageLoader
|
||||
import coil.executeBlocking
|
||||
@@ -133,13 +134,23 @@ object NotificationUtils {
|
||||
channelId: String,
|
||||
applicationContext: Context
|
||||
) {
|
||||
val notId = id.hashCode()
|
||||
|
||||
// dont notify twice
|
||||
val notifications: Array<StatusBarNotification> = getActiveNotifications()
|
||||
for (notification in notifications) {
|
||||
if (notification.id == notId) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val contentIntent = Intent(applicationContext, MainActivity::class.java).apply {
|
||||
data = Uri.parse(uri)
|
||||
}
|
||||
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
id.hashCode(),
|
||||
notId,
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
@@ -173,7 +184,7 @@ object NotificationUtils {
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notify(id.hashCode(), builder.build())
|
||||
notify(notId, builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
|
||||
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
fun fetchUrlPreview(timeOut: Int = 30000) {
|
||||
@@ -18,13 +19,13 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
|
||||
}
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
checkNotInMainThread()
|
||||
lateinit var urlInfoItem: UrlInfoItem
|
||||
if (checkIsImageUrl()) {
|
||||
urlInfoItem = UrlInfoItem(url = url, image = url)
|
||||
} else {
|
||||
val document = getDocument(url, timeOut)
|
||||
urlInfoItem = parseHtml(document)
|
||||
urlInfoItem.url = url
|
||||
urlInfoItem = parseHtml(url, document)
|
||||
}
|
||||
callback?.onComplete(urlInfoItem)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.service.previews
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.net.URL
|
||||
|
||||
@Immutable
|
||||
data class UrlInfoItem(
|
||||
var url: String = "",
|
||||
var title: String = "",
|
||||
var description: String = "",
|
||||
var image: String = ""
|
||||
val url: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val image: String = ""
|
||||
) {
|
||||
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
|
||||
val imageUrlFullPath =
|
||||
if (image.startsWith("/")) {
|
||||
URL(verifiedUrl, image).toString()
|
||||
} else {
|
||||
image
|
||||
}
|
||||
|
||||
fun allFetchComplete(): Boolean {
|
||||
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
|
||||
}
|
||||
|
||||
@@ -67,50 +67,47 @@ suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
|
||||
.get()
|
||||
}
|
||||
|
||||
suspend fun parseHtml(document: Document): UrlInfoItem =
|
||||
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
|
||||
val urlInfo = UrlInfoItem()
|
||||
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
|
||||
when (propertyTag) {
|
||||
in META_OG_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
|
||||
in META_OG_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
|
||||
urlInfo.description =
|
||||
it.attr(CONTENT)
|
||||
in META_OG_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
|
||||
in META_OG_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
|
||||
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_NAME_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
|
||||
in META_NAME_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
|
||||
urlInfo.description =
|
||||
it.attr(CONTENT)
|
||||
in META_NAME_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
|
||||
in META_NAME_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
|
||||
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_ITEMPROP_TITLE -> if (urlInfo.title.isEmpty()) {
|
||||
urlInfo.title =
|
||||
it.attr(CONTENT)
|
||||
in META_ITEMPROP_TITLE -> if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
in META_ITEMPROP_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
|
||||
urlInfo.description =
|
||||
it.attr(
|
||||
CONTENT
|
||||
)
|
||||
in META_ITEMPROP_DESCRIPTION -> if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
in META_ITEMPROP_IMAGE -> if (urlInfo.image.isEmpty()) {
|
||||
urlInfo.image = it.attr(
|
||||
CONTENT
|
||||
)
|
||||
in META_ITEMPROP_IMAGE -> if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
if (urlInfo.allFetchComplete()) {
|
||||
return@withContext urlInfo
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return@withContext UrlInfoItem(url, title, description, image)
|
||||
}
|
||||
}
|
||||
return@withContext urlInfo
|
||||
return@withContext UrlInfoItem(url, title, description, image)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -32,6 +33,8 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@Synchronized
|
||||
fun connect(relays: Array<Relay>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
RelayPool.register(this)
|
||||
RelayPool.unloadRelays()
|
||||
RelayPool.loadRelays(relays.toList())
|
||||
@@ -54,6 +57,8 @@ object Client : RelayPool.Listener {
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf()
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilter(subscriptionId)
|
||||
}
|
||||
@@ -62,11 +67,15 @@ object Client : RelayPool.Listener {
|
||||
subscriptionId: String = UUID.randomUUID().toString().substring(0..10),
|
||||
filters: List<TypedFilter> = listOf()
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
subscriptions = subscriptions + Pair(subscriptionId, filters)
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set<FeedType>? = null, onDone: (() -> Unit)? = null) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
@@ -91,7 +100,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
private fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
@@ -69,7 +70,10 @@ class Relay(
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch() {
|
||||
checkNotInMainThread()
|
||||
requestAndWatch {
|
||||
checkNotInMainThread()
|
||||
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
@@ -79,6 +83,7 @@ class Relay(
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch(onConnected: (Relay) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
if (socket != null) return
|
||||
|
||||
try {
|
||||
@@ -89,6 +94,8 @@ class Relay(
|
||||
val listener = object : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
checkNotInMainThread()
|
||||
|
||||
afterEOSE = false
|
||||
isReady = true
|
||||
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
|
||||
@@ -99,6 +106,8 @@ class Relay(
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
eventDownloadCounterInBytes += text.bytesUsedInMemory()
|
||||
|
||||
try {
|
||||
@@ -153,6 +162,8 @@ class Relay(
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
listeners.forEach {
|
||||
it.onRelayStateChange(
|
||||
this@Relay,
|
||||
@@ -163,6 +174,8 @@ class Relay(
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
socket = null
|
||||
isReady = false
|
||||
afterEOSE = false
|
||||
@@ -171,6 +184,8 @@ class Relay(
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
checkNotInMainThread()
|
||||
|
||||
errorCounter++
|
||||
|
||||
socket?.close(1000, "Normal close")
|
||||
@@ -209,6 +224,8 @@ class Relay(
|
||||
}
|
||||
|
||||
fun sendFilter(requestId: String) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (read) {
|
||||
if (isConnected()) {
|
||||
if (isReady) {
|
||||
@@ -233,6 +250,8 @@ class Relay(
|
||||
}
|
||||
|
||||
fun sendFilterOnlyIfDisconnected() {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (socket == null) {
|
||||
// waits 60 seconds to reconnect after disconnected.
|
||||
if (Date().time / 1000 > closingTime + 60) {
|
||||
@@ -243,6 +262,8 @@ class Relay(
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (signedEvent is RelayAuthEvent) {
|
||||
val event = """["AUTH",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -48,6 +49,8 @@ object RelayPool : Relay.Listener {
|
||||
}
|
||||
|
||||
fun requestAndWatch() {
|
||||
checkNotInMainThread()
|
||||
|
||||
relays.forEach { it.requestAndWatch() }
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ class TypedFilter(
|
||||
filter.limit?.run {
|
||||
jsonObject.addProperty("limit", filter.limit)
|
||||
}
|
||||
filter.search?.run {
|
||||
jsonObject.addProperty("search", filter.search)
|
||||
}
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -12,15 +11,8 @@ import androidx.compose.material.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.decode.SvgDecoder
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
@@ -49,23 +41,6 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
val startingPage = uriToRoute(uri)
|
||||
|
||||
// Initializes video cache.
|
||||
VideoCache.init(this.applicationContext)
|
||||
|
||||
Coil.setImageLoader {
|
||||
ImageLoader.Builder(this).components {
|
||||
if (SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
add(SvgDecoder.Factory())
|
||||
} // .logger(DebugLogger())
|
||||
.okHttpClient { HttpClient.getHttpClient() }
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
LocalPreferences.migrateSingleUserPrefs()
|
||||
|
||||
setContent {
|
||||
@@ -73,7 +48,7 @@ class MainActivity : FragmentActivity() {
|
||||
// A surface container using the 'background' color from the theme
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
val accountStateViewModel: AccountStateViewModel = viewModel {
|
||||
AccountStateViewModel()
|
||||
AccountStateViewModel(this@MainActivity)
|
||||
}
|
||||
|
||||
AccountScreen(accountStateViewModel, startingPage)
|
||||
@@ -92,7 +67,7 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
ServiceManager.start()
|
||||
ServiceManager.start(this@MainActivity)
|
||||
}
|
||||
|
||||
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())
|
||||
@@ -111,7 +86,9 @@ class MainActivity : FragmentActivity() {
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
println("Trim Memory $level")
|
||||
ServiceManager.cleanUp()
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
ServiceManager.cleanUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.Divider
|
||||
@@ -28,6 +29,8 @@ 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.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -52,13 +55,14 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -72,14 +76,28 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -> Unit) {
|
||||
val searchBarViewModel: SearchBarViewModel = viewModel()
|
||||
searchBarViewModel.account = account
|
||||
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
val searchBarViewModel: SearchBarViewModel = viewModel(
|
||||
key = accountViewModel.account.userProfile().pubkeyHex + "SearchBarViewModel",
|
||||
factory = SearchBarViewModel.Factory(
|
||||
accountViewModel.account
|
||||
)
|
||||
)
|
||||
|
||||
JoinUserOrChannelView(
|
||||
searchBarViewModel = searchBarViewModel,
|
||||
onClose = onClose,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
searchBarViewModel.clean()
|
||||
searchBarViewModel.clear()
|
||||
onClose()
|
||||
},
|
||||
properties = DialogProperties(
|
||||
@@ -88,7 +106,9 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.heightIn(min = 500.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -96,7 +116,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
searchBarViewModel.clean()
|
||||
searchBarViewModel.clear()
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
onClose()
|
||||
})
|
||||
@@ -115,28 +135,20 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
RenderSeach(searchBarViewModel, account, nav)
|
||||
RenderSearch(searchBarViewModel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun RenderSeach(
|
||||
private fun RenderSearch(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// Create a channel for processing search queries.
|
||||
@@ -145,9 +157,10 @@ private fun RenderSeach(
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
checkNotInMainThread()
|
||||
if (searchBarViewModel.isSearchingFun()) {
|
||||
searchBarViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
@@ -155,9 +168,6 @@ private fun RenderSeach(
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
|
||||
// Wait for text changes to stop for 300 ms before firing off search.
|
||||
withContext(Dispatchers.IO) {
|
||||
searchTextChanges.receiveAsFlow()
|
||||
@@ -166,13 +176,13 @@ private fun RenderSeach(
|
||||
.debounce(300)
|
||||
.collectLatest {
|
||||
if (it.length >= 2) {
|
||||
onlineSearch.search(it.trim())
|
||||
NostrSearchEventOrUserDataSource.search(it.trim())
|
||||
}
|
||||
|
||||
searchBarViewModel.invalidateData()
|
||||
|
||||
// makes sure to show the top of the search
|
||||
scope.launch(Dispatchers.Main) {
|
||||
launch(Dispatchers.Main) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
@@ -200,8 +210,34 @@ private fun RenderSeach(
|
||||
}
|
||||
|
||||
// LAST ROW
|
||||
SearchEditTextForJoin(searchBarViewModel, searchTextChanges)
|
||||
|
||||
RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun SearchEditTextForJoin(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
searchTextChanges: Channel<String>
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -238,11 +274,11 @@ private fun RenderSeach(
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchBarViewModel.isTrailingIconVisible) {
|
||||
if (searchBarViewModel.isSearching) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
searchBarViewModel.clean()
|
||||
onlineSearch.clear()
|
||||
searchBarViewModel.clear()
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
@@ -254,10 +290,24 @@ private fun RenderSeach(
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderSearchResults(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (searchBarViewModel.isSearching) {
|
||||
val users by searchBarViewModel.searchResultsUsers.collectAsState()
|
||||
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
|
||||
|
||||
if (searchBarViewModel.searchValue.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(vertical = 10.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
@@ -268,50 +318,60 @@ private fun RenderSeach(
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(
|
||||
searchBarViewModel.searchResults.value,
|
||||
users,
|
||||
key = { _, item -> "u" + item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserComposeForChat(
|
||||
item,
|
||||
account = account,
|
||||
nav = nav
|
||||
)
|
||||
UserComposeForChat(item, accountViewModel) {
|
||||
nav("Room/${item.pubkeyHex}")
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
searchBarViewModel.searchResultsChannels.value,
|
||||
channels,
|
||||
key = { _, item -> "c" + item.idHex }
|
||||
) { _, item ->
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
false,
|
||||
onClick = { nav("Channel/${item.idHex}") }
|
||||
)
|
||||
RenderChannel(item) {
|
||||
nav("Channel/${item.idHex}")
|
||||
searchBarViewModel.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderChannel(
|
||||
item: com.vitorpamplona.amethyst.model.Channel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
false,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserComposeForChat(
|
||||
baseUser: User,
|
||||
account: Account,
|
||||
nav: (String) -> Unit
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { nav("Room/${baseUser.pubkeyHex}") }
|
||||
onClick = onClick
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
@@ -323,22 +383,18 @@ fun UserComposeForChat(
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, nav, account.userProfile(), 55.dp)
|
||||
UserPicture(baseUser, 55.dp, accountViewModel)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UsernameDisplay(baseUser)
|
||||
}
|
||||
|
||||
val baseUserState by baseUser.live().metadata.observeAsState()
|
||||
val user = baseUserState?.user ?: return
|
||||
|
||||
Text(
|
||||
user.info?.about ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
DisplayUserAboutInfo(baseUser)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,3 +404,20 @@ fun UserComposeForChat(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUserAboutInfo(baseUser: User) {
|
||||
val baseUserState by baseUser.live().metadata.observeAsState()
|
||||
val about by remember(baseUserState) {
|
||||
derivedStateOf {
|
||||
baseUserState?.user?.info?.about ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = about,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -24,13 +25,15 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = null) {
|
||||
fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) {
|
||||
val postViewModel: NewChannelViewModel = viewModel()
|
||||
postViewModel.load(account, channel)
|
||||
postViewModel.load(accountViewModel.account, channel)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
@@ -53,10 +56,14 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
onClose()
|
||||
})
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.create()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
postViewModel.channelName.value.text.isNotBlank()
|
||||
)
|
||||
@@ -100,7 +107,9 @@ fun NewChannelView(onClose: () -> Unit, account: Account, channel: Channel? = nu
|
||||
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.description)) },
|
||||
modifier = Modifier.fillMaxWidth().height(100.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp),
|
||||
value = postViewModel.channelDescription.value,
|
||||
onValueChange = { postViewModel.channelDescription.value = it },
|
||||
placeholder = {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -14,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Stable
|
||||
open class NewMediaModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,8 +33,10 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
@@ -48,8 +49,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +123,6 @@ fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val fileServers = listOf(
|
||||
// Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
@@ -127,8 +131,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = fileServers.map { it.second }
|
||||
val fileServerExplainers = fileServers.map { it.third }
|
||||
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
|
||||
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
|
||||
Row(
|
||||
@@ -152,7 +156,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
@@ -175,7 +179,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(it)
|
||||
VideoView(it.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
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.model.parseDirtyWordForKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
class NewMessageTagger(var channelHex: String?, var mentions: List<User>?, var replyTos: List<Note>?, var message: String) {
|
||||
|
||||
fun addUserToMentions(user: User) {
|
||||
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
|
||||
@@ -20,12 +19,12 @@ class NewMessageTagger(var channel: Channel?, var mentions: List<User>?, var rep
|
||||
|
||||
fun tagIndex(user: User): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
return (if (channelHex != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
|
||||
}
|
||||
|
||||
fun tagIndex(note: Note): Int {
|
||||
// Postr Events assembles replies before mentions in the tag order
|
||||
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
return (if (channelHex != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
|
||||
}
|
||||
|
||||
fun run() {
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) {
|
||||
val pollViewModel: NewPostViewModel = viewModel()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
pollViewModel.load(account, baseReplyTo, quote)
|
||||
delay(100)
|
||||
|
||||
pollViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp, end = 10.dp, top = 10.dp)
|
||||
.imePadding()
|
||||
.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
pollViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PollButton(
|
||||
onPost = {
|
||||
pollViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = pollViewModel.message.text.isNotBlank() &&
|
||||
pollViewModel.pollOptions.values.all { it.isNotEmpty() } &&
|
||||
pollViewModel.isValidRecipients.value &&
|
||||
pollViewModel.isValidvalueMaximum.value &&
|
||||
pollViewModel.isValidvalueMinimum.value &&
|
||||
pollViewModel.isValidConsensusThreshold.value &&
|
||||
pollViewModel.isValidClosedAt.value
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "✖ ") {
|
||||
pollViewModel.removeFromReplyList(it)
|
||||
}
|
||||
}
|
||||
|
||||
Text(stringResource(R.string.poll_heading_required))
|
||||
// NewPollRecipientsField(pollViewModel, account)
|
||||
NewPollPrimaryDescription(pollViewModel)
|
||||
pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
|
||||
NewPollOption(pollViewModel, index)
|
||||
}
|
||||
Button(
|
||||
onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" },
|
||||
border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Image(
|
||||
painterResource(id = android.R.drawable.ic_input_add),
|
||||
contentDescription = "Add poll option button",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Text(stringResource(R.string.poll_heading_optional))
|
||||
NewPollVoteValueRange(pollViewModel)
|
||||
NewPollConsensusThreshold(pollViewModel)
|
||||
NewPollClosing(pollViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
val userSuggestions = pollViewModel.userSuggestions
|
||||
if (userSuggestions.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(0.dp, 300.dp)
|
||||
) {
|
||||
itemsIndexed(
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
pollViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
/*UploadFromGallery(
|
||||
isUploading = pollViewModel.isUploadingImage
|
||||
) {
|
||||
pollViewModel.upload(it, context)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) {
|
||||
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 = stringResource(R.string.post_poll), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
/*@Preview
|
||||
@Composable
|
||||
fun NewPollViewPreview() {
|
||||
NewPollView(onClose = {}, account = Account(loggedIn = Persona()))
|
||||
}*/
|
||||
@@ -24,13 +24,13 @@ import androidx.compose.material.icons.filled.Cancel
|
||||
import androidx.compose.material.icons.filled.CurrencyBitcoin
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -71,9 +71,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
@@ -97,8 +100,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +151,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -161,7 +170,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Notifying(postViewModel.mentions) {
|
||||
Notifying(postViewModel.mentions?.toImmutableList()) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
|
||||
@@ -291,7 +300,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
} else {
|
||||
UrlPreview(myUrlPreview, myUrlPreview)
|
||||
}
|
||||
} else if (isBechLink(myUrlPreview)) {
|
||||
} else if (startsWithNIP19Scheme(myUrlPreview)) {
|
||||
BechLink(
|
||||
myUrlPreview,
|
||||
true,
|
||||
@@ -319,7 +328,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
userSuggestions,
|
||||
key = { _, item -> item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserLine(item, account) {
|
||||
UserLine(item, accountViewModel) {
|
||||
postViewModel.autocompleteWithUser(item)
|
||||
}
|
||||
}
|
||||
@@ -371,7 +380,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
|
||||
val mentions = baseMentions?.toSet()
|
||||
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
|
||||
@@ -384,11 +393,13 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
|
||||
mentions.forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
innerUserState?.user?.let { myUser ->
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
|
||||
val tags = remember(innerUserState) {
|
||||
myUser.info?.latestMetadata?.tags?.toImmutableListOfLists()
|
||||
}
|
||||
|
||||
Button(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -399,8 +410,8 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
}
|
||||
) {
|
||||
CreateTextWithEmoji(
|
||||
text = "✖ ${myUser.toBestDisplayName()}",
|
||||
tags = myUser.info?.latestMetadata?.tags,
|
||||
text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" },
|
||||
tags = tags,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -727,7 +738,6 @@ fun ImageVideoDescription(
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isImage = mediaType.startsWith("image")
|
||||
val isVideo = mediaType.startsWith("video")
|
||||
@@ -744,8 +754,8 @@ fun ImageVideoDescription(
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
val fileServerOptions = fileServers.map { it.second }
|
||||
val fileServerExplainers = fileServers.map { it.third }
|
||||
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
|
||||
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
|
||||
|
||||
var selectedServer by remember { mutableStateOf(defaultServer) }
|
||||
var message by remember { mutableStateOf("") }
|
||||
@@ -830,7 +840,7 @@ fun ImageVideoDescription(
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
@@ -851,7 +861,7 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
VideoView(uri)
|
||||
VideoView(uri.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,3 +929,10 @@ fun ImageVideoDescription(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
|
||||
|
||||
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
|
||||
return ImmutableListOfLists(this)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
@@ -15,6 +16,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.BaseTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
@@ -23,7 +25,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class NewPostViewModel : ViewModel() {
|
||||
@Stable
|
||||
open class NewPostViewModel() : ViewModel() {
|
||||
var account: Account? = null
|
||||
var originalNote: Note? = null
|
||||
|
||||
@@ -73,7 +76,12 @@ open class NewPostViewModel : ViewModel() {
|
||||
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
|
||||
originalNote = replyingTo
|
||||
replyingTo?.let { replyNote ->
|
||||
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
if (replyNote.event is BaseTextNoteEvent) {
|
||||
this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote)
|
||||
} else {
|
||||
this.replyTos = listOf(replyNote)
|
||||
}
|
||||
|
||||
replyNote.author?.let { replyUser ->
|
||||
val currentMentions = (replyNote.event as? TextNoteEvent)
|
||||
?.mentions()
|
||||
@@ -96,7 +104,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
@@ -108,7 +116,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
|
||||
val tagger = NewMessageTagger(originalNote?.channelHex(), mentions, replyTos, message.text)
|
||||
tagger.run()
|
||||
|
||||
val zapReceiver = if (wantsForwardZapTo) {
|
||||
@@ -123,10 +131,10 @@ open class NewPostViewModel : ViewModel() {
|
||||
|
||||
if (wantsPoll) {
|
||||
account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver, wantsToMarkAsSensitive)
|
||||
} else if (originalNote?.channel() != null) {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
} else if (originalNote?.channelHex() != null) {
|
||||
account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
} else if (originalNote?.event is PrivateDmEvent) {
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive)
|
||||
} else {
|
||||
account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive)
|
||||
}
|
||||
@@ -220,7 +228,11 @@ open class NewPostViewModel : ViewModel() {
|
||||
userSuggestionsMainMessage = true
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() }))
|
||||
.reversed()
|
||||
}
|
||||
} else {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
userSuggestions = emptyList()
|
||||
@@ -236,7 +248,15 @@ open class NewPostViewModel : ViewModel() {
|
||||
userSuggestionsMainMessage = false
|
||||
if (lastWord.startsWith("@") && lastWord.length > 2) {
|
||||
NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@"))
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
|
||||
.sortedWith(
|
||||
compareBy(
|
||||
{ account?.isFollowing(it) },
|
||||
{ it.toBestDisplayName() }
|
||||
)
|
||||
).reversed()
|
||||
}
|
||||
} else {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
userSuggestions = emptyList()
|
||||
|
||||
@@ -49,18 +49,18 @@ import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import java.lang.Math.round
|
||||
|
||||
@Composable
|
||||
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") {
|
||||
fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "") {
|
||||
val postViewModel: NewRelayListViewModel = viewModel()
|
||||
val feedState by postViewModel.relays.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
postViewModel.load(accountViewModel.account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -46,7 +47,7 @@ class NewRelayListViewModel : ViewModel() {
|
||||
if (relayFile != null) {
|
||||
relayFile.map {
|
||||
val liveRelay = RelayPool.getRelay(it.key)
|
||||
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
|
||||
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet().toImmutableSet()
|
||||
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
|
||||
@@ -28,6 +28,9 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
@@ -37,8 +40,12 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
launch(Dispatchers.IO) {
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
|
||||
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
|
||||
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayInputStream
|
||||
@@ -119,7 +120,9 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
val writer = StringWriter()
|
||||
ObjectMapper().writeValue(writer, currentJson)
|
||||
|
||||
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
|
||||
}
|
||||
|
||||
clear()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@@ -99,21 +101,23 @@ private fun UploadBoxButton(
|
||||
}
|
||||
}
|
||||
|
||||
val DefaultAnimationColors = listOf(
|
||||
Color(0xFF5851D8),
|
||||
Color(0xFF833AB4),
|
||||
Color(0xFFC13584),
|
||||
Color(0xFFE1306C),
|
||||
Color(0xFFFD1D1D),
|
||||
Color(0xFFF56040),
|
||||
Color(0xFFF77737),
|
||||
Color(0xFFFCAF45),
|
||||
Color(0xFFFFDC80),
|
||||
Color(0xFF5851D8)
|
||||
).toImmutableList()
|
||||
|
||||
@Composable
|
||||
fun LoadingAnimation(
|
||||
indicatorSize: Dp = 20.dp,
|
||||
circleColors: List<Color> = listOf(
|
||||
Color(0xFF5851D8),
|
||||
Color(0xFF833AB4),
|
||||
Color(0xFFC13584),
|
||||
Color(0xFFE1306C),
|
||||
Color(0xFFFD1D1D),
|
||||
Color(0xFFF56040),
|
||||
Color(0xFFF77737),
|
||||
Color(0xFFFCAF45),
|
||||
Color(0xFFFFDC80),
|
||||
Color(0xFF5851D8)
|
||||
),
|
||||
circleColors: ImmutableList<Color> = DefaultAnimationColors,
|
||||
animationDuration: Int = 1000
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
|
||||
@@ -16,12 +16,12 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun ChannelFabColumn(account: Account, nav: (String) -> Unit) {
|
||||
fun ChannelFabColumn(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
|
||||
var isOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -35,11 +35,11 @@ fun ChannelFabColumn(account: Account, nav: (String) -> Unit) {
|
||||
}
|
||||
|
||||
if (wantsToCreateChannel) {
|
||||
NewChannelView({ wantsToCreateChannel = false }, account = account)
|
||||
NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
if (wantsToJoinChannelOrUser) {
|
||||
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, nav = nav)
|
||||
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
|
||||
Column() {
|
||||
|
||||
@@ -19,17 +19,17 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
fun NewChannelButton(account: Account) {
|
||||
fun NewChannelButton(accountViewModel: AccountViewModel) {
|
||||
var wantsToPost by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
NewChannelView({ wantsToPost = false }, account = account)
|
||||
NewChannelView({ wantsToPost = false }, accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,13 +17,12 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
*/
|
||||
class BundledUpdate(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
val onUpdate: () -> Unit
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var invalidatesAgain = false
|
||||
|
||||
fun invalidate(ignoreIfDoing: Boolean = false) {
|
||||
fun invalidate(ignoreIfDoing: Boolean = false, onUpdate: suspend () -> Unit) {
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
if (!ignoreIfDoing) {
|
||||
invalidatesAgain = true
|
||||
@@ -59,6 +59,8 @@ class BundledInsert<T>(
|
||||
private var queue = LinkedBlockingQueue<T>()
|
||||
|
||||
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
|
||||
checkNotInMainThread()
|
||||
|
||||
queue.put(newObject)
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
return
|
||||
|
||||
@@ -13,7 +13,9 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
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
|
||||
@@ -45,26 +47,61 @@ import com.vitorpamplona.amethyst.service.NIP30Parser
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadChannel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
nip19: Nip19.Return,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
DisplayUser(nip19, nav)
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
DisplayAddress(nip19, nav)
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
DisplayNote(nip19, nav)
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
DisplayEvent(nip19, nav)
|
||||
} else {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
when (nip19.type) {
|
||||
Nip19.Type.USER -> {
|
||||
DisplayUser(nip19, nav)
|
||||
}
|
||||
Nip19.Type.ADDRESS -> {
|
||||
DisplayAddress(nip19, nav)
|
||||
}
|
||||
Nip19.Type.NOTE -> {
|
||||
DisplayNote(nip19, nav)
|
||||
}
|
||||
Nip19.Type.EVENT -> {
|
||||
DisplayEvent(nip19, nav)
|
||||
}
|
||||
else -> {
|
||||
Text(
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadNote(
|
||||
hex: String,
|
||||
content: @Composable (Note) -> Unit
|
||||
) {
|
||||
var noteBase by remember(hex) { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = hex) {
|
||||
if (noteBase == null) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(hex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
content(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,54 +110,8 @@ private fun DisplayEvent(
|
||||
nip19: Nip19.Return,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
val channel = remember(noteState) { note.channel() }
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
nav = nav
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
nav = nav
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${channel.idHex}",
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Event/${nip19.hex}",
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
LoadNote(nip19.hex) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,53 +120,61 @@ private fun DisplayNote(
|
||||
nip19: Nip19.Return,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
LoadNote(nip19.hex) {
|
||||
DisplayNoteLink(it, nip19, nav)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
val channel = note.channel()
|
||||
@Composable
|
||||
private fun DisplayNoteLink(
|
||||
it: Note,
|
||||
nip19: Nip19.Return,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
|
||||
val channelHex = remember(noteState) { note.channelHex() }
|
||||
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars} " }
|
||||
|
||||
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.kind) {
|
||||
CreateClickableText(
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Channel/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.kind) {
|
||||
CreateClickableText(
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
|
||||
nav = nav
|
||||
)
|
||||
} else if (channelHex != null) {
|
||||
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
|
||||
val channelState by baseChannel.live.observeAsState()
|
||||
val channelDisplayName by remember(channelState) {
|
||||
derivedStateOf {
|
||||
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
|
||||
}
|
||||
}
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${nip19.hex}",
|
||||
nav = nav
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Room/${note.author?.pubkeyHex}",
|
||||
nav = nav
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
clickablePart = channel.toBestDisplayName(),
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Channel/${note.channel()?.idHex}",
|
||||
nav = nav
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
clickablePart = channelDisplayName,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
} else {
|
||||
CreateClickableText(
|
||||
clickablePart = noteIdDisplayNote,
|
||||
suffix = addedCharts,
|
||||
route = remember(noteState) { "Event/${nip19.hex}" },
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -188,26 +187,31 @@ private fun DisplayAddress(
|
||||
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = remember(noteState) { noteState?.note } ?: return
|
||||
|
||||
val route = remember(noteState) { "Note/${nip19.hex}" }
|
||||
val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" }
|
||||
val addedCharts = remember { "${nip19.additionalChars} " }
|
||||
|
||||
CreateClickableText(
|
||||
clickablePart = "@${note.idDisplayNote()}",
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
route = "Note/${nip19.hex}",
|
||||
clickablePart = displayName,
|
||||
suffix = addedCharts,
|
||||
route = route,
|
||||
nav = nav
|
||||
)
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +224,7 @@ private fun DisplayUser(
|
||||
var userBase by remember(nip19) { mutableStateOf<User?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
}
|
||||
}
|
||||
@@ -229,12 +233,15 @@ private fun DisplayUser(
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
val route = remember(userState) { "User/${it.pubkeyHex}" }
|
||||
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
|
||||
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
|
||||
val addedCharts = remember {
|
||||
"${nip19.additionalChars} "
|
||||
}
|
||||
|
||||
if (userDisplayName != null) {
|
||||
CreateClickableTextWithEmoji(
|
||||
clickablePart = userDisplayName,
|
||||
suffix = "${nip19.additionalChars} ",
|
||||
suffix = addedCharts,
|
||||
tags = userTags,
|
||||
route = route,
|
||||
nav = nav
|
||||
@@ -244,7 +251,9 @@ private fun DisplayUser(
|
||||
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
remember {
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +287,7 @@ fun CreateClickableText(
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
@@ -287,32 +296,29 @@ fun CreateTextWithEmoji(
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val emojis = remember {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = text) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList = assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
emojis: Map<String, String>,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val textColor = color.takeOrElse {
|
||||
LocalTextStyle.current.color.takeOrElse {
|
||||
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
}
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
if (emojiList.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
@@ -324,10 +330,6 @@ fun CreateTextWithEmoji(
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(text, emojis)
|
||||
}
|
||||
|
||||
val style = LocalTextStyle.current.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
@@ -337,33 +339,97 @@ fun CreateTextWithEmoji(
|
||||
)
|
||||
).toSpanStyle()
|
||||
|
||||
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
|
||||
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateTextWithEmoji(
|
||||
text: String,
|
||||
emojis: ImmutableMap<String, String>,
|
||||
color: Color = Color.Unspecified,
|
||||
textAlign: TextAlign? = null,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var emojiList by remember(text) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = text) {
|
||||
if (emojis.isNotEmpty()) {
|
||||
launch(Dispatchers.Default) {
|
||||
val newEmojiList = assembleAnnotatedList(text, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val textColor = color.takeOrElse {
|
||||
LocalTextStyle.current.color.takeOrElse {
|
||||
LocalContentColor.current.copy(alpha = LocalContentAlpha.current)
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiList.isEmpty()) {
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow,
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
val style = LocalTextStyle.current.merge(
|
||||
TextStyle(
|
||||
color = textColor,
|
||||
textAlign = textAlign,
|
||||
fontWeight = fontWeight,
|
||||
fontSize = fontSize
|
||||
)
|
||||
).toSpanStyle()
|
||||
|
||||
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
style: TextStyle,
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
var emojiList by remember(clickablePart) { mutableStateOf<ImmutableList<Renderable>>(persistentListOf()) }
|
||||
|
||||
LaunchedEffect(key1 = clickablePart) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList = assembleAnnotatedList(clickablePart, emojis)
|
||||
if (newEmojiList.isNotEmpty()) {
|
||||
emojiList = newEmojiList.toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
if (emojiList.isEmpty()) {
|
||||
ClickableText(
|
||||
AnnotatedString(clickablePart),
|
||||
style = style,
|
||||
onClick = onClick
|
||||
)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, style.toSpanStyle()) {
|
||||
ClickableInLineIconRenderer(emojiList, style.toSpanStyle()) {
|
||||
onClick(it)
|
||||
}
|
||||
}
|
||||
@@ -373,36 +439,44 @@ fun CreateClickableTextWithEmoji(
|
||||
fun CreateClickableTextWithEmoji(
|
||||
clickablePart: String,
|
||||
suffix: String,
|
||||
tags: List<List<String>>?,
|
||||
tags: ImmutableListOfLists<String>?,
|
||||
overrideColor: Color? = null,
|
||||
fontWeight: FontWeight = FontWeight.Normal,
|
||||
route: String,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val emojis = remember(tags) {
|
||||
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
var emojiLists by remember(clickablePart) {
|
||||
mutableStateOf<Pair<ImmutableList<Renderable>, ImmutableList<Renderable>>?>(null)
|
||||
}
|
||||
|
||||
if (emojis.isEmpty()) {
|
||||
LaunchedEffect(key1 = clickablePart) {
|
||||
launch(Dispatchers.Default) {
|
||||
val emojis =
|
||||
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
|
||||
|
||||
if (emojis.isNotEmpty()) {
|
||||
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
|
||||
val newEmojiList2 = assembleAnnotatedList(suffix, emojis)
|
||||
|
||||
if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) {
|
||||
emojiLists = Pair(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emojiLists == null) {
|
||||
CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, nav)
|
||||
} else {
|
||||
val myList = remember {
|
||||
assembleAnnotatedList(clickablePart, emojis)
|
||||
}
|
||||
|
||||
ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
|
||||
ClickableInLineIconRenderer(emojiLists!!.first, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) {
|
||||
nav(route)
|
||||
}
|
||||
|
||||
val myList2 = remember {
|
||||
assembleAnnotatedList(suffix, emojis)
|
||||
}
|
||||
|
||||
InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
|
||||
InLineIconRenderer(emojiLists!!.second, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle())
|
||||
}
|
||||
}
|
||||
|
||||
fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Renderable> {
|
||||
suspend fun assembleAnnotatedList(text: String, emojis: Map<String, String>): ImmutableList<Renderable> {
|
||||
return NIP30Parser().buildArray(text).map {
|
||||
val url = emojis[it]
|
||||
if (url != null) {
|
||||
@@ -410,15 +484,20 @@ fun assembleAnnotatedList(text: String, emojis: Map<String, String>): List<Rende
|
||||
} else {
|
||||
TextType(it)
|
||||
}
|
||||
}
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
open class Renderable()
|
||||
|
||||
@Immutable
|
||||
class TextType(val text: String) : Renderable()
|
||||
|
||||
@Immutable
|
||||
class ImageUrlType(val url: String) : Renderable()
|
||||
|
||||
@Composable
|
||||
fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
|
||||
fun ClickableInLineIconRenderer(wordsInOrder: ImmutableList<Renderable>, style: SpanStyle, onClick: (Int) -> Unit) {
|
||||
val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value ->
|
||||
if (value is ImageUrlType) {
|
||||
Pair(
|
||||
@@ -433,7 +512,9 @@ fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -477,7 +558,7 @@ fun ClickableInLineIconRenderer(wordsInOrder: List<Renderable>, style: SpanStyle
|
||||
|
||||
@Composable
|
||||
fun InLineIconRenderer(
|
||||
wordsInOrder: List<Renderable>,
|
||||
wordsInOrder: ImmutableList<Renderable>,
|
||||
style: SpanStyle,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
@@ -489,15 +570,17 @@ fun InLineIconRenderer(
|
||||
"inlineContent$idx",
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
width = 20.sp,
|
||||
height = 20.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
AsyncImage(
|
||||
model = value.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize().padding(1.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 1.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
|
||||
@@ -11,10 +12,16 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
val doubleCheckedUrl = remember(url) {
|
||||
if (url.contains("://")) url else "https://$url"
|
||||
}
|
||||
|
||||
val text = remember(urlText) {
|
||||
AnnotatedString(urlText)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(urlText),
|
||||
text = text,
|
||||
onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
@@ -14,10 +15,19 @@ fun ClickableUserTag(
|
||||
user: User,
|
||||
nav: (String) -> Unit
|
||||
) {
|
||||
val route = remember {
|
||||
"User/${user.pubkeyHex}"
|
||||
}
|
||||
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
|
||||
val userName = remember(innerUserState) {
|
||||
AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}")
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
|
||||
onClick = { nav("User/${innerUserState?.user?.pubkeyHex}") },
|
||||
text = userName,
|
||||
onClick = { nav(route) },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MayBeWithdrawal(lnurlWord: String) {
|
||||
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnurlWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
|
||||
}
|
||||
}
|
||||
@@ -38,11 +38,19 @@ fun MayBeWithdrawal(lnurlWord: String) {
|
||||
fun ClickableWithdrawal(withdrawalString: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val uri = remember(withdrawalString) {
|
||||
Uri.parse("lightning:$withdrawalString")
|
||||
}
|
||||
|
||||
val withdraw = remember(withdrawalString) {
|
||||
AnnotatedString("$withdrawalString ")
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$withdrawalString "),
|
||||
text = withdraw,
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri)
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
const val SHORT_TEXT_LENGTH = 350
|
||||
@@ -34,8 +35,8 @@ const val SHORT_TEXT_LENGTH = 350
|
||||
fun ExpandableRichTextViewer(
|
||||
content: String,
|
||||
canPreview: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tags: List<List<String>>?,
|
||||
modifier: Modifier,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: (String) -> Unit
|
||||
@@ -87,18 +88,26 @@ fun ExpandableRichTextViewer(
|
||||
)
|
||||
)
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = { showFullText = !showFullText },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_more), color = Color.White)
|
||||
ShowMoreButton() {
|
||||
showFullText = !showFullText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun ShowMoreButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
.compositeOver(MaterialTheme.colors.background)
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.show_more), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,19 +27,18 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.text.NumberFormat
|
||||
|
||||
@Composable
|
||||
fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
|
||||
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = lnbcWord) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
|
||||
if (myInvoice != null) {
|
||||
val myInvoiceAmount = try {
|
||||
LnInvoiceUtil.getAmountInSats(myInvoice)
|
||||
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
@@ -60,7 +59,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
|
||||
fun InvoicePreview(lnInvoice: String, amount: String?) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
@@ -100,9 +99,7 @@ fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
|
||||
|
||||
amount?.let {
|
||||
Text(
|
||||
text = "${
|
||||
NumberFormat.getInstance().format(amount)
|
||||
} ${stringResource(id = R.string.sats)}",
|
||||
text = "$it ${stringResource(id = R.string.sats)}",
|
||||
fontSize = 25.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
modifier = Modifier
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import coil.fetch.SourceResult
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.Options
|
||||
import coil.size.Size
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import okio.Buffer
|
||||
import java.security.MessageDigest
|
||||
|
||||
@@ -21,8 +22,6 @@ private fun toHex(color: Color): String {
|
||||
return String.format("#%06X", rgb)
|
||||
}
|
||||
|
||||
private val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
private fun byteMod10(byte: Byte): Int {
|
||||
val ub = byte.toUByte().toInt()
|
||||
return ub % 10
|
||||
@@ -33,7 +32,7 @@ private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
|
||||
}
|
||||
|
||||
private fun svgString(msg: String): String {
|
||||
val hash = sha256.digest(msg.toByteArray())
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(msg.toByteArray())
|
||||
val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) }
|
||||
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
|
||||
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
|
||||
@@ -67,6 +66,7 @@ class HashImageFetcher(
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): SourceResult {
|
||||
checkNotInMainThread()
|
||||
val source = try {
|
||||
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
|
||||
} finally {
|
||||
|
||||
@@ -31,13 +31,14 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(
|
||||
label: String,
|
||||
placeholder: String,
|
||||
options: List<String>,
|
||||
explainers: List<String>? = null,
|
||||
options: ImmutableList<String>,
|
||||
explainers: ImmutableList<String>? = null,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -83,7 +84,12 @@ fun TextSpinner(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
fun SpinnerSelectionDialog(
|
||||
options: ImmutableList<String>,
|
||||
explainers: ImmutableList<String>?,
|
||||
onDismiss: () -> Unit,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
border = BorderStroke(0.25.dp, Color.LightGray),
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -32,12 +31,11 @@ fun UrlPreview(url: String, urlText: String) {
|
||||
|
||||
mutableStateOf(default)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
|
||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -22,7 +21,6 @@ import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import java.net.URL
|
||||
|
||||
@Composable
|
||||
fun UrlPreviewCard(
|
||||
@@ -42,26 +40,15 @@ fun UrlPreviewCard(
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
val validatedUrl = remember(url) { URL(previewInfo.url) }
|
||||
|
||||
// correctly treating relative images
|
||||
val imageUrl = remember(url) {
|
||||
if (previewInfo.image.startsWith("/")) {
|
||||
URL(validatedUrl, previewInfo.image).toString()
|
||||
} else {
|
||||
previewInfo.image
|
||||
}
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
|
||||
model = previewInfo.imageUrlFullPath,
|
||||
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Text(
|
||||
text = validatedUrl.host,
|
||||
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
|
||||
@Immutable
|
||||
sealed class UrlPreviewState {
|
||||
object Loading : UrlPreviewState()
|
||||
class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -26,11 +25,10 @@ import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -46,7 +44,6 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import coil.imageLoader
|
||||
@@ -54,7 +51,6 @@ import coil.request.ImageRequest
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
@@ -62,35 +58,17 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
public var DefaultMutedSetting = mutableStateOf(true)
|
||||
|
||||
@Composable
|
||||
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
if (localFile != null) {
|
||||
val video = remember(localFile) { localFile.toUri() }
|
||||
|
||||
VideoView(video, description, null, onDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val video = remember(videoUri) { Uri.parse(videoUri) }
|
||||
|
||||
VideoView(video, description, null, onDialog)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
try {
|
||||
val request = ImageRequest.Builder(context).data(thumbUri).build()
|
||||
val myCover = context.imageLoader.execute(request).drawable
|
||||
@@ -108,38 +86,37 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
|
||||
|
||||
if (loadingFinished.first) {
|
||||
if (loadingFinished.second != null) {
|
||||
val video = remember(videoUri) { Uri.parse(videoUri) }
|
||||
|
||||
VideoView(video, description, loadingFinished.second, onDialog)
|
||||
VideoView(videoUri, description, VideoThumb(loadingFinished.second), onDialog)
|
||||
} else {
|
||||
VideoView(videoUri, description, onDialog)
|
||||
VideoView(videoUri, description, null, onDialog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
fun VideoView(videoUri: String, description: String? = null, thumb: VideoThumb? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
|
||||
|
||||
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
|
||||
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
|
||||
val defaultVolume = remember { if (DefaultMutedSetting.value) 0f else 1f }
|
||||
|
||||
val exoPlayer = remember(videoUri) {
|
||||
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
|
||||
|
||||
description?.let {
|
||||
mediaBuilder.setMediaMetadata(
|
||||
MediaMetadata.Builder().setDisplayTitle(it).build()
|
||||
)
|
||||
LaunchedEffect(key1 = videoUri) {
|
||||
if (exoPlayerData == null) {
|
||||
launch(Dispatchers.Default) {
|
||||
exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val media = mediaBuilder.build()
|
||||
exoPlayerData?.let {
|
||||
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
|
||||
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
it.exoPlayer.apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
|
||||
volume = 0f
|
||||
if (videoUri.scheme?.startsWith("file") == true) {
|
||||
volume = defaultVolume
|
||||
if (videoUri.startsWith("file") == true) {
|
||||
setMediaItem(media)
|
||||
} else {
|
||||
setMediaSource(
|
||||
@@ -150,55 +127,15 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
|
||||
RenderVideoPlayer(it, thumb, onDialog)
|
||||
}
|
||||
|
||||
DisposableEffect(
|
||||
BoxWithConstraints() {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
.align(Alignment.Center)
|
||||
.onVisibilityChanges { visible ->
|
||||
if (visible && !exoPlayer.isPlaying) {
|
||||
exoPlayer.play()
|
||||
} else if (!visible && exoPlayer.isPlaying) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
},
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
thumb?.let { defaultArtwork = thumb }
|
||||
hideController()
|
||||
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton(mutedInstance) {
|
||||
mutedInstance.value = !mutedInstance.value
|
||||
DefaultMutedSetting.value = mutedInstance.value
|
||||
|
||||
exoPlayer.volume = if (mutedInstance.value) 0f else 1f
|
||||
}
|
||||
}
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
exoPlayer.pause()
|
||||
exoPlayerData?.exoPlayer?.pause()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
@@ -207,24 +144,82 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
|
||||
lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
exoPlayerData?.exoPlayer?.release()
|
||||
lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class VideoPlayer(
|
||||
val exoPlayer: ExoPlayer
|
||||
)
|
||||
|
||||
@Stable
|
||||
data class VideoThumb(
|
||||
val thumb: Drawable?
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RenderVideoPlayer(
|
||||
playerData: VideoPlayer,
|
||||
thumbData: VideoThumb?,
|
||||
onDialog: ((Boolean) -> Unit)?
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
BoxWithConstraints() {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
.align(Alignment.Center)
|
||||
.onVisibilityChanges { visible ->
|
||||
if (visible && !playerData.exoPlayer.isPlaying) {
|
||||
playerData.exoPlayer.play()
|
||||
} else if (!visible && playerData.exoPlayer.isPlaying) {
|
||||
playerData.exoPlayer.pause()
|
||||
}
|
||||
},
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = playerData.exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
thumbData?.thumb?.let { defaultArtwork = it }
|
||||
hideController()
|
||||
resizeMode =
|
||||
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
playerData.exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton() { mute: Boolean ->
|
||||
DefaultMutedSetting.value = mute
|
||||
|
||||
playerData.exoPlayer.volume = if (mute) 0f else 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
|
||||
val view = LocalView.current
|
||||
var isVisible: Boolean? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(isVisible) {
|
||||
onVisibilityChanges(isVisible == true)
|
||||
}
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
val newIsVisible = coordinates.isCompletelyVisible(view)
|
||||
if (isVisible != newIsVisible) {
|
||||
isVisible = newIsVisible
|
||||
onVisibilityChanges(isVisible == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,7 +241,7 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteButton(muted: MutableState<Boolean>, toggle: () -> Unit) {
|
||||
private fun MuteButton(toggle: (Boolean) -> Unit) {
|
||||
Box(
|
||||
remember {
|
||||
Modifier
|
||||
@@ -263,11 +258,16 @@ private fun MuteButton(muted: MutableState<Boolean>, toggle: () -> Unit) {
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
|
||||
|
||||
IconButton(
|
||||
onClick = toggle,
|
||||
onClick = {
|
||||
mutedInstance.value = !mutedInstance.value
|
||||
toggle(mutedInstance.value)
|
||||
},
|
||||
modifier = Modifier.size(50.dp)
|
||||
) {
|
||||
if (muted.value) {
|
||||
if (mutedInstance.value) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeOff,
|
||||
"Hash Verified",
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -36,6 +38,7 @@ import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Report
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -61,11 +64,10 @@ import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.net.toUri
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
@@ -74,6 +76,8 @@ import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.theme.Nip05
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -82,11 +86,13 @@ import net.engawapg.lib.zoomable.zoomable
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
@Immutable
|
||||
abstract class ZoomableContent(
|
||||
val description: String? = null,
|
||||
val dim: String? = null
|
||||
)
|
||||
|
||||
@Immutable
|
||||
abstract class ZoomableUrlContent(
|
||||
val url: String,
|
||||
description: String? = null,
|
||||
@@ -95,6 +101,7 @@ abstract class ZoomableUrlContent(
|
||||
val uri: String? = null
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
@Immutable
|
||||
class ZoomableUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
@@ -104,6 +111,7 @@ class ZoomableUrlImage(
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
class ZoomableUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
@@ -112,6 +120,7 @@ class ZoomableUrlVideo(
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
@Immutable
|
||||
abstract class ZoomablePreloadedContent(
|
||||
val localFile: File?,
|
||||
description: String? = null,
|
||||
@@ -121,6 +130,7 @@ abstract class ZoomablePreloadedContent(
|
||||
val uri: String
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
@Immutable
|
||||
class ZoomableLocalImage(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
@@ -131,6 +141,7 @@ class ZoomableLocalImage(
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
@Immutable
|
||||
class ZoomableLocalVideo(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
@@ -156,7 +167,7 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
|
||||
fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList()) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// store the dialog open or close state
|
||||
@@ -193,7 +204,10 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
|
||||
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
|
||||
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
|
||||
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
|
||||
is ZoomableLocalVideo ->
|
||||
content.localFile?.let {
|
||||
VideoView(it.toUri().toString(), content.description) { dialogOpen = true }
|
||||
}
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
@@ -201,17 +215,18 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun LocalImageView(
|
||||
content: ZoomableLocalImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
// store the dialog open or close state
|
||||
var imageLoadingState by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
val myModifier = remember {
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
@@ -233,17 +248,25 @@ private fun LocalImageView(
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
onError = {
|
||||
if (imageLoadingState != false) {
|
||||
imageLoadingState = false
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
if (imageLoadingState != true) {
|
||||
imageLoadingState = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
|
||||
if (imageLoadingState == true) {
|
||||
if (content.isVerified != null) {
|
||||
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
} else if (imageLoadingState == false || content.localFile == null || !content.localFile.exists()) {
|
||||
BlankNote()
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
@@ -253,39 +276,29 @@ private fun LocalImageView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error || content.localFile == null || !content.localFile.exists()) {
|
||||
BlankNote()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageLoadingStatus by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
val myModifier = remember {
|
||||
mainImageModifier
|
||||
.widthIn(max = maxWidth)
|
||||
@@ -306,16 +319,29 @@ private fun UrlImageView(
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
onError = {
|
||||
if (imageLoadingStatus != false) {
|
||||
imageLoadingStatus = false
|
||||
}
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
if (verifiedHash == null) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
if (imageLoadingStatus != true) {
|
||||
imageLoadingStatus = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
if (imageLoadingStatus == true) {
|
||||
verifiedHash?.let {
|
||||
HashVerificationSymbol(it, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
} else if (imageLoadingStatus == false) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
@@ -325,10 +351,6 @@ private fun UrlImageView(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,10 +372,9 @@ private fun aspectRatio(dim: String?): Float? {
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
launch(Dispatchers.IO) {
|
||||
delay(200)
|
||||
cnt = content
|
||||
}
|
||||
@@ -412,10 +433,12 @@ private fun DisplayBlurHash(
|
||||
|
||||
val context = LocalContext.current
|
||||
AsyncImage(
|
||||
model = BlurHashRequester.imageRequest(
|
||||
context,
|
||||
blurhash
|
||||
),
|
||||
model = remember {
|
||||
BlurHashRequester.imageRequest(
|
||||
context,
|
||||
blurhash
|
||||
)
|
||||
},
|
||||
contentDescription = description,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier
|
||||
@@ -424,7 +447,7 @@ private fun DisplayBlurHash(
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
|
||||
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
@@ -489,7 +512,9 @@ fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
LocalImageView(content = content, mainImageModifier = mainModifier)
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.localFile, content.description)
|
||||
content.localFile?.let {
|
||||
VideoView(it.toUri().toString(), content.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,9 +539,7 @@ private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean?
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
|
||||
if (verifiedHash == null) return
|
||||
|
||||
private fun HashVerificationSymbol(verifiedHash: Boolean, modifier: Modifier) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var channelId: String? = null
|
||||
|
||||
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) {
|
||||
this.account = accountLoggedIn
|
||||
this.channelId = channelId
|
||||
}
|
||||
|
||||
class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val processingChannel = channelId ?: return emptyList()
|
||||
val channel = LocalCache.getOrCreateChannel(processingChannel)
|
||||
|
||||
return channel.notes
|
||||
.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
@@ -26,9 +15,6 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
val processingChannel = channelId ?: return emptySet()
|
||||
val channel = LocalCache.getOrCreateChannel(processingChannel)
|
||||
|
||||
return collection
|
||||
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
|
||||
.toSet()
|
||||
|
||||
@@ -1,51 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var withUser: String? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
account = accountIn
|
||||
withUser = userId
|
||||
}
|
||||
|
||||
class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val processingUser = withUser ?: return emptyList()
|
||||
|
||||
val myAccount = account
|
||||
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
|
||||
|
||||
if (myAccount == null || myUser == null) return emptyList()
|
||||
|
||||
val messages = myAccount
|
||||
val messages = account
|
||||
.userProfile()
|
||||
.privateChatrooms[myUser] ?: return emptyList()
|
||||
.privateChatrooms[withUser] ?: return emptyList()
|
||||
|
||||
return messages.roomMessages
|
||||
.filter { myAccount.isAcceptable(it) }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
val processingUser = withUser ?: return emptySet()
|
||||
|
||||
val myAccount = account
|
||||
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
|
||||
|
||||
if (myAccount == null || myUser == null) return emptySet()
|
||||
|
||||
val messages = myAccount
|
||||
val messages = account
|
||||
.userProfile()
|
||||
.privateChatrooms[myUser] ?: return emptySet()
|
||||
.privateChatrooms[withUser] ?: return emptySet()
|
||||
|
||||
return collection
|
||||
.filter { it in messages.roomMessages && account?.isAcceptable(it) == true }
|
||||
.filter { it in messages.roomMessages && account.isAcceptable(it) == true }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -10,8 +9,7 @@ import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
object ChatroomListKnownFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
@@ -85,7 +83,7 @@ object ChatroomListKnownFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
sort(myNewList.toSet()).take(1000)
|
||||
}
|
||||
|
||||
Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
|
||||
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
|
||||
return feed
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.updated
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
object ChatroomListNewFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = ChatroomListKnownFeedFilter.account.followingKeySet()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val privateChatrooms = account.userProfile().privateChatrooms
|
||||
val messagingWith = privateChatrooms.keys.filter {
|
||||
@@ -27,4 +31,80 @@ object ChatroomListNewFeedFilter : FeedFilter<Note>() {
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
val me = account.userProfile()
|
||||
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
|
||||
if (newRelevantPrivateMessages.isEmpty()) {
|
||||
return oldList
|
||||
}
|
||||
|
||||
var myNewList = oldList
|
||||
|
||||
newRelevantPrivateMessages.forEach { newNotePair ->
|
||||
oldList.forEach { oldNote ->
|
||||
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
|
||||
|
||||
if (
|
||||
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
|
||||
) {
|
||||
myNewList = myNewList.updated(oldNote, newNotePair.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort(myNewList.toSet()).take(1000)
|
||||
}
|
||||
|
||||
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
|
||||
return feed
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
// Gets the latest message by room from the new items.
|
||||
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
|
||||
|
||||
return if (newRelevantPrivateMessages.isEmpty()) {
|
||||
emptySet()
|
||||
} else {
|
||||
newRelevantPrivateMessages.values.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
|
||||
val me = account.userProfile()
|
||||
val followingKeySet = account.followingKeySet()
|
||||
|
||||
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
|
||||
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
|
||||
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
|
||||
val roomUser = roomUserHex?.let { LocalCache.users[it] }
|
||||
|
||||
if (roomUserHex != null &&
|
||||
(newNote.author?.pubkeyHex != me.pubkeyHex && roomUserHex !in followingKeySet && !me.hasSentMessagesTo(roomUser)) &&
|
||||
!account.isHidden(roomUserHex)
|
||||
) {
|
||||
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
|
||||
if (lastNote != null) {
|
||||
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
|
||||
newRelevantPrivateMessages.put(roomUserHex, newNote)
|
||||
}
|
||||
} else {
|
||||
newRelevantPrivateMessages.put(roomUserHex, newNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newRelevantPrivateMessages
|
||||
}
|
||||
|
||||
override fun sort(collection: Set<Note>): List<Note> {
|
||||
return collection
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
abstract class FeedFilter<T> {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun loadTop(): List<T> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
feed()
|
||||
}
|
||||
@@ -24,6 +27,8 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
|
||||
checkNotInMainThread()
|
||||
|
||||
val (feed, elapsed) = measureTimedValue {
|
||||
val newItemsToBeAdded = applyFilter(newItems)
|
||||
if (newItemsToBeAdded.isNotEmpty()) {
|
||||
@@ -34,7 +39,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
|
||||
}
|
||||
}
|
||||
|
||||
Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
|
||||
// Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
|
||||
return feed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
class GlobalFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object HiddenAccountsFeedFilter : FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
|
||||
|
||||
override fun feed() = account.hiddenUsers()
|
||||
override fun feed(): List<User> {
|
||||
return (account.hiddenUsers + account.transientHiddenUsers)
|
||||
.map { LocalCache.getOrCreateUser(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
|
||||
|
||||
@@ -7,9 +7,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
return sort(innerApplyFilter(LocalCache.notes.values))
|
||||
}
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.ThreadAssembler
|
||||
|
||||
object ThreadFeedFilter : FeedFilter<Note>() {
|
||||
var noteId: String? = null
|
||||
@Immutable
|
||||
class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
|
||||
val eventsToWatch = noteId?.let { ThreadAssembler().findThreadFor(it) } ?: emptySet()
|
||||
val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
|
||||
// Currently orders by date of each event, descending, at each level of the reply stack
|
||||
val order = compareByDescending<Note> { it.replyLevelSignature(cachedSignatures) }
|
||||
|
||||
return eventsToWatch.sortedWith(order)
|
||||
}
|
||||
|
||||
fun loadThread(noteId: String?) {
|
||||
this.noteId = noteId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
|
||||
|
||||
class UserProfileAppRecommendationsFeedFilter(val user: User) : FeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
val recommendations = LocalCache.addressables.values.filter {
|
||||
(it.event as? AppRecommendationEvent)?.pubKey == user.pubkeyHex
|
||||
}.mapNotNull {
|
||||
(it.event as? AppRecommendationEvent)?.recommendations()
|
||||
}.flatten()
|
||||
.mapNotNull {
|
||||
LocalCache.getOrCreateAddressableNote(it)
|
||||
}.toSet().toList()
|
||||
|
||||
return recommendations
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,9 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object UserProfileFollowersFeedFilter : FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
}
|
||||
class UserProfileFollowersFeedFilter(val user: User, val account: Account) : FeedFilter<User>() {
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.let { myUser ->
|
||||
LocalCache.users.values.filter { it.isFollowing(myUser) && account.isAcceptable(it) }
|
||||
} ?: emptyList()
|
||||
return LocalCache.users.values.filter { it.isFollowing(user) && !account.isHidden(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,25 @@ package com.vitorpamplona.amethyst.ui.dal
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
|
||||
object UserProfileFollowsFeedFilter : FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
class UserProfileFollowsFeedFilter(val user: User, val account: Account) : FeedFilter<User>() {
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
this.user = user
|
||||
}
|
||||
val cache: MutableMap<ContactListEvent, List<User>> = mutableMapOf()
|
||||
|
||||
override fun feed(): List<User> {
|
||||
return user?.latestContactList?.unverifiedFollowKeySet()?.mapNotNull {
|
||||
LocalCache.checkGetOrCreateUser(it)
|
||||
}?.toSet()
|
||||
?.filter { account.isAcceptable(it) }
|
||||
val contactList = user.latestContactList ?: return emptyList()
|
||||
|
||||
val previousList = cache[contactList]
|
||||
if (previousList != null) return previousList
|
||||
|
||||
cache[contactList] = user.latestContactList
|
||||
?.unverifiedFollowKeySet()?.mapNotNull {
|
||||
LocalCache.checkGetOrCreateUser(it)
|
||||
}?.toSet()
|
||||
?.filter { !account.isHidden(it) }
|
||||
?.reversed() ?: emptyList()
|
||||
|
||||
return cache[contactList] ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.AppRecommendationEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
|
||||
@@ -18,7 +19,7 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent) }
|
||||
.filter { it.author == user && (it.event !is PeopleListEvent && it.event !is BookmarkListEvent && it.event !is AppRecommendationEvent) }
|
||||
|
||||
return user?.notes
|
||||
?.plus(longFormNotes)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
|
||||
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
|
||||
var user: User? = null
|
||||
@@ -11,10 +13,12 @@ object UserProfileReportsFeedFilter : FeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
return user?.reports
|
||||
?.values
|
||||
?.flatten()
|
||||
?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
?.reversed() ?: emptyList()
|
||||
val myUser = user ?: return emptyList()
|
||||
|
||||
val reportNotes = LocalCache.notes.values.filter { (it.event as? ReportEvent)?.isTaggedUser(myUser.pubkeyHex) == true }
|
||||
|
||||
return reportNotes
|
||||
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
|
||||
|
||||
object UserProfileZapsFeedFilter : FeedFilter<Pair<Note, Note>>() {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Pair<Note, Note>> {
|
||||
return UserZaps.forProfileFeed(user?.zaps)
|
||||
class UserProfileZapsFeedFilter(val user: User) : FeedFilter<ZapReqResponse>() {
|
||||
override fun feed(): List<ZapReqResponse> {
|
||||
return UserZaps.forProfileFeed(user.zaps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
|
||||
object VideoFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
|
||||
override fun feed(): List<Note> {
|
||||
val notes = innerApplyFilter(LocalCache.notes.values)
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Logout
|
||||
import androidx.compose.material.icons.filled.RadioButtonChecked
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
@@ -37,14 +39,18 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
@@ -52,6 +58,8 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AccountSwitchBottomSheet(
|
||||
@@ -59,11 +67,6 @@ fun AccountSwitchBottomSheet(
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
) {
|
||||
val accounts = LocalPreferences.allSavedAccounts()
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
@@ -79,108 +82,7 @@ fun AccountSwitchBottomSheet(
|
||||
Text(stringResource(R.string.account_switch_select_account), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
accounts.forEach { acc ->
|
||||
val current = accountUser.pubkeyNpub() == acc.npub
|
||||
|
||||
val baseUser = try {
|
||||
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (baseUser != null) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable {
|
||||
accountStateViewModel.switchUser(acc.npub)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp, 16.dp)
|
||||
.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = user.pubkeyHex,
|
||||
model = ResizeImage(user.profilePicture(), 55.dp),
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)/*
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
if (acc.hasPrivKey) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Key,
|
||||
contentDescription = stringResource(R.string.account_switch_has_private_key),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = stringResource(R.string.account_switch_pubkey_only),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}*/
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
val npubShortHex = acc.npub.toShortenHex()
|
||||
|
||||
user.bestDisplayName()?.let {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = user.info?.latestMetadata?.tags
|
||||
)
|
||||
}
|
||||
|
||||
Text(npubShortHex)
|
||||
}
|
||||
Column(modifier = Modifier.width(32.dp)) {
|
||||
if (current) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RadioButtonChecked,
|
||||
contentDescription = stringResource(R.string.account_switch_active_account),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { accountStateViewModel.logOff(acc.npub) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Logout,
|
||||
contentDescription = stringResource(R.string.log_out),
|
||||
tint = MaterialTheme.colors.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DisplayAccount(acc, accountViewModel, accountStateViewModel)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -222,3 +124,140 @@ fun AccountSwitchBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayAccount(
|
||||
acc: AccountInfo,
|
||||
accountViewModel: AccountViewModel,
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
) {
|
||||
var baseUser by remember { mutableStateOf<User?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = acc.npub) {
|
||||
launch(Dispatchers.IO) {
|
||||
baseUser = try {
|
||||
LocalCache.getOrCreateUser(decodePublicKey(acc.npub).toHexKey())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseUser?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
accountStateViewModel.switchUser(acc.npub)
|
||||
}.padding(16.dp, 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.width(55.dp).padding(0.dp)
|
||||
) {
|
||||
AccountPicture(it)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
AccountName(acc, it)
|
||||
}
|
||||
Column(modifier = Modifier.width(32.dp)) {
|
||||
ActiveMarker(acc, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LogoutButton(acc, accountStateViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveMarker(acc: AccountInfo, accountViewModel: AccountViewModel) {
|
||||
val isCurrentUser by remember(accountViewModel) {
|
||||
derivedStateOf {
|
||||
accountViewModel.account.userProfile().pubkeyNpub() == acc.npub
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrentUser) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RadioButtonChecked,
|
||||
contentDescription = stringResource(R.string.account_switch_active_account),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountPicture(user: User) {
|
||||
val userState by user.live().metadata.observeAsState()
|
||||
val profilePicture by remember(userState) {
|
||||
derivedStateOf {
|
||||
ResizeImage(userState?.user?.profilePicture(), 55.dp)
|
||||
}
|
||||
}
|
||||
|
||||
RobohashAsyncImageProxy(
|
||||
robot = remember(user) { user.pubkeyHex },
|
||||
model = profilePicture,
|
||||
contentDescription = stringResource(R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.height(55.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountName(
|
||||
acc: AccountInfo,
|
||||
user: User
|
||||
) {
|
||||
val userState by user.live().metadata.observeAsState()
|
||||
val displayName by remember(userState) {
|
||||
derivedStateOf {
|
||||
user.bestDisplayName()
|
||||
}
|
||||
}
|
||||
val tags by remember(userState) {
|
||||
derivedStateOf {
|
||||
user.info?.latestMetadata?.tags?.toImmutableListOfLists()
|
||||
}
|
||||
}
|
||||
|
||||
displayName?.let {
|
||||
CreateTextWithEmoji(
|
||||
text = it,
|
||||
tags = tags,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = remember(user) { acc.npub.toShortenHex() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogoutButton(
|
||||
acc: AccountInfo,
|
||||
accountStateViewModel: AccountStateViewModel
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { accountStateViewModel.logOff(acc.npub) }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Logout,
|
||||
contentDescription = stringResource(R.string.log_out),
|
||||
tint = MaterialTheme.colors.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -37,8 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -67,11 +67,16 @@ fun keyboardAsState(): State<Keyboard> {
|
||||
view.getWindowVisibleDisplayFrame(rect)
|
||||
val screenHeight = view.rootView.height
|
||||
val keypadHeight = screenHeight - rect.bottom
|
||||
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
|
||||
|
||||
val newKeyboardValue = if (keypadHeight > screenHeight * 0.15) {
|
||||
Keyboard.Opened
|
||||
} else {
|
||||
Keyboard.Closed
|
||||
}
|
||||
|
||||
if (newKeyboardValue != keyboardState.value) {
|
||||
keyboardState.value = newKeyboardValue
|
||||
}
|
||||
}
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
|
||||
|
||||
@@ -84,21 +89,30 @@ fun keyboardAsState(): State<Keyboard> {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) {
|
||||
fun AppBottomBar(accountViewModel: AccountViewModel, navEntryState: State<NavBackStackEntry?>, nav: (Route, Boolean) -> Unit) {
|
||||
val isKeyboardOpen by keyboardAsState()
|
||||
if (isKeyboardOpen == Keyboard.Closed) {
|
||||
Column() {
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
BottomNavigation(
|
||||
modifier = Modifier,
|
||||
elevation = 0.dp,
|
||||
backgroundColor = MaterialTheme.colors.background
|
||||
) {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
HasNewItemsIcon(item, accountViewModel, navController)
|
||||
}
|
||||
RenderBottomMenu(accountViewModel, navEntryState, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBottomMenu(
|
||||
accountViewModel: AccountViewModel,
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
nav: (Route, Boolean) -> Unit
|
||||
) {
|
||||
Column() {
|
||||
Divider(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
BottomNavigation(
|
||||
modifier = Modifier,
|
||||
elevation = 0.dp,
|
||||
backgroundColor = MaterialTheme.colors.background
|
||||
) {
|
||||
bottomNavigationItems.forEach { item ->
|
||||
HasNewItemsIcon(item, accountViewModel, navEntryState, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,66 +122,56 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
||||
private fun RowScope.HasNewItemsIcon(
|
||||
route: Route,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavHostController
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
nav: (Route, Boolean) -> Unit
|
||||
) {
|
||||
var hasNewItems by remember { mutableStateOf(false) }
|
||||
|
||||
WatchPossibleNotificationChanges(route, accountViewModel) {
|
||||
if (it != hasNewItems) {
|
||||
hasNewItems = it
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val notifState by NotificationCache.live.observeAsState()
|
||||
val notif = remember(notifState) { notifState?.cache } ?: return
|
||||
|
||||
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notifState, key2 = accountState) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val newHasNewItems = route.hasNewItems(account, notif, emptySet())
|
||||
if (newHasNewItems != hasNewItems) {
|
||||
hasNewItems = newHasNewItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(accountState) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
val newHasNewItems = route.hasNewItems(account, notif, it)
|
||||
if (newHasNewItems != hasNewItems) {
|
||||
hasNewItems = newHasNewItems
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BottomIcon(
|
||||
icon = route.icon,
|
||||
size = if ("Home" == route.base) 25.dp else 23.dp,
|
||||
iconSize = if ("Home" == route.base) 24.dp else 20.dp,
|
||||
base = route.base,
|
||||
hasNewItems = hasNewItems,
|
||||
navController
|
||||
navEntryState = navEntryState
|
||||
) { selected ->
|
||||
scope.launch {
|
||||
if (!selected) {
|
||||
navController.navigate(route.base) {
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start)
|
||||
restoreState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
} else {
|
||||
val newRoute = route.route.replace("{scrollToTop}", "true")
|
||||
navController.navigate(newRoute) {
|
||||
navController.graph.startDestinationRoute?.let { start ->
|
||||
popUpTo(start) { inclusive = route.route == Route.Home.route }
|
||||
restoreState = true
|
||||
}
|
||||
nav(route, selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
@Composable
|
||||
fun WatchPossibleNotificationChanges(
|
||||
route: Route,
|
||||
accountViewModel: AccountViewModel,
|
||||
onChange: (Boolean) -> Unit
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val notifState by NotificationCache.live.observeAsState()
|
||||
val notif = remember(notifState) { notifState?.cache } ?: return
|
||||
|
||||
LaunchedEffect(key1 = notifState, key2 = accountState) {
|
||||
launch(Dispatchers.IO) {
|
||||
onChange(route.hasNewItems(account, notif, emptySet()))
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
launch(Dispatchers.IO) {
|
||||
onChange(route.hasNewItems(account, notif, it))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,55 +185,68 @@ private fun RowScope.BottomIcon(
|
||||
iconSize: Dp,
|
||||
base: String,
|
||||
hasNewItems: Boolean,
|
||||
navController: NavHostController,
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
onClick: (Boolean) -> Unit
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
|
||||
navBackStackEntry?.let {
|
||||
val selected = remember(it) {
|
||||
it.destination.route?.substringBefore("?") == base
|
||||
val selected by remember(navEntryState.value) {
|
||||
derivedStateOf {
|
||||
navEntryState.value?.destination?.route?.substringBefore("?") == base
|
||||
}
|
||||
|
||||
BottomNavigationItem(
|
||||
icon = {
|
||||
NotifiableIcon(
|
||||
icon,
|
||||
size,
|
||||
iconSize,
|
||||
selected,
|
||||
hasNewItems
|
||||
)
|
||||
},
|
||||
selected = selected,
|
||||
onClick = { onClick(selected) }
|
||||
)
|
||||
}
|
||||
|
||||
NavigationIcon(icon, size, iconSize, selected, hasNewItems, onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.NavigationIcon(
|
||||
icon: Int,
|
||||
size: Dp,
|
||||
iconSize: Dp,
|
||||
selected: Boolean,
|
||||
hasNewItems: Boolean,
|
||||
onClick: (Boolean) -> Unit
|
||||
) {
|
||||
BottomNavigationItem(
|
||||
icon = {
|
||||
NotifiableIcon(
|
||||
icon,
|
||||
size,
|
||||
iconSize,
|
||||
selected,
|
||||
hasNewItems
|
||||
)
|
||||
},
|
||||
selected = selected,
|
||||
onClick = { onClick(selected) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean, hasNewItems: Boolean) {
|
||||
Box(Modifier.size(size)) {
|
||||
Box(remember { Modifier.size(size) }) {
|
||||
Icon(
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = remember { Modifier.size(iconSize) },
|
||||
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
|
||||
)
|
||||
|
||||
if (hasNewItems) {
|
||||
Box(
|
||||
Modifier
|
||||
.width(10.dp)
|
||||
.height(10.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
remember {
|
||||
Modifier
|
||||
.width(10.dp)
|
||||
.height(10.dp)
|
||||
.clip(shape = CircleShape)
|
||||
.background(MaterialTheme.colors.primary),
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(10.dp)
|
||||
.height(10.dp)
|
||||
.clip(shape = CircleShape)
|
||||
}.background(MaterialTheme.colors.primary),
|
||||
contentAlignment = Alignment.TopEnd
|
||||
) {
|
||||
Text(
|
||||
@@ -237,9 +254,11 @@ private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.wrapContentHeight()
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
@@ -39,50 +31,26 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AppNavigation(
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
|
||||
newFeedViewModel: NostrChatroomListNewFeedViewModel,
|
||||
searchFeedViewModel: NostrGlobalFeedViewModel,
|
||||
videoFeedViewModel: NostrVideoFeedViewModel,
|
||||
notifFeedViewModel: NotificationViewModel,
|
||||
userReactionsStatsModel: UserReactionsViewModel,
|
||||
|
||||
navController: NavHostController,
|
||||
accountViewModel: AccountViewModel,
|
||||
nextPage: String? = null
|
||||
) {
|
||||
val homePagerState = rememberPagerState()
|
||||
var actionableNextPage by remember { mutableStateOf<String?>(nextPage) }
|
||||
|
||||
// Avoids creating ViewModels for performance reasons (up to 1 second delays)
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
val accountHex = remember(accountState) { accountState?.account?.userProfile()?.pubkeyHex }
|
||||
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
HomeConversationsFeedFilter.account = account
|
||||
|
||||
val homeFeedViewModel: NostrHomeFeedViewModel = viewModel()
|
||||
val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel()
|
||||
|
||||
GlobalFeedFilter.account = account
|
||||
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||
|
||||
VideoFeedFilter.account = account
|
||||
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
|
||||
|
||||
NotificationFeedFilter.account = account
|
||||
val notifFeedViewModel: NotificationViewModel = viewModel()
|
||||
|
||||
val userReactionsStatsModel: UserReactionsViewModel = viewModel()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(accountHex) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
userReactionsStatsModel.load(account.userProfile())
|
||||
userReactionsStatsModel.initializeSuspend()
|
||||
}
|
||||
}
|
||||
|
||||
val nav = remember {
|
||||
{ route: String ->
|
||||
if (getRouteWithArguments(navController) != route) {
|
||||
@@ -96,23 +64,16 @@ fun AppNavigation(
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
if (scrollToTop) {
|
||||
videoFeedViewModel.sendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
VideoScreen(
|
||||
videoFeedView = videoFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
scrollToTop = scrollToTop
|
||||
nav = nav
|
||||
)
|
||||
|
||||
// Avoids running scroll to top when back button is pressed
|
||||
// Changes this on a thread to avoid changing before it finishes the composition
|
||||
if (scrollToTop) {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -120,23 +81,16 @@ fun AppNavigation(
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
if (scrollToTop) {
|
||||
searchFeedViewModel.sendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
SearchScreen(
|
||||
searchFeedViewModel = searchFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
scrollToTop = scrollToTop
|
||||
nav = nav
|
||||
)
|
||||
|
||||
// Avoids running scroll to top when back button is pressed
|
||||
// Changes this on a thread to avoid changing before it finishes the composition
|
||||
if (scrollToTop) {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,28 +99,23 @@ fun AppNavigation(
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
val nip47 = it.arguments?.getString("nip47")
|
||||
|
||||
if (scrollToTop) {
|
||||
homeFeedViewModel.sendToTop()
|
||||
repliesFeedViewModel.sendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
HomeScreen(
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
repliesFeedViewModel = repliesFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
pagerState = homePagerState,
|
||||
scrollToTop = scrollToTop,
|
||||
nip47 = nip47
|
||||
)
|
||||
|
||||
// Avoids running scroll to top when back button is pressed
|
||||
if (scrollToTop) {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nip47 != null) {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
scope.launch {
|
||||
launch {
|
||||
delay(1000)
|
||||
it.arguments?.remove("nip47")
|
||||
}
|
||||
@@ -179,28 +128,33 @@ fun AppNavigation(
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
if (scrollToTop) {
|
||||
notifFeedViewModel.clear()
|
||||
notifFeedViewModel.sendToTop()
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
|
||||
NotificationScreen(
|
||||
notifFeedViewModel = notifFeedViewModel,
|
||||
userReactionsStatsModel = userReactionsStatsModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
scrollToTop = scrollToTop
|
||||
nav = nav
|
||||
)
|
||||
|
||||
// Avoids running scroll to top when back button is pressed
|
||||
// Changes this on a thread to avoid changing before it finishes the composition
|
||||
if (scrollToTop) {
|
||||
LaunchedEffect(key1 = Unit) {
|
||||
scope.launch {
|
||||
delay(1000)
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, nav) })
|
||||
composable(
|
||||
Route.Message.route,
|
||||
content = {
|
||||
ChatroomListScreen(
|
||||
knownFeedViewModel,
|
||||
newFeedViewModel,
|
||||
accountViewModel,
|
||||
nav
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
|
||||
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
|
||||
|
||||
@@ -258,7 +212,6 @@ fun AppNavigation(
|
||||
composable(route.route, route.arguments, content = {
|
||||
LoadRedirectScreen(
|
||||
eventId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
|
||||
@@ -26,6 +26,9 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -42,8 +45,10 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -51,7 +56,6 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
@@ -65,6 +69,7 @@ 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.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
@@ -72,17 +77,43 @@ import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
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.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
when (currentRoute(navController)?.substringBefore("?")) {
|
||||
fun AppTopBar(
|
||||
followLists: FollowListViewModel,
|
||||
navEntryState: State<NavBackStackEntry?>,
|
||||
scaffoldState: ScaffoldState,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val currentRoute by remember(navEntryState.value) {
|
||||
derivedStateOf {
|
||||
navEntryState.value?.destination?.route?.substringBefore("?")
|
||||
}
|
||||
}
|
||||
|
||||
RenderTopRouteBar(currentRoute, followLists, scaffoldState, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderTopRouteBar(
|
||||
currentRoute: String?,
|
||||
followLists: FollowListViewModel,
|
||||
scaffoldState: ScaffoldState,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
when (currentRoute) {
|
||||
// Route.Profile.route -> TopBarWithBackButton(nav)
|
||||
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
|
||||
@@ -93,27 +124,63 @@ fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController
|
||||
|
||||
@Composable
|
||||
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultStoriesFollowList(listName)
|
||||
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
val list by remember(accountState) {
|
||||
derivedStateOf {
|
||||
accountState?.account?.defaultStoriesFollowList ?: GLOBAL_FOLLOWS
|
||||
}
|
||||
}
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
true
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultStoriesFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName ->
|
||||
account.changeDefaultHomeFollowList(listName)
|
||||
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
val list by remember(accountState) {
|
||||
derivedStateOf {
|
||||
accountState?.account?.defaultHomeFollowList ?: GLOBAL_FOLLOWS
|
||||
}
|
||||
}
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
false
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultHomeFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultNotificationFollowList(listName)
|
||||
GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
|
||||
val list by remember(accountState) {
|
||||
derivedStateOf {
|
||||
accountState?.account?.defaultNotificationFollowList ?: GLOBAL_FOLLOWS
|
||||
}
|
||||
}
|
||||
|
||||
FollowList(
|
||||
followLists,
|
||||
list,
|
||||
true
|
||||
) { listName ->
|
||||
accountViewModel.account.changeDefaultNotificationFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,14 +194,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
|
||||
@OptIn(coil.annotation.ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
|
||||
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
|
||||
|
||||
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (AccountViewModel) -> Unit) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var wantsToEditRelays by remember {
|
||||
@@ -142,7 +202,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
|
||||
}
|
||||
|
||||
if (wantsToEditRelays) {
|
||||
NewRelayListView({ wantsToEditRelays = false }, account)
|
||||
NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
|
||||
}
|
||||
|
||||
Column() {
|
||||
@@ -163,54 +223,24 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
content(account)
|
||||
content(accountViewModel)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
RelayStatus(
|
||||
{ wantsToEditRelays = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
LoggedInUserPictureDrawer(account) {
|
||||
LoggedInUserPictureDrawer(accountViewModel) {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.open()
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { wantsToEditRelays = true },
|
||||
modifier = Modifier
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
RelayIcon { wantsToEditRelays = true }
|
||||
}
|
||||
)
|
||||
Divider(thickness = 0.25.dp)
|
||||
@@ -218,20 +248,102 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoggedInUserPictureDrawer(
|
||||
account: Account,
|
||||
private fun RelayStatus(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RelayStatus(relayViewModel, onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayStatus(
|
||||
relayViewModel: RelayPoolViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
|
||||
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
|
||||
|
||||
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
|
||||
derivedStateOf {
|
||||
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
|
||||
}
|
||||
}
|
||||
|
||||
val isConnected by remember(connectedRelaysLiveData) {
|
||||
derivedStateOf {
|
||||
(connectedRelaysLiveData.value ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
RenderRelayStatus(connectedRelaysText, isConnected, onClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderRelayStatus(
|
||||
connectedRelaysText: String,
|
||||
isConnected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Text(
|
||||
text = connectedRelaysText,
|
||||
color = if (isConnected) {
|
||||
MaterialTheme.colors.onSurface.copy(
|
||||
alpha = 0.32f
|
||||
)
|
||||
} else {
|
||||
Color.Red
|
||||
},
|
||||
style = MaterialTheme.typography.subtitle1,
|
||||
modifier = Modifier.clickable(
|
||||
onClick = onClick
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayIcon(onClick: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.relays),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoggedInUserPictureDrawer(
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
|
||||
|
||||
val pubkeyHex = remember { accountUserState?.user?.pubkeyHex ?: "" }
|
||||
val profilePicture = remember(accountUserState) { ResizeImage(accountUserState?.user?.profilePicture(), 34.dp) }
|
||||
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = accountUser.pubkeyHex,
|
||||
model = ResizeImage(accountUser.profilePicture(), 34.dp),
|
||||
robot = pubkeyHex,
|
||||
model = profilePicture,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(34.dp)
|
||||
@@ -242,39 +354,37 @@ private fun LoggedInUserPictureDrawer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
fun FollowList(followListsModel: FollowListViewModel, listName: String, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
|
||||
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
|
||||
|
||||
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
|
||||
|
||||
val followLists = remember(followListsModel.followLists) {
|
||||
(defaultOptions + followListsModel.followLists)
|
||||
val followLists by followListsModel.followLists.collectAsState()
|
||||
|
||||
val allLists = remember(followLists) {
|
||||
(defaultOptions + followLists)
|
||||
}
|
||||
|
||||
val followNames = remember(followLists) {
|
||||
val followNames by remember(followLists) {
|
||||
derivedStateOf {
|
||||
followLists.map { it.second }
|
||||
allLists.map { it.second }.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
SimpleTextSpinner(
|
||||
placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
|
||||
options = followNames.value,
|
||||
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
|
||||
options = followNames,
|
||||
onSelect = {
|
||||
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
|
||||
onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class FollowListViewModel : ViewModel() {
|
||||
var followLists by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
var account: Account? = null
|
||||
|
||||
fun load(account: Account?) {
|
||||
this.account = account
|
||||
refresh()
|
||||
}
|
||||
@Stable
|
||||
class FollowListViewModel(val account: Account) : ViewModel() {
|
||||
private var _followLists = MutableStateFlow<ImmutableList<Pair<String, String>>>(emptyList<Pair<String, String>>().toPersistentList())
|
||||
val followLists = _followLists.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
@@ -284,34 +394,35 @@ class FollowListViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun refreshFollows() {
|
||||
val myAccount = account ?: return
|
||||
checkNotInMainThread()
|
||||
|
||||
val newFollowLists = LocalCache.addressables.mapNotNull {
|
||||
val event = (it.value.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
|
||||
if (event != null &&
|
||||
event.pubKey == account.userProfile().pubkeyHex &&
|
||||
(event.tags.size > 1 || event.content.length > 50)
|
||||
) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }
|
||||
}.sortedBy { it.second }.toImmutableList()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (followLists != newFollowLists) {
|
||||
followLists = newFollowLists
|
||||
}
|
||||
if (!equalImmutableLists(_followLists.value, newFollowLists)) {
|
||||
_followLists.emit(newFollowLists)
|
||||
}
|
||||
}
|
||||
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
refresh()
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
newNotes.forEach {
|
||||
if (it.event is PeopleListEvent) {
|
||||
refresh()
|
||||
}
|
||||
checkNotInMainThread()
|
||||
if (newNotes.any { it.event is PeopleListEvent }) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,13 +432,19 @@ class FollowListViewModel : ViewModel() {
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
class Factory(val account: Account) : ViewModelProvider.Factory {
|
||||
override fun <FollowListViewModel : ViewModel> create(modelClass: Class<FollowListViewModel>): FollowListViewModel {
|
||||
return FollowListViewModel(account) as FollowListViewModel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTextSpinner(
|
||||
placeholder: String,
|
||||
options: List<String>,
|
||||
explainers: List<String>? = null,
|
||||
options: ImmutableList<String>,
|
||||
explainers: ImmutableList<String>? = null,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user