Compare commits

...

96 Commits

Author SHA1 Message Date
Vitor Pamplona
e28cd2212e v0.54.2 2023-06-02 22:46:45 -04:00
Vitor Pamplona
8f38353271 Shows comment and amount of zaps sent directly to the profile. 2023-06-02 22:45:58 -04:00
Vitor Pamplona
a41ee653d8 Moves reaction counts to a thread 2023-06-02 22:14:21 -04:00
Vitor Pamplona
839be06f62 Removes unnecessary mapping 2023-06-02 21:59:17 -04:00
Vitor Pamplona
bbef06de59 Moves UserFeed to MutableLists 2023-06-02 21:52:22 -04:00
Vitor Pamplona
515159f18c Ignores unloaded notes in the MultiComposeSet renderer. 2023-06-02 21:29:12 -04:00
Vitor Pamplona
e7b7ebbbdf Adds a new thread call for the notification of reaction rows 2023-06-02 21:28:50 -04:00
Vitor Pamplona
5965d9a3bb Moves profile watchers to their own compose functions 2023-06-02 21:28:30 -04:00
Vitor Pamplona
31959721b1 Moves reward calculations to the IO thread 2023-06-02 21:27:57 -04:00
Vitor Pamplona
8e16daaaa3 Only refreshes follow lists once per notification event. 2023-06-02 21:27:43 -04:00
Vitor Pamplona
e9fd47a536 Runs bottom's new notification refresher in the IO thread 2023-06-02 21:27:11 -04:00
Vitor Pamplona
6191ef9a6b Removes old notification cards 2023-06-02 20:36:01 -04:00
Vitor Pamplona
05aa12ebac - Removing outdated version of the FlowRow
- Improving performance of custom emojis
- Improving performance of DropDown menus
2023-06-02 20:30:49 -04:00
Vitor Pamplona
42428c5e0f Moves channel checks to happen before a Channel object is loaded. 2023-06-01 19:56:31 -04:00
Vitor Pamplona
1a3685c5ab v0.54.1 2023-06-01 16:07:08 -04:00
Vitor Pamplona
fff892e1c8 Making sure pollnote view models are unique 2023-06-01 16:03:22 -04:00
Vitor Pamplona
99c30816c6 Simplifying observables 2023-06-01 16:01:35 -04:00
Vitor Pamplona
92c61f317b Restructuring follows observables 2023-06-01 15:16:03 -04:00
Vitor Pamplona
b0e50e0c00 Fixes Translations 2023-06-01 09:11:47 -04:00
Vitor Pamplona
f065dcfc5a v0.54.0 2023-05-31 22:13:54 -04:00
Vitor Pamplona
91f34000f5 Improves rendering performance of the ExoPlayer 2023-05-31 22:09:18 -04:00
Vitor Pamplona
93e2af3c1f formatting 2023-05-31 21:32:53 -04:00
Vitor Pamplona
6072e5977e Reducing recompositions on images. 2023-05-31 21:32:46 -04:00
Vitor Pamplona
8575ea018d Improving rendering of the text 2023-05-31 20:43:57 -04:00
Vitor Pamplona
ed919ae531 UrlPreviewer using Main thread :( 2023-05-31 20:08:30 -04:00
Vitor Pamplona
3eb832c4e0 Improves Poll Rendering speed. 2023-05-31 20:08:07 -04:00
Vitor Pamplona
a57ca2a79d Using the correct immutable class 2023-05-31 16:33:23 -04:00
Vitor Pamplona
07a681c002 Merge remote-tracking branch 'origin/HEAD' 2023-05-31 16:31:55 -04:00
Vitor Pamplona
cb561d432c Merge pull request #433 from davotoula/add_per_app_language_preferences
Add support for per-app languange preferences
2023-05-31 16:31:19 -04:00
Vitor Pamplona
d942c12627 Restructuring ViewModels 2023-05-31 16:30:11 -04:00
David Kaspar
a466f33b4e Add support for per-app languange preferences 2023-05-31 09:34:21 +01:00
Vitor Pamplona
3744f90e87 Merge pull request #432 from davotoula/translation_sv
Added Swedish translation
2023-05-30 16:04:34 -04:00
Vitor Pamplona
1cc0f4bf80 Don't notify twice 2023-05-30 16:02:45 -04:00
David Kaspar
dec409c209 moved previous Swedish translation to a resource file
Corrected and added to Swedish translation
2023-05-30 20:58:50 +01:00
Vitor Pamplona
c664397b11 Starts video with the audio on/off based on global mute variable 2023-05-30 13:58:21 -04:00
Vitor Pamplona
bc1b71e9bf Improves reply info rendering 2023-05-30 13:58:00 -04:00
Vitor Pamplona
a42606af0d v0.53.7 2023-05-30 12:07:23 -04:00
Vitor Pamplona
b301e1d79b Removes sorting of the blocked list because it needs to load all metadata to sort correctly. 2023-05-30 12:07:15 -04:00
Vitor Pamplona
5ab94e8a1c - Renders the report's comment in the user profile report tab
- Allows reply, zap, boost and like the report itself.
- Shows report messages on notifications
2023-05-30 12:03:33 -04:00
Vitor Pamplona
7ad982ec71 v0.53.6 2023-05-29 21:44:11 -04:00
Vitor Pamplona
030064e112 Sorting blocked users by name 2023-05-29 21:43:59 -04:00
Vitor Pamplona
d9a392d2c1 BugFix for not loading the security filter screen 2023-05-29 21:40:44 -04:00
Vitor Pamplona
97cd1c5e30 v0.53.5 2023-05-29 21:15:10 -04:00
Vitor Pamplona
24a6d8a155 Restart Video Cache when chancing Tor Setup 2023-05-29 21:14:06 -04:00
Vitor Pamplona
ea185147b3 v0.54.4 2023-05-29 19:47:02 -04:00
Vitor Pamplona
f2db2b88c2 Improves rendering of Markdown summaries 2023-05-29 19:46:54 -04:00
Vitor Pamplona
6ea0972151 Fixes bug with rendering of markdown with user citations. 2023-05-29 19:20:33 -04:00
Vitor Pamplona
093705f3b6 v0.52.3 2023-05-29 18:06:42 -04:00
Vitor Pamplona
1972297755 adds flavor to the version descriptor in the bottom left of the drawer 2023-05-29 18:06:35 -04:00
Vitor Pamplona
ac00694690 Moves ExoPlayer to OkHttp in order to enable Tor support. 2023-05-29 18:00:29 -04:00
Vitor Pamplona
605861dc32 v0.53.2 2023-05-29 17:12:04 -04:00
Vitor Pamplona
43d508ba33 Correcting the user's room for the notification marker 2023-05-29 17:11:56 -04:00
Vitor Pamplona
e692647d58 v0.53.1 2023-05-29 16:26:16 -04:00
Vitor Pamplona
64f98e4b92 Filters Message Feed DAL to only take PrivateMessages in the Notification Bubble. 2023-05-29 16:25:35 -04:00
Vitor Pamplona
c58c20cc54 Adding immutable tag to ResizeImage class 2023-05-29 15:15:20 -04:00
Vitor Pamplona
89ddb7f4b7 v0.53.0 2023-05-29 15:05:01 -04:00
Vitor Pamplona
cb92a51191 Adds separate colors for the newItem background between light and dark themes 2023-05-29 14:58:10 -04:00
Vitor Pamplona
213b172c32 Merge pull request #431 from davotoula/add_translation_cs
Add Czech translations
2023-05-29 14:27:01 -04:00
Vitor Pamplona
cfdd470626 formatting 2023-05-29 14:25:43 -04:00
Vitor Pamplona
a5a3c62edd Moving chatroomlist to an additive filter 2023-05-29 14:25:34 -04:00
David Kaspar
c9bcfa1c17 added Czech translation 2023-05-29 18:36:51 +01:00
Vitor Pamplona
02ad85a740 Avoids running through the entire filter when collection is Empty 2023-05-28 17:53:08 -04:00
Vitor Pamplona
446273de80 Adds mute control per track on the screen. 2023-05-28 17:36:20 -04:00
Vitor Pamplona
b513877552 Optimizing UserProfile status 2023-05-28 17:33:01 -04:00
Vitor Pamplona
013eedd892 Removing logs 2023-05-28 17:32:48 -04:00
Vitor Pamplona
d2cbb27756 Separating logs for full feed updates and additive feed updates 2023-05-28 17:32:40 -04:00
Vitor Pamplona
1a477ea1c8 Refreshing user status when added to the transient spam list 2023-05-28 17:32:05 -04:00
Vitor Pamplona
83df639aff Merge pull request #430 from malimahda/main
changes to translation
2023-05-28 16:25:10 -04:00
Vitor Pamplona
f298cc6f13 Adds some documentation to make sure any NIP05 changes take into account that we are converting the entire json to lowercase. 2023-05-28 16:24:37 -04:00
Vitor Pamplona
300a70ce18 Merge remote-tracking branch 'origin/HEAD' 2023-05-28 16:09:54 -04:00
Vitor Pamplona
0f6bf1cc59 Merge pull request #428 from davotoula/bugfix_case_sensitive_nip05
Bugfix case sensitive nip05
2023-05-28 16:09:37 -04:00
Vitor Pamplona
6bc1b6b28a Loading reply from launch and not withContent 2023-05-28 16:03:15 -04:00
Vitor Pamplona
1c381f64f9 v0.52.3 2023-05-28 15:35:46 -04:00
Vitor Pamplona
5e3703e849 Moves all note states to leaf compositors 2023-05-28 15:34:13 -04:00
Vitor Pamplona
acbd41ce61 Better recomposition structure for ReactionsRow 2023-05-28 15:17:10 -04:00
Vitor Pamplona
056d00b73b Performance Improvements when rendering a channel creation note 2023-05-28 15:16:50 -04:00
L
72fc21cdc6 Update strings.xml
translated new strings
2023-05-28 21:22:33 +03:30
L
3e983cdf1c Update strings.xml
added new strings
2023-05-28 20:40:16 +03:30
L
d81af9a194 Merge branch 'vitorpamplona:main' into main 2023-05-28 17:59:35 +03:30
David Kaspar
7f6b44cd0b spelling fix 2023-05-27 23:51:13 +01:00
David Kaspar
88bad1e308 added additional trivial tests 2023-05-27 22:35:34 +01:00
David Kaspar
aa0a4d1aac Use lowercase versions of user name and retrieved json to make case insensitive match 2023-05-27 21:56:57 +01:00
David Kaspar
b0a61cce42 Added failing test
(a bit dirty using a spy for the actual "system under test" but it is the quickest way to test added functionality and mocking out complexity around coroutines and httpclient)
2023-05-27 21:42:12 +01:00
Vitor Pamplona
b3be4567e2 v0.52.2 2023-05-27 16:36:36 -04:00
Vitor Pamplona
c5a586ec56 Fixes Missing Notifications 2023-05-27 16:36:27 -04:00
Vitor Pamplona
ece3664e74 Merge remote-tracking branch 'origin/HEAD' 2023-05-27 16:34:42 -04:00
Vitor Pamplona
2297c797dc Merge pull request #427 from ZsZolee/main
Sensitive content Hungarian translation
2023-05-27 16:24:48 -04:00
Vitor Pamplona
4517ca9fdd trimming urls as well 2023-05-27 16:16:52 -04:00
Vitor Pamplona
1146b8ad54 v0.52.1 2023-05-27 16:06:15 -04:00
Vitor Pamplona
545643d165 Fixes Relay Icon rendering. 2023-05-27 16:04:20 -04:00
Zoltan
359e22ae42 Sensitive content Hungarian translation 2023-05-27 17:02:15 +02:00
Vitor Pamplona
69be28ec98 Making sure the UserReactonsRow in notifications get updated as new events come in. 2023-05-26 18:05:35 -04:00
Vitor Pamplona
a8ffbb922d Moves relay color filters to a singleton 2023-05-26 14:03:57 -04:00
L
459e579bcb Update strings.xml
changed some word to better choices
2023-05-20 20:46:04 +03:30
L
3c069d574e Merge branch 'vitorpamplona:main' into main 2023-05-20 20:38:19 +03:30
L
d2accf7622 Update strings.xml
a few words . not urgent
2023-05-16 19:22:04 +03:30
131 changed files with 5693 additions and 4436 deletions

View File

@@ -60,12 +60,12 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Audio Tracks (zapstr.live) (NIP-TBD)
- [x] Push Notifications (Zaps and Messages)
- [x] Generic Tags (NIP-12)
- [x] Sensitive Content (NIP-36)
- [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
- [ ] Sensitive Content (NIP-36)
- [ ] Relay Pages (NIP-11)
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
- [ ] Events with a Subject (NIP-14)

View File

@@ -13,13 +13,14 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 180
versionName "0.52.0"
versionCode 194
versionName "0.54.2"
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"

View File

@@ -10,7 +10,7 @@ fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
tags: List<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit

View File

@@ -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"

View File

@@ -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

View File

@@ -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))
}
}
}
}

View File

@@ -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())

View File

@@ -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)
}

View File

@@ -909,15 +909,7 @@ class Account(
fun decryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
var pubkeyToUse = event.pubKey
val recepientPK = event.verifiedRecipientPubKey()
if (note.author == userProfile() && recepientPK != null) {
pubkeyToUse = recepientPK
}
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
event.plainContent(loggedIn.privKey!!, event.talkingWith(userProfile().pubkeyHex).hexToByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
decryptZapContentAuthor(note)?.content()
} else {
@@ -1012,10 +1004,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()
}
@@ -1154,6 +1146,7 @@ class Account(
val userToBlock = LocalCache.getOrCreateUser(it.pubkeyHex)
if (userToBlock != userProfile() && userToBlock.pubkeyHex !in followingKeySet()) {
transientHiddenUsers = transientHiddenUsers + it.pubkeyHex
live.invalidateData()
}
}
}
@@ -1173,14 +1166,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() {

View File

@@ -55,14 +55,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))
}
}
}
}

View File

@@ -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() {

View File

@@ -429,8 +429,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 +507,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 +534,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)

View File

@@ -73,10 +73,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()
@@ -232,7 +228,7 @@ open class Note(val idHex: String) {
fun isZappedBy(user: User, account: Account): Boolean {
// Zaps who the requester was the user
return zaps.any {
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
it.key.author?.pubkeyHex == user.pubkeyHex || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
} || zapPayments.any {
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
val response = if (zapResponseEvent != null) {
@@ -245,11 +241,11 @@ open class Note(val idHex: String) {
}
fun isReactedBy(user: User): Boolean {
return reactions.any { it.author === user }
return reactions.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author === user }
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun reportsBy(user: User): Set<Note> {
@@ -414,14 +410,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() {

View File

@@ -3,10 +3,6 @@ package com.vitorpamplona.amethyst.model
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>()
@@ -25,22 +21,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()
}
}

View File

@@ -249,15 +249,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 +268,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,14 +294,14 @@ 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 }
}
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
return messagesToUser.roomMessages.any { this === it.author }
return messagesToUser.roomMessages.any { this.pubkeyHex == it.author?.pubkeyHex }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
@@ -400,14 +402,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() {

View File

@@ -75,14 +75,17 @@ class Nip05Verifier() {
fetchNip05Json(
nip05,
onSuccess = {
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
val nip05url = try {
mapper.readTree(it)
mapper.readTree(it.lowercase())
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val user = nip05.split("@")[0]
val user = nip05.split("@")[0].lowercase()
val hexKey = nip05url?.get("names")?.get(user)?.asText()

View File

@@ -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() {

View File

@@ -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
@@ -121,7 +122,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
),
ids = interestedEvents.toList()
)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -29,6 +29,10 @@ class PrivateDmEvent(
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
fun talkingWith(oneSideHex: String): HexKey {
return if (pubKey == oneSideHex) verifiedRecipientPubKey() ?: pubKey else pubKey
}
/**
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
*

View File

@@ -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,

View File

@@ -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())
}
/**

View File

@@ -5,7 +5,7 @@ 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) {
@@ -23,8 +23,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
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)
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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
@@ -28,7 +20,7 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.notifications.PushNotificationUtils
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.components.DefaultMutedSetting
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -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)
@@ -88,11 +63,11 @@ class MainActivity : FragmentActivity() {
override fun onResume() {
super.onResume()
// starts muted every time
muted.value = true
DefaultMutedSetting.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start()
ServiceManager.start(this@MainActivity)
}
PushNotificationUtils().init(LocalPreferences.allSavedAccounts())

View File

@@ -52,13 +52,13 @@ 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.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,9 +72,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -> Unit) {
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = account
searchBarViewModel.account = accountViewModel.account
Dialog(
onDismissRequest = {
@@ -115,7 +115,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, account, nav)
RenderSeach(searchBarViewModel, accountViewModel, nav)
}
}
}
@@ -125,7 +125,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, nav: (String) -
@Composable
private fun RenderSeach(
searchBarViewModel: SearchBarViewModel,
account: Account,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
@@ -145,7 +145,7 @@ private fun RenderSeach(
}
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
searchBarViewModel.invalidateData()
@@ -268,12 +268,12 @@ private fun RenderSeach(
state = listState
) {
itemsIndexed(
searchBarViewModel.searchResults.value,
searchBarViewModel.searchResultsUsers.value,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(
item,
account = account,
accountViewModel = accountViewModel,
nav = nav
)
}
@@ -305,7 +305,7 @@ private fun RenderSeach(
@Composable
fun UserComposeForChat(
baseUser: User,
account: Account,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Column(
@@ -323,7 +323,7 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, nav, account.userProfile(), 55.dp)
UserPicture(baseUser, nav, accountViewModel, 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {

View File

@@ -24,13 +24,13 @@ 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
@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() },

View File

@@ -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
@@ -117,8 +116,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)),
@@ -152,7 +149,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)

View File

@@ -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() {

View File

@@ -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()))
}*/

View File

@@ -24,7 +24,6 @@ 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
@@ -63,7 +62,6 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
@@ -78,7 +76,10 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val postViewModel: NewPostViewModel = viewModel()
val context = LocalContext.current
@@ -289,7 +290,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,
@@ -317,7 +318,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)
}
}
@@ -725,7 +726,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")
@@ -828,7 +828,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) {

View File

@@ -15,6 +15,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
@@ -73,7 +74,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 +102,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 +114,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,8 +129,8 @@ 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)
} else {

View File

@@ -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(

View File

@@ -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() {

View File

@@ -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(

View File

@@ -17,18 +17,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
fun NewNoteButton(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var wantsToPost by remember {
mutableStateOf(false)
}
if (wantsToPost) {
NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, nav = nav)
NewPostView({ wantsToPost = false }, accountViewModel = accountViewModel, nav = nav)
}
OutlinedButton(

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -14,6 +15,7 @@ import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import java.util.Base64
@Immutable
data class ResizeImage(val url: String?, val size: Dp) {
fun proxyUrl(): String? {
if (url == null) return null

View File

@@ -16,13 +16,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

View File

@@ -14,6 +14,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
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
@@ -45,8 +46,12 @@ 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.note.LoadChannel
import kotlinx.collections.immutable.ImmutableList
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(
@@ -73,43 +78,55 @@ private fun DisplayEvent(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
if (noteBase == null) {
launch(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() }
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
clickablePart = noteIdDisplayNote,
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 if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = "${nip19.additionalChars} ",
route = "Channel/${baseChannel.idHex}",
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Event/${nip19.hex}",
nav = nav
@@ -129,10 +146,10 @@ private fun DisplayNote(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
@@ -140,32 +157,42 @@ private fun DisplayNote(
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channel = note.channel()
val channelHex = note.channelHex()
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Channel/${nip19.hex}",
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
clickablePart = noteIdDisplayNote,
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 if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = "${nip19.additionalChars} ",
route = "Channel/${baseChannel.idHex}",
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = "@${note.idDisplayNote()}",
clickablePart = noteIdDisplayNote,
suffix = "${nip19.additionalChars} ",
route = "Note/${nip19.hex}",
nav = nav
@@ -185,10 +212,10 @@ private fun DisplayAddress(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember { mutableStateOf<Note?>(null) }
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
}
}
@@ -217,17 +244,17 @@ private fun DisplayUser(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var userBase by remember { mutableStateOf<User?>(null) }
var userBase by remember(nip19) { mutableStateOf<User?>(null) }
LaunchedEffect(key1 = nip19.hex) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
}
}
userBase?.let {
val userState by it.live().metadata.observeAsState()
val route = remember { "User/${it.pubkeyHex}" }
val route = remember(userState) { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
@@ -287,11 +314,51 @@ 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?.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)
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
@@ -306,13 +373,26 @@ fun CreateTextWithEmoji(
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 (emojis.isEmpty()) {
if (emojiList.isEmpty()) {
Text(
text = text,
color = textColor,
@@ -324,10 +404,6 @@ fun CreateTextWithEmoji(
modifier = modifier
)
} else {
val myList = remember {
assembleAnnotatedList(text, emojis)
}
val style = LocalTextStyle.current.merge(
TextStyle(
color = textColor,
@@ -337,7 +413,7 @@ fun CreateTextWithEmoji(
)
).toSpanStyle()
InLineIconRenderer(myList, style, maxLines, overflow, modifier)
InLineIconRenderer(emojiList, style, maxLines, overflow, modifier)
}
}
@@ -348,22 +424,30 @@ fun CreateClickableTextWithEmoji(
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?.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)
}
}
@@ -379,30 +463,38 @@ fun CreateClickableTextWithEmoji(
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?.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>): List<Renderable> {
return NIP30Parser().buildArray(text).map {
val url = emojis[it]
if (url != null) {
@@ -433,7 +525,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)
)
}
)
@@ -489,15 +583,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)
)
}
)

View File

@@ -35,7 +35,7 @@ fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
tags: List<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.LruCache
import android.util.Patterns
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -26,7 +27,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import com.halilibo.richtext.markdown.Markdown
import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichTextStyle
@@ -38,18 +38,26 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.uriToRoute
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -90,15 +98,13 @@ fun RichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: List<List<String>>?,
tags: List<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val isMarkdown = remember { isMarkdown(content) }
Column(modifier = modifier) {
if (isMarkdown) {
if (remember(content) { isMarkdown(content) }) {
RenderContentAsMarkdown(content, backgroundColor, tags, nav)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
@@ -106,205 +112,272 @@ fun RichTextViewer(
}
}
@Stable
class RichTextViewerState(
val content: String,
val urlSet: Set<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>,
val customEmoji: Map<String, String>
val urlSetCache = LruCache<String, RichTextViewerState>(200)
@Immutable
data class RichTextViewerState(
val urlSet: ImmutableSet<String>,
val imagesForPager: ImmutableMap<String, ZoomableUrlContent>,
val imageList: ImmutableList<ZoomableUrlContent>,
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalTime::class, ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
tags: List<List<String>>?,
tags: List<List<String>>,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var state by remember {
mutableStateOf(RichTextViewerState(content, emptySet(), emptyMap(), emptyList(), emptyMap()))
}
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = content) {
scope.launch(Dispatchers.IO) {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
state = RichTextViewerState(content, urlSet, imagesForPager, imageList, emojiMap)
}
val state by remember(content) {
if (urlSetCache[content] != null) {
mutableStateOf(urlSetCache[content])
} else {
val newUrls = parseUrls(content, tags)
urlSetCache.put(content, newUrls)
mutableStateOf(newUrls)
}
}
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = if (isArabic(paragraph)) {
paragraph.trim().split(' ')
.reversed()
} else {
paragraph.trim().split(' ')
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed()
} else {
paragraph.trim().split(' ')
}
}
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
val img = state.imagesForPager[word]
if (img != null) {
ZoomableContentView(img, state.imageList)
} else if (state.urlSet.contains(word)) {
UrlPreview(word, "$word ")
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
MayBeWithdrawal(word)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
if (state.urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (state.customEmoji.any { word.contains(it.key) }) {
RenderCustomEmoji(word, state.customEmoji)
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word)
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
ClickablePhone(word)
} else if (isBechLink(word)) {
BechLink(
word,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
TagLink(
word,
tags,
canPreview,
backgroundColor,
accountViewModel,
nav
)
} else if (hashTagsPattern.matcher(word).matches()) {
HashTag(word, nav)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
val matcher = noProtocolUrlValidator.matcher(word)
matcher.find()
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
if (url != null) {
ClickableUrl(url, "https://$url")
Text("$additionalChars ")
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
}
}
}
private fun parseUrls(
content: String,
tags: List<List<String>>
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableUrlVideo(fullUrl)
} else {
null
}
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
return if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
RichTextViewerState(
urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(),
imageList.toImmutableList(),
emojiMap.toImmutableMap()
)
} else {
RichTextViewerState(
persistentSetOf(),
persistentMapOf(),
persistentListOf(),
persistentMapOf()
)
}
}
enum class WordType {
IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER
}
@Composable
fun RenderCustomEmoji(word: String, customEmoji: Map<String, String>) {
private fun RenderWord(
word: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: List<List<String>>
) {
val type = remember(word) {
if (state.imagesForPager[word] != null) {
WordType.IMAGE
} else if (state.urlSet.contains(word)) {
WordType.LINK
} else if (state.customEmoji.any { word.contains(it.key) }) {
WordType.EMOJI
} else if (word.startsWith("lnbc", true)) {
WordType.INVOICE
} else if (word.startsWith("lnurl", true)) {
WordType.WITHDRAW
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
WordType.EMAIL
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
WordType.PHONE
} else if (startsWithNIP19Scheme(word)) {
WordType.BECH
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches()) {
if (tags.isNotEmpty()) {
WordType.HASH_INDEX
} else {
WordType.OTHER
}
} else if (hashTagsPattern.matcher(word).matches()) {
WordType.HASHTAG
} else {
WordType.OTHER
}
} else if (noProtocolUrlValidator.matcher(word).matches()) {
WordType.SCHEMELESS_URL
} else {
WordType.OTHER
}
}
if (canPreview) {
RenderWordWithPreview(type, word, state, tags, backgroundColor, accountViewModel, nav)
} else {
RenderWordWithoutPreview(type, word, state, tags, backgroundColor, accountViewModel, nav)
}
}
@Composable
private fun RenderWordWithoutPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: List<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val wordSpace = remember(word) {
"$word "
}
when (type) {
// Don't preview Images
WordType.IMAGE -> UrlPreview(word, wordSpace)
WordType.LINK -> UrlPreview(word, wordSpace)
WordType.EMOJI -> RenderCustomEmoji(word, state)
// Don't offer to pay invoices
WordType.INVOICE -> NormalWord(wordSpace)
// Don't offer to withdraw
WordType.WITHDRAW -> NormalWord(wordSpace)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav)
WordType.HASHTAG -> HashTag(word, nav)
WordType.HASH_INDEX -> TagLink(word, tags, false, backgroundColor, accountViewModel, nav)
WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word)
WordType.OTHER -> NormalWord(wordSpace)
}
}
@Composable
private fun RenderWordWithPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: List<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val wordSpace = remember(word) {
"$word "
}
when (type) {
WordType.IMAGE -> ZoomableContentView(word, state)
WordType.LINK -> UrlPreview(word, wordSpace)
WordType.EMOJI -> RenderCustomEmoji(word, state)
WordType.INVOICE -> MayBeInvoicePreview(word)
WordType.WITHDRAW -> MayBeWithdrawal(word)
WordType.EMAIL -> ClickableEmail(word)
WordType.PHONE -> ClickablePhone(word)
WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav)
WordType.HASHTAG -> HashTag(word, nav)
WordType.HASH_INDEX -> TagLink(word, tags, true, backgroundColor, accountViewModel, nav)
WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word)
WordType.OTHER -> NormalWord(wordSpace)
}
}
@Composable
private fun ZoomableContentView(word: String, state: RichTextViewerState) {
state.imagesForPager[word]?.let {
ZoomableContentView(it, state.imageList)
}
}
@Composable
private fun NormalWord(word: String) {
Text(
text = word,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
private fun NoProtocolUrlRenderer(word: String) {
val wordSpace = remember(word) {
"$word "
}
var linkedUrl by remember(word) {
mutableStateOf<Pair<String, String>?>(null)
}
LaunchedEffect(key1 = word) {
launch(Dispatchers.Default) {
val matcher = noProtocolUrlValidator.matcher(word)
if (matcher.find()) {
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
linkedUrl = Pair(url, additionalChars)
}
}
}
if (linkedUrl != null) {
ClickableUrl(linkedUrl!!.first, "https://${linkedUrl!!.first}")
Text("${linkedUrl!!.second} ")
} else {
Text(
text = wordSpace,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
}
@Composable
fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
CreateTextWithEmoji(
text = "$word ",
emojis = customEmoji
text = remember { "$word " },
emojis = state.customEmoji
)
}
@@ -344,19 +417,38 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
)
)
var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
var refresh by remember { mutableStateOf(0) }
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
nip19References = returnNIP19References(content, tags)
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
val uri = LocalUriHandler.current
val onClick = { link: String ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
}
Unit
}
LaunchedEffect(key1 = refresh) {
withContext(Dispatchers.IO) {
MaterialRichText(
style = myMarkDownStyle
) {
RefreshableContent(content, tags) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default,
onLinkClicked = onClick
)
}
}
}
@Composable
private fun RefreshableContent(content: String, tags: List<List<String>>?, onCompose: @Composable (String) -> Unit) {
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
val scope = rememberCoroutineScope()
ObserverAllNIP19References(content, tags) {
scope.launch(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
@@ -364,18 +456,79 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
}
}
nip19References.forEach {
var baseUser by remember { mutableStateOf<User?>(null) }
var baseNote by remember { mutableStateOf<Note?>(null) }
markdownWithSpecialContent?.let {
onCompose(it)
}
}
LaunchedEffect(key1 = it.hex) {
withContext(Dispatchers.IO) {
@Composable
fun ObserverAllNIP19References(content: String, tags: List<List<String>>?, onRefresh: () -> Unit) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
LaunchedEffect(key1 = content) {
launch(Dispatchers.IO) {
nip19References = returnNIP19References(content, tags)
}
}
nip19References.forEach {
ObserveNIP19(it, onRefresh)
}
}
@Composable
fun ObserveNIP19(
it: Nip19.Return,
onRefresh: () -> Unit
) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
ObserveNIP19Event(it, onRefresh)
} else if (it.type == Nip19.Type.USER) {
ObserveNIP19User(it, onRefresh)
}
}
@Composable
private fun ObserveNIP19Event(
it: Nip19.Return,
onRefresh: suspend () -> Unit
) {
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = it.hex) {
if (baseNote == null) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNote = note
}
}
}
}
}
baseNote?.let { note ->
val noteState by note.live().metadata.observeAsState()
LaunchedEffect(key1 = noteState) {
if (noteState?.note?.event != null) {
launch(Dispatchers.IO) {
onRefresh()
}
}
}
}
}
@Composable
private fun ObserveNIP19User(
it: Nip19.Return,
onRefresh: () -> Unit
) {
var baseUser by remember(it) { mutableStateOf<User?>(null) }
LaunchedEffect(key1 = it.hex) {
if (baseUser == null) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
baseUser = user
@@ -383,40 +536,15 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
}
}
}
baseNote?.let {
val noteState by it.live().metadata.observeAsState()
if (noteState?.note?.event != null) {
LaunchedEffect(key1 = noteState) {
refresh++
}
}
}
baseUser?.let {
val userState by it.live().metadata.observeAsState()
if (userState?.user?.info != null) {
LaunchedEffect(key1 = userState) {
refresh++
}
}
}
}
val uri = LocalUriHandler.current
baseUser?.let { user ->
val userState by user.live().metadata.observeAsState()
markdownWithSpecialContent?.let {
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default
) { link ->
val route = uriToRoute(link)
if (route != null) {
nav(route)
} else {
runCatching { uri.openUri(link) }
LaunchedEffect(key1 = userState) {
if (userState?.user?.info != null) {
launch(Dispatchers.IO) {
onRefresh()
}
}
}
@@ -478,7 +606,7 @@ private fun returnNIP19References(content: String, tags: List<List<String>>?): L
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isBechLink(word)) {
if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
parsedNip19?.let {
listOfReferences.add(it)
@@ -505,12 +633,17 @@ private fun returnMarkdownWithSpecialContent(content: String, tags: List<List<St
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
returnContent += "[$word]($word) "
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
returnContent += "$word "
} else {
returnContent += "[$word]($word) "
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
returnContent += "[$word](mailto:$word) "
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
returnContent += "[$word](tel:$word) "
} else if (isBechLink(word)) {
} else if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val pair = getDisplayNameFromNip19(parsedNip19)
@@ -563,7 +696,7 @@ private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
fun isBechLink(word: String): Boolean {
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
@@ -574,10 +707,8 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
@@ -627,10 +758,8 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
fun HashTag(word: String, nav: (String) -> Unit) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
@@ -707,30 +836,30 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = word) {
scope.launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
if (baseUserPair == null && baseNotePair == null) {
launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
baseUserPair = Pair(it, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
baseNotePair = Pair(it, suffix)
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
baseUserPair = Pair(it, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
baseNotePair = Pair(it, suffix)
}
}
}
}

View File

@@ -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
@@ -21,7 +20,7 @@ import kotlinx.coroutines.launch
fun UrlPreview(url: String, urlText: String) {
val context = LocalContext.current
var urlPreviewState by remember {
var urlPreviewState by remember(url) {
val default = UrlCachedPreviewer.cache[url]?.let {
if (it.allFetchComplete() && it.url == url) {
UrlPreviewState.Loaded(it)
@@ -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 {

View File

@@ -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(previewInfo.url) }
// correctly treating relative images
val imageUrl = remember {
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()

View File

@@ -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()

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
@@ -30,7 +31,6 @@ import androidx.compose.runtime.MutableState
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
@@ -54,7 +54,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
@@ -64,29 +63,32 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
public var muted = mutableStateOf(true)
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
VideoView(localFile.toUri(), description, null, onDialog)
val video = remember(localFile) { localFile.toUri() }
VideoView(video, description, null, onDialog)
}
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
VideoView(Uri.parse(videoUri), description, null, onDialog)
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, null, onDialog)
}
@Composable
fun VideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
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
@@ -104,7 +106,9 @@ fun VideoView(videoUri: String, description: String? = null, thumbUri: String, o
if (loadingFinished.first) {
if (loadingFinished.second != null) {
VideoView(Uri.parse(videoUri), description, loadingFinished.second, onDialog)
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, loadingFinished.second, onDialog)
} else {
VideoView(videoUri, description, onDialog)
}
@@ -116,23 +120,25 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
println("loading audio with artwork $thumb")
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
description?.let {
mediaBuilder.setMediaMetadata(
MediaMetadata.Builder().setDisplayTitle(it).build()
)
LaunchedEffect(key1 = videoUri) {
if (exoPlayer == null) {
launch(Dispatchers.Default) {
exoPlayer = ExoPlayer.Builder(context).build()
}
}
}
val media = mediaBuilder.build()
exoPlayer?.let {
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
ExoPlayer.Builder(context).build().apply {
it.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = 0f
volume = if (mutedInstance.value) 0f else 1f
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
@@ -144,56 +150,15 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
}
prepare()
}
RenderVideoPlayer(it, context, thumb, onDialog, mutedInstance)
}
LaunchedEffect(key1 = muted.value) {
exoPlayer.volume = if (muted.value) 0f else 1f
}
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(muted, Modifier) {
muted.value = !muted.value
}
}
) {
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
exoPlayer?.pause()
}
else -> {}
}
@@ -202,24 +167,73 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
lifecycle.addObserver(observer)
onDispose {
exoPlayer.release()
exoPlayer?.release()
lifecycle.removeObserver(observer)
}
}
}
@Composable
private fun RenderVideoPlayer(
exoPlayer: ExoPlayer,
context: Context,
thumb: Drawable?,
onDialog: ((Boolean) -> Unit)?,
mutedInstance: MutableState<Boolean>
) {
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
}
}
}
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)
}
}
}
@@ -241,12 +255,14 @@ fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
}
@Composable
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
private fun MuteButton(muted: MutableState<Boolean>, toggle: () -> Unit) {
Box(
modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
remember {
Modifier
.width(70.dp)
.height(70.dp)
.padding(10.dp)
}
) {
Box(
Modifier
@@ -256,23 +272,18 @@ private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle:
.background(MaterialTheme.colors.background)
)
if (muted.value) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
if (muted.value) {
Icon(
imageVector = Icons.Default.VolumeOff,
"Hash Verified",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.size(30.dp)
)
}
} else {
IconButton(
onClick = toggle,
modifier = Modifier.size(50.dp)
) {
} else {
Icon(
imageVector = Icons.Default.VolumeUp,
"Hash Verified",

View File

@@ -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
@@ -63,9 +66,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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
@@ -82,11 +83,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 +98,7 @@ abstract class ZoomableUrlContent(
val uri: String? = null
) : ZoomableContent(description, dim)
@Immutable
class ZoomableUrlImage(
url: String,
description: String? = null,
@@ -104,6 +108,7 @@ class ZoomableUrlImage(
uri: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
class ZoomableUrlVideo(
url: String,
description: String? = null,
@@ -112,6 +117,7 @@ class ZoomableUrlVideo(
uri: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
abstract class ZoomablePreloadedContent(
val localFile: File?,
description: String? = null,
@@ -121,6 +127,7 @@ abstract class ZoomablePreloadedContent(
val uri: String
) : ZoomableContent(description, dim)
@Immutable
class ZoomableLocalImage(
localFile: File?,
mimeType: String? = null,
@@ -131,6 +138,7 @@ class ZoomableLocalImage(
uri: String
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
@Immutable
class ZoomableLocalVideo(
localFile: File?,
mimeType: String? = null,
@@ -201,17 +209,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 +242,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 +270,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 +313,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 +345,6 @@ private fun UrlImageView(
}
}
}
if (imageState is AsyncImagePainter.State.Error) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
}
}
@@ -412,10 +428,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
@@ -514,9 +532,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()

View File

@@ -1,10 +1,16 @@
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
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
object ChatroomListKnownFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
@@ -35,4 +41,113 @@ object ChatroomListKnownFeedFilter : 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 channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPublicMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
if (
(newNotePair.key == oldNote.channelHex()) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
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 channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty()) {
emptySet()
} else {
(newRelevantPrivateMessages.values + newRelevantPublicMessages.values).toSet()
}
}
private fun filterRelevantPublicMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val followingChannels = account.followingChannels
val newRelevantPublicMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is ChannelMessageEvent }.forEach { newNote ->
newNote.channelHex()?.let { channelHex ->
if (channelHex in followingChannels && account.isAcceptable(newNote)) {
val lastNote = newRelevantPublicMessages.get(channelHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPublicMessages.put(channelHex, newNote)
}
} else {
newRelevantPublicMessages.put(channelHex, newNote)
}
}
}
}
return newRelevantPublicMessages
}
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()
}
}

View File

@@ -3,13 +3,12 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
object ChatroomListNewFeedFilter : FeedFilter<Note>() {
lateinit var account: Account
class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<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 {

View File

@@ -11,7 +11,7 @@ abstract class FeedFilter<T> {
feed()
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
Log.d("Time", "${this.javaClass.simpleName} Full Feed in $elapsed with ${feed.size} objects")
return feed.take(1000)
}
@@ -23,7 +23,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
val (feed, elapsed) = measureTimedValue {
val newItemsToBeAdded = applyFilter(newItems)
if (newItemsToBeAdded.isNotEmpty()) {
@@ -34,7 +34,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
}
}
Log.d("Time", "${this.javaClass.simpleName} Feed in $elapsed with ${feed.size} objects")
Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
}

View File

@@ -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)

View File

@@ -3,8 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
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()
}

View File

@@ -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))
}

View File

@@ -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)

View File

@@ -2,14 +2,12 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
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))
}
@@ -36,7 +34,7 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
(isGlobal || it.author?.pubkeyHex in followingKeySet) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
tagsAnEventByUser(it, loggedInUser)
tagsAnEventByUser(it, loggedInUserHex)
}.toSet()
}
@@ -44,27 +42,27 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
fun tagsAnEventByUser(note: Note, author: User): Boolean {
fun tagsAnEventByUser(note: Note, authorHex: HexKey): Boolean {
val event = note.event
if (event is BaseTextNoteEvent) {
val isAuthoredPostCited = event.findCitations().any {
LocalCache.notes[it]?.author === author || LocalCache.addressables[it]?.author === author
LocalCache.notes[it]?.author?.pubkeyHex == authorHex || LocalCache.addressables[it]?.author?.pubkeyHex == authorHex
}
return isAuthoredPostCited ||
(
event.citedUsers().contains(author.pubkeyHex) ||
note.replyTo?.any { it.author === author } == true
event.citedUsers().contains(authorHex) ||
note.replyTo?.any { it.author?.pubkeyHex == authorHex } == true
)
}
if (event is ReactionEvent) {
return note.replyTo?.lastOrNull()?.author === author
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
}
if (event is RepostEvent) {
return note.replyTo?.lastOrNull()?.author === author
return note.replyTo?.lastOrNull()?.author?.pubkeyHex == authorHex
}
return true

View File

@@ -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
}
}

View File

@@ -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) }
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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
@@ -40,9 +42,11 @@ import androidx.compose.ui.text.font.FontWeight
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.components.CreateTextWithEmoji
@@ -52,6 +56,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 +65,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 +80,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 +122,144 @@ 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(),
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)
) {
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
}
}
displayName?.let {
CreateTextWithEmoji(
text = it,
tags = tags
)
}
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
)
}
}

View File

@@ -110,36 +110,16 @@ private fun RowScope.HasNewItemsIcon(
accountViewModel: AccountViewModel,
navController: NavHostController
) {
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,
@@ -151,21 +131,14 @@ private fun RowScope.HasNewItemsIcon(
scope.launch {
if (!selected) {
navController.navigate(route.base) {
navController.graph.startDestinationRoute?.let { start ->
popUpTo(start)
restoreState = true
}
popUpTo(Route.Home.route)
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
}
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
@@ -174,6 +147,35 @@ private fun RowScope.HasNewItemsIcon(
}
}
@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))
}
}
}
}
}
@Composable
private fun RowScope.BottomIcon(
icon: Int,
@@ -209,27 +211,30 @@ private fun RowScope.BottomIcon(
@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 +242,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)
}
)
}
}

View File

@@ -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,20 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// 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)
LaunchedEffect(key1 = it) {
if (scrollToTop) {
launch {
videoFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
@@ -120,23 +85,20 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// 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)
LaunchedEffect(key1 = it) {
if (scrollToTop) {
launch {
searchFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
@@ -145,28 +107,27 @@ fun AppNavigation(
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
val nip47 = it.arguments?.getString("nip47")
LaunchedEffect(key1 = it) {
if (scrollToTop) {
launch {
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 +140,37 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// 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)
LaunchedEffect(key1 = it) {
if (scrollToTop) {
launch {
notifFeedViewModel.clear()
notifFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav
)
})
}
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 +228,6 @@ fun AppNavigation(
composable(route.route, route.arguments, content = {
LoadRedirectScreen(
eventId = it.arguments?.getString("id"),
accountViewModel = accountViewModel,
navController = navController
)
})

View File

@@ -26,6 +26,8 @@ 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.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -42,6 +44,7 @@ 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.NavHostController
@@ -51,7 +54,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
@@ -72,13 +74,18 @@ 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) {
@@ -93,27 +100,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 +170,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 +178,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
}
if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, account)
NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
}
Column() {
@@ -163,54 +199,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 +224,79 @@ 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() }
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
derivedStateOf {
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.End
) {
Row(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = connectedRelaysText,
color = if (connectedRelaysLiveData.value == 0) {
Color.Red
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
},
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 +307,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) {
derivedStateOf {
followLists.map { it.second }
allLists.map { it.second }
}
}
SimpleTextSpinner(
placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
options = followNames.value,
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 +347,32 @@ class FollowListViewModel : ViewModel() {
}
private suspend fun refreshFollows() {
val myAccount = account ?: return
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()
}
if (newNotes.any { it.event is PeopleListEvent }) {
refresh()
}
}
}
@@ -321,6 +382,12 @@ 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

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -31,6 +32,7 @@ import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -43,6 +45,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -62,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@@ -72,16 +76,13 @@ fun DrawerContent(
sheetState: ModalBottomSheetState,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.background
) {
Column() {
ProfileContent(
account.userProfile(),
accountViewModel.account.userProfile(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp)
@@ -94,17 +95,16 @@ fun DrawerContent(
modifier = Modifier.padding(top = 20.dp)
)
ListContent(
account.userProfile().pubkeyHex,
nav,
scaffoldState,
sheetState,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
account
accountViewModel
)
BottomContent(account.userProfile(), scaffoldState, nav)
BottomContent(accountViewModel.account.userProfile(), scaffoldState, nav)
}
}
}
@@ -130,10 +130,6 @@ fun ProfileContent(
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags }
val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" }
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
val followingCount = remember(accountUserFollowsState) { accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--" }
val followerCount = remember(accountUserFollowsState) { accountUserFollowsState?.user?.cachedFollowerCount()?.toString() ?: "--" }
Box {
if (profileBanner != null) {
AsyncImage(
@@ -219,35 +215,61 @@ fun ProfileContent(
}
})
) {
Row() {
Text(
text = followingCount,
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.following))
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text(
text = followerCount,
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.followers))
}
FollowingAndFollowerCounts(baseAccountUser)
}
}
}
}
@Composable
private fun FollowingAndFollowerCounts(baseAccountUser: User) {
val accountUserFollowsState by baseAccountUser.live().follows.observeAsState()
var followingCount by remember { mutableStateOf("--") }
var followerCount by remember { mutableStateOf("--") }
LaunchedEffect(key1 = accountUserFollowsState) {
launch(Dispatchers.IO) {
val newFollowing = accountUserFollowsState?.user?.cachedFollowCount()?.toString() ?: "--"
val newFollower = accountUserFollowsState?.user?.cachedFollowerCount()?.toString() ?: "--"
if (followingCount != newFollowing) {
followingCount = newFollowing
}
if (followerCount != newFollower) {
followerCount = newFollower
}
}
}
Row() {
Text(
text = followingCount,
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.following))
}
Row(modifier = Modifier.padding(start = 10.dp)) {
Text(
text = followerCount,
fontWeight = FontWeight.Bold
)
Text(stringResource(R.string.followers))
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ListContent(
accountUserPubKey: String?,
nav: (String) -> Unit,
scaffoldState: ScaffoldState,
sheetState: ModalBottomSheetState,
modifier: Modifier,
account: Account
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val coroutineScope = rememberCoroutineScope()
var backupDialogOpen by remember { mutableStateOf(false) }
var checked by remember { mutableStateOf(account.proxy != null) }
@@ -255,26 +277,28 @@ fun ListContent(
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) }
Column(modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState())) {
if (accountUserPubKey != null) {
NavigationRow(
title = stringResource(R.string.profile),
icon = Route.Profile.icon,
tint = MaterialTheme.colors.primary,
nav = nav,
scaffoldState = scaffoldState,
route = "User/$accountUserPubKey"
)
Column(
modifier = modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState())
) {
NavigationRow(
title = stringResource(R.string.profile),
icon = Route.Profile.icon,
tint = MaterialTheme.colors.primary,
nav = nav,
scaffoldState = scaffoldState,
route = "User/${account.userProfile().pubkeyHex}"
)
NavigationRow(
title = stringResource(R.string.bookmarks),
icon = Route.Bookmarks.icon,
tint = MaterialTheme.colors.onBackground,
nav = nav,
scaffoldState = scaffoldState,
route = Route.Bookmarks.route
)
}
NavigationRow(
title = stringResource(R.string.bookmarks),
icon = Route.Bookmarks.icon,
tint = MaterialTheme.colors.onBackground,
nav = nav,
scaffoldState = scaffoldState,
route = Route.Bookmarks.route
)
NavigationRow(
title = stringResource(R.string.security_filters),
@@ -335,6 +359,8 @@ fun ListContent(
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
}
val context = LocalContext.current
if (conectOrbotDialogOpen) {
ConnectOrbotDialog(
onClose = { conectOrbotDialogOpen = false },
@@ -342,7 +368,7 @@ fun ListContent(
conectOrbotDialogOpen = false
disconnectTorDialog = false
checked = true
enableTor(account, true, proxyPort)
enableTor(account, true, proxyPort, context = context)
},
proxyPort
)
@@ -364,7 +390,7 @@ fun ListContent(
onClick = {
disconnectTorDialog = false
checked = false
enableTor(account, false, proxyPort)
enableTor(account, false, proxyPort, context)
}
) {
Text(text = stringResource(R.string.yes))
@@ -386,13 +412,14 @@ fun ListContent(
private fun enableTor(
account: Account,
checked: Boolean,
portNumber: MutableState<String>
portNumber: MutableState<String>,
context: Context
) {
account.proxyPort = portNumber.value.toInt()
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
LocalPreferences.saveToEncryptedStorage(account)
ServiceManager.pause()
ServiceManager.start()
ServiceManager.start(context)
}
@Composable
@@ -467,7 +494,7 @@ fun BottomContent(user: User, scaffoldState: ScaffoldState, nav: (String) -> Uni
) {
Text(
modifier = Modifier.padding(start = 16.dp),
text = "v" + BuildConfig.VERSION_NAME,
text = "v" + BuildConfig.VERSION_NAME + "-" + BuildConfig.FLAVOR.uppercase(),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)

View File

@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
@@ -127,17 +128,25 @@ open class LatestItem {
if (newestItem == null) {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.feed().firstOrNull { it.createdAt() != null }
filterMore(filter.feed()).firstOrNull { it.createdAt() != null }
)
} else {
newestItemPerAccount = newestItemPerAccount + Pair(
account.userProfile().pubkeyHex,
filter.sort(filter.applyFilter(newNotes) + newestItem).first()
filter.sort(filterMore(filter.applyFilter(newNotes)) + newestItem).first()
)
}
return newestItemPerAccount[account.userProfile().pubkeyHex]
}
open fun filterMore(newItems: Set<Note>): Set<Note> {
return newItems
}
open fun filterMore(newItems: List<Note>): List<Note> {
return newItems
}
}
object HomeLatestItem : LatestItem() {
@@ -147,9 +156,8 @@ object HomeLatestItem : LatestItem() {
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("HomeFollows")
HomeNewThreadFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account))
return (newestItem?.createdAt() ?: 0) > lastTime
}
@@ -162,29 +170,34 @@ object NotificationLatestItem : LatestItem() {
newNotes: Set<Note>
): Boolean {
val lastTime = cache.load("Notification")
NotificationFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account))
return (newestItem?.createdAt() ?: 0) > lastTime
}
}
object MessagesLatestItem {
object MessagesLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
cache: NotificationCache,
newNotes: Set<Note>
): Boolean {
ChatroomListKnownFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account))
val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
} ?: return false
val roomUserHex = (newestItem?.event as? PrivateDmEvent)?.talkingWith(account.userProfile().pubkeyHex)
val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
val lastTime = cache.load("Room/$roomUserHex")
return (note.createdAt() ?: 0) > lastTime
return (newestItem?.createdAt() ?: 0) > lastTime
}
override fun filterMore(newItems: Set<Note>): Set<Note> {
return newItems.filter { it.event is PrivateDmEvent }.toSet()
}
override fun filterMore(newItems: List<Note>): List<Note> {
return newItems.filter { it.event is PrivateDmEvent }
}
}

View File

@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -16,14 +15,11 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
@@ -38,6 +34,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
class AddBountyAmountViewModel : ViewModel() {
private var account: Account? = null
@@ -75,16 +72,10 @@ class AddBountyAmountViewModel : ViewModel() {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AddBountyAmountDialog(bounty: Note, account: Account, onClose: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
fun AddBountyAmountDialog(bounty: Note, accountViewModel: AccountViewModel, onClose: () -> Unit) {
val postViewModel: AddBountyAmountViewModel = viewModel()
LaunchedEffect(account) {
postViewModel.load(account, bounty)
}
postViewModel.load(accountViewModel.account, bounty)
Dialog(
onDismissRequest = { onClose() },

View File

@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -64,7 +65,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}

View File

@@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -17,10 +19,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: String? = null) {
@@ -53,8 +54,16 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: St
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier, isQuote: Boolean = false, nav: (String) -> Unit, onClick: () -> Unit) {
fun HiddenNote(
reports: Set<Note>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
isQuote: Boolean = false,
nav: (String) -> Unit,
onClick: () -> Unit
) {
Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) {
@@ -75,7 +84,7 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
NoteAuthorPicture(
baseNote = it,
nav = nav,
userAccount = loggedIn,
accountViewModel = accountViewModel,
size = 35.dp
)
}

View File

@@ -1,138 +0,0 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
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.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by boostSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = boostSetCard) {
withContext(Dispatchers.IO) {
isNew = boostSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, boostSetCard.createdAt)
}
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.combinedClickable(
onClick = {
scope.launch {
routeFor(
note,
account.userProfile()
)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp
)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = Modifier
.size(16.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified
)
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
boostSetCard.boostEvents.forEach {
NoteAuthorPicture(
baseNote = it,
nav = nav,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
}
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
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
@@ -66,39 +67,59 @@ fun ChatroomCompose(
val scope = rememberCoroutineScope()
val channelHex by remember(noteState) {
derivedStateOf {
noteState?.note?.channelHex()
}
}
if (note?.event == null) {
BlankNote(Modifier)
} else if (note.channel() != null) {
val authorState by note.author!!.live().metadata.observeAsState()
val author = authorState?.user
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
val authorState by note.author!!.live().metadata.observeAsState()
val authorName = remember(authorState) {
authorState?.user?.toBestDisplayName()
}
val channelState by note.channel()!!.live.observeAsState()
val channel = channelState?.channel
val chanHex = remember { channel.idHex }
val noteEvent = note.event
val channelState by channel.live.observeAsState()
val channelPicture by remember(channelState) {
derivedStateOf {
channel.profilePicture()
}
}
val channelName by remember(channelState) {
derivedStateOf {
channel.info.name
}
}
val noteEvent = note.event
val description = if (noteEvent is ChannelCreateEvent) {
stringResource(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringResource(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content()
}
val description = if (noteEvent is ChannelCreateEvent) {
stringResource(R.string.channel_created)
} else if (noteEvent is ChannelMetadataEvent) {
"${stringResource(R.string.channel_information_changed_to)} "
} else {
noteEvent?.content()
}
channel?.let { chan ->
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = notificationCache, key2 = note) {
scope.launch(Dispatchers.IO) {
note.createdAt()?.let { timestamp ->
hasNewMessages =
timestamp > notificationCache.cache.load("Channel/${chan.idHex}")
timestamp > notificationCache.cache.load("Channel/$chanHex")
}
}
}
ChannelName(
channelIdHex = chan.idHex,
channelPicture = chan.profilePicture(),
channelIdHex = chanHex,
channelPicture = channelPicture,
channelTitle = { modifier ->
Text(
text = buildAnnotatedString {
@@ -107,7 +128,7 @@ fun ChatroomCompose(
fontWeight = FontWeight.Bold
)
) {
append(chan.info.name)
append(channelName)
}
withStyle(
@@ -125,9 +146,9 @@ fun ChatroomCompose(
)
},
channelLastTime = note.createdAt(),
channelLastContent = "${author?.toBestDisplayName()}: " + description,
channelLastContent = "$authorName: $description",
hasNewMessages = hasNewMessages,
onClick = { nav("Channel/${chan.idHex}") }
onClick = { nav("Channel/$chanHex") }
)
}
} else {
@@ -163,7 +184,7 @@ fun ChatroomCompose(
channelPicture = {
UserPicture(
userToComposeOn,
accountViewModel.userProfile(),
accountViewModel = accountViewModel,
size = 55.dp
)
},

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -37,8 +39,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.onSizeChanged
@@ -50,7 +50,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -66,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -116,9 +116,9 @@ fun ChatroomMessageCompose(
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
withContext(Dispatchers.IO) {
launch(Dispatchers.Default) {
account.userProfile().let { loggedIn ->
val newCanPreview = note.author === loggedIn ||
val newCanPreview = note.author?.pubkeyHex == loggedIn.pubkeyHex ||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!(noteForReports.hasAnyReports())
@@ -134,7 +134,7 @@ fun ChatroomMessageCompose(
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
HiddenNote(
account.getRelevantReports(noteForReports),
account.userProfile(),
accountViewModel,
Modifier,
innerQuote,
nav,
@@ -365,12 +365,13 @@ private fun RenderRegularTextNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val tags = remember { note.event?.tags() }
val tags = remember { note.event?.tags() ?: emptyList() }
val eventContent = remember { accountViewModel.decrypt(note) }
val modifier = remember { Modifier.padding(top = 5.dp) }
if (eventContent != null) {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
accountViewModel = accountViewModel
@@ -496,6 +497,7 @@ data class RelayBadgesState(
val noteRelaysSimple: List<String>
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
@@ -538,31 +540,27 @@ private fun RelayBadges(baseNote: Note) {
}
@Composable
private fun RenderRelay(dirtyUrl: String) {
fun RenderRelay(dirtyUrl: String) {
val uri = LocalUriHandler.current
val website = remember {
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
val website = remember(dirtyUrl) {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl"
}
val iconUrl = remember {
val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://")
val iconUrl = remember(dirtyUrl) {
val cleanUrl = dirtyUrl.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/")
"https://$cleanUrl/favicon.ico"
}
val clickableModifier = remember {
val clickableModifier = remember(dirtyUrl) {
Modifier
.size(15.dp)
.padding(1.dp)
.size(15.dp)
.clickable(onClick = { uri.openUri(website) })
}
val colorFilter = remember {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
}
val iconModifier = remember {
val iconModifier = remember(dirtyUrl) {
Modifier
.size(15.dp)
.size(13.dp)
.clip(shape = CircleShape)
}
@@ -571,10 +569,10 @@ private fun RenderRelay(dirtyUrl: String) {
) {
RobohashFallbackAsyncImage(
robot = iconUrl,
robotSize = 15.dp,
robotSize = 13.dp,
model = iconUrl,
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = colorFilter,
colorFilter = RelayIconFilter,
modifier = iconModifier.background(MaterialTheme.colors.background)
)
}

View File

@@ -1,137 +0,0 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
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.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by likeSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = likeSetCard) {
withContext(Dispatchers.IO) {
isNew = likeSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt)
}
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.combinedClickable(
onClick = {
scope.launch {
routeFor(
note,
account.userProfile()
)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp
)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = Modifier
.size(16.dp)
.align(Alignment.TopEnd),
tint = Color.Unspecified
)
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
likeSetCard.likeEvents.forEach {
NoteAuthorPicture(
baseNote = it,
nav = nav,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
}
}

View File

@@ -27,8 +27,10 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -66,7 +68,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
}
val backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
@@ -98,12 +100,14 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
MessageIcon()
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
val routeForLastRead = "Room/${(baseNote.event as? PrivateDmEvent)?.talkingWith(loggedIn.pubkeyHex)}"
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
routeForLastRead = routeForLastRead,
isBoostedNote = true,
addMarginTop = false,
parentBackgroundColor = backgroundColor,
parentBackgroundColor = null,
accountViewModel = accountViewModel,
nav = nav
)

View File

@@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -38,7 +40,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
@@ -51,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.coroutines.Dispatchers
@@ -61,95 +63,85 @@ import kotlinx.coroutines.launch
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseNote = remember { multiSetCard.note }
val noteState by baseNote.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note.event == null) {
BlankNote(Modifier, false)
var isNew by remember { mutableStateOf(false) }
LaunchedEffect(key1 = multiSetCard.createdAt()) {
launch(Dispatchers.IO) {
val newIsNew = multiSetCard.maxCreatedAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.maxCreatedAt)
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
val primaryColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = if (isNew) {
primaryColor.compositeOver(defaultBackgroundColor)
} else {
var isNew by remember { mutableStateOf(false) }
defaultBackgroundColor
}
LaunchedEffect(key1 = multiSetCard.createdAt()) {
scope.launch(Dispatchers.IO) {
val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
if (newIsNew != isNew) {
isNew = newIsNew
val columnModifier = Modifier
.background(backgroundColor)
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
)
.combinedClickable(
onClick = {
scope.launch {
routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) }
}
}
},
onLongClick = { popupExpanded = true }
)
.fillMaxWidth()
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
val likeEvents by remember { derivedStateOf { multiSetCard.likeEvents } }
val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } }
val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } }
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
Column(modifier = columnModifier) {
if (hasZapEvents) {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
val primaryColor = MaterialTheme.colors.primary.copy(0.12f)
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = if (isNew) {
primaryColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
if (hasBoostEvents) {
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
}
val columnModifier = Modifier
.background(backgroundColor)
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
if (hasLikeEvents) {
RenderLikeGallery(likeEvents, backgroundColor, nav, accountViewModel)
}
Row(Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(65.dp))
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
.combinedClickable(
onClick = {
scope.launch {
routeFor(baseNote, loggedIn)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
.fillMaxWidth()
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
val likeEvents by remember { derivedStateOf { multiSetCard.likeEvents } }
val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } }
val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } }
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
Column(modifier = columnModifier) {
if (hasZapEvents) {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
if (hasBoostEvents) {
RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel)
}
if (hasLikeEvents) {
RenderLikeGallery(likeEvents, backgroundColor, nav, accountViewModel)
}
Row(Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.width(65.dp))
NoteCompose(
baseNote = baseNote,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
@@ -186,7 +178,7 @@ private fun RenderLikeGallery(
}
@Composable
private fun RenderZapGallery(
fun RenderZapGallery(
zapEvents: ImmutableMap<Note, Note>,
backgroundColor: Color,
nav: (String) -> Unit,
@@ -251,6 +243,7 @@ private fun RenderBoostGallery(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthorGalleryZaps(
authorNotes: ImmutableMap<Note, Note>,
@@ -261,9 +254,7 @@ fun AuthorGalleryZaps(
Column(modifier = Modifier.padding(start = 10.dp)) {
FlowRow() {
authorNotes.forEach {
Box() {
AuthorPictureAndComment(it.key, it.value, backgroundColor, nav, accountViewModel)
}
AuthorPictureAndComment(it.key, it.value, backgroundColor, nav, accountViewModel)
}
}
}
@@ -278,10 +269,9 @@ private fun AuthorPictureAndComment(
accountViewModel: AccountViewModel
) {
var content by remember { mutableStateOf<Triple<User?, String?, String?>>(Triple(zapRequest.author, null, null)) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
scope.launch(Dispatchers.Default) {
launch(Dispatchers.Default) {
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
@@ -297,6 +287,8 @@ private fun AuthorPictureAndComment(
}
}
println("AAAA $content")
content.first?.let {
val route by remember {
derivedStateOf {
@@ -328,17 +320,9 @@ private fun AuthorPictureAndComment(
) {
val authorPictureModifier = remember { Modifier }
val modifier = remember(comment) {
if (comment != null) {
Modifier
.fillMaxWidth()
.clickable {
nav(route)
}
} else {
Modifier.clickable {
nav(route)
}
val modifier = remember {
Modifier.clickable {
nav(route)
}
}
@@ -379,8 +363,8 @@ private fun AuthorPictureAndComment(
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = null,
modifier = Modifier.weight(1f),
tags = remember { emptyList() },
modifier = remember { Modifier.fillMaxWidth() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -389,6 +373,7 @@ private fun AuthorPictureAndComment(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthorGallery(
authorNotes: ImmutableList<Note>,
@@ -437,30 +422,21 @@ fun FastNoteAuthorPicture(
accountViewModel: AccountViewModel
) {
val userState by author.live().metadata.observeAsState()
val profilePicture = remember(userState) {
userState?.user?.profilePicture()
val profilePicture by remember(userState) {
derivedStateOf {
userState?.user?.profilePicture()
}
}
val authorPubKey = remember {
author.pubkeyHex
}
val accountState by accountViewModel.accountLiveData.observeAsState()
val loggedInLiveFollows = remember(accountState) { accountState?.account?.userProfile()?.live()?.follows } ?: return
val accountFollowsState by loggedInLiveFollows.observeAsState()
val showFollowingMark by remember(accountFollowsState) {
derivedStateOf {
accountFollowsState?.user?.isFollowingCached(author) == true || (author === accountFollowsState?.user)
}
}
UserPicture(
userHex = authorPubKey,
userPicture = profilePicture,
showFollowingMark = showFollowingMark,
size = size,
modifier = pictureModifier
modifier = pictureModifier,
accountViewModel = accountViewModel
)
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
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
@@ -97,11 +98,14 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Bool
@Composable
fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val author by remember(noteState) {
derivedStateOf {
noteState?.note?.author
}
}
val author = note.author
if (author != null) {
ObserveDisplayNip05Status(author, columnModifier)
author?.let {
ObserveDisplayNip05Status(it, columnModifier)
}
}

View File

@@ -111,33 +111,34 @@ private fun VerticalDivider(color: Color) =
@Composable
fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, accountViewModel: AccountViewModel) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
var showSelectTextDialog by remember { mutableStateOf(false) }
var showDeleteAlertDialog by remember { mutableStateOf(false) }
var showBlockAlertDialog by remember { mutableStateOf(false) }
var showReportDialog by remember { mutableStateOf(false) }
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
context,
context.getString(stringResource),
Toast.LENGTH_SHORT
).show()
}
}
var showSelectTextDialog by remember(note) { mutableStateOf(false) }
var showDeleteAlertDialog by remember(note) { mutableStateOf(false) }
var showBlockAlertDialog by remember(note) { mutableStateOf(false) }
var showReportDialog by remember(note) { mutableStateOf(false) }
if (popupExpanded) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colors.primary, 0.1f)
val cardShape = RoundedCornerShape(5.dp)
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val backgroundColor = if (MaterialTheme.colors.isLight) {
MaterialTheme.colors.primary
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background)
}
val showToast = { stringResource: Int ->
scope.launch {
Toast.makeText(
context,
context.getString(stringResource),
Toast.LENGTH_SHORT
).show()
}
}
val isOwnNote = accountViewModel.isLoggedUser(note.author)
val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author)

View File

@@ -4,14 +4,12 @@ import android.widget.Toast
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -20,23 +18,20 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.*
import kotlin.math.roundToInt
@@ -48,16 +43,11 @@ fun PollNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val pollViewModel: PollNoteViewModel = viewModel(
key = baseNote.idHex
key = baseNote.idHex + "PollNoteViewModel"
)
LaunchedEffect(key1 = baseNote) {
pollViewModel.load(account, baseNote)
}
pollViewModel.load(accountViewModel.account, baseNote)
PollNote(
baseNote = baseNote,
@@ -78,15 +68,11 @@ fun PollNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val zapsState by baseNote.live().zaps.observeAsState()
WatchZapsAndUpdateTallies(baseNote, pollViewModel)
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
pollViewModel.refreshTallies()
}
}
val tallies by pollViewModel.tallies.collectAsState()
pollViewModel.tallies.forEach { poll_op ->
tallies.forEach { poll_op ->
OptionNote(
poll_op,
pollViewModel,
@@ -99,6 +85,20 @@ fun PollNote(
}
}
@Composable
private fun WatchZapsAndUpdateTallies(
baseNote: Note,
pollViewModel: PollNoteViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
pollViewModel.refreshTallies()
}
}
}
@Composable
private fun OptionNote(
poolOption: PollOption,
@@ -109,6 +109,8 @@ private fun OptionNote(
backgroundColor: Color,
nav: (String) -> Unit
) {
val tags = remember { baseNote.event?.tags() ?: emptyList() }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp)
@@ -126,54 +128,16 @@ private fun OptionNote(
accountViewModel,
pollViewModel,
nonClickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
color,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = poolOption.tally.toFloat()
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.padding(horizontal = 10.dp)
.width(40.dp)
) {
Text(
text = "${(poolOption.tally.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp)
) {
TranslatableRichTextViewer(
poolOption.descriptor,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
nav
)
}
}
}
RenderOptionAfterVote(
poolOption.descriptor,
poolOption.tally.toFloat(),
color,
canPreview,
tags,
backgroundColor,
accountViewModel,
nav
)
},
clickablePrepend = {
}
@@ -186,32 +150,113 @@ private fun OptionNote(
pollViewModel,
nonClickablePrepend = {},
clickablePrepend = {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
) {
TranslatableRichTextViewer(
poolOption.descriptor,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
backgroundColor,
accountViewModel,
nav
)
}
RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav)
}
)
}
}
}
@Composable
private fun RenderOptionAfterVote(
description: String,
totalRatio: Float,
color: Color,
canPreview: Boolean,
tags: List<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val totalPercentage = remember(totalRatio) {
"${(totalRatio * 100).roundToInt()}%"
}
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
color,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = color,
progress = totalRatio
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Column(
horizontalAlignment = Alignment.End,
modifier = remember {
Modifier
.padding(horizontal = 10.dp)
.width(40.dp)
}
) {
Text(
text = totalPercentage,
fontWeight = FontWeight.Bold
)
}
Column(
modifier = remember {
Modifier
.fillMaxWidth()
.padding(15.dp)
}
) {
TranslatableRichTextViewer(
description,
canPreview,
remember { Modifier },
tags,
backgroundColor,
accountViewModel,
nav
)
}
}
}
}
@Composable
private fun RenderOptionBeforeVote(
description: String,
canPreview: Boolean,
tags: List<List<String>>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
Box(
Modifier
.fillMaxWidth(0.75f)
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
MaterialTheme.colors.primary,
RoundedCornerShape(15.dp)
)
) {
TranslatableRichTextViewer(
description,
canPreview,
remember { Modifier.padding(15.dp) },
tags,
backgroundColor,
accountViewModel,
nav
)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZapVote(
@@ -223,19 +268,18 @@ fun ZapVote(
nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
val isLoggedUser by remember {
derivedStateOf {
accountViewModel.isLoggedUser(baseNote.author)
}
}
var wantsToZap by remember { mutableStateOf(false) }
var zappingProgress by remember { mutableStateOf(0f) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) }
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
nonClickablePrepend()
Row(
@@ -265,7 +309,7 @@ fun ZapVote(
)
.show()
}
} else if (accountViewModel.isLoggedUser(zappedNote.author)) {
} else if (isLoggedUser) {
scope.launch {
Toast
.makeText(
@@ -287,11 +331,13 @@ fun ZapVote(
.show()
}
return@combinedClickable
} else if (account.zapAmountChoices.size == 1 && pollViewModel.isValidInputVoteAmount(account.zapAmountChoices.first())) {
} else if (accountViewModel.account.zapAmountChoices.size == 1 &&
pollViewModel.isValidInputVoteAmount(accountViewModel.account.zapAmountChoices.first())
) {
scope.launch(Dispatchers.IO) {
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
accountViewModel.account.zapAmountChoices.first() * 1000,
poolOption.option,
"",
context,
@@ -308,7 +354,7 @@ fun ZapVote(
zappingProgress = it
}
},
zapType = account.defaultZapType
zapType = accountViewModel.account.defaultZapType
)
}
} else {
@@ -374,7 +420,7 @@ fun ZapVote(
}
// only show tallies after a user has zapped note
if (baseNote.author == accountViewModel.userProfile() || pollViewModel.wasZappedByLoggedInAccount) {
if (!pollViewModel.canZap()) {
Text(
showAmount(poolOption.zappedValue),
fontSize = 14.sp,
@@ -399,23 +445,18 @@ fun FilteredZapAmountChoicePopup(
val context = LocalContext.current
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val defaultZapType by remember(accountState) {
derivedStateOf {
accountState?.account?.defaultZapType ?: LnZapEvent.ZapType.PRIVATE
}
}
val zapMessage = ""
val scope = rememberCoroutineScope()
val options = account.zapAmountChoices.filter { pollViewModel.isValidInputVoteAmount(it) }.toMutableList()
if (options.isEmpty()) {
pollViewModel.valueMinimum?.let { minimum ->
pollViewModel.valueMaximum?.let { maximum ->
if (minimum != maximum) {
options.add(((minimum + maximum) / 2).toLong())
}
}
}
val sortedOptions = remember(accountState) {
pollViewModel.createZapOptionsThatMatchThePollingParameters()
}
pollViewModel.valueMinimum?.let { options.add(it.toLong()) }
pollViewModel.valueMaximum?.let { options.add(it.toLong()) }
val sortedOptions = options.toSet().sorted()
Popup(
alignment = Alignment.BottomCenter,
@@ -424,6 +465,10 @@ fun FilteredZapAmountChoicePopup(
) {
FlowRow(horizontalArrangement = Arrangement.Center) {
sortedOptions.forEach { amountInSats ->
val zapAmount = remember {
"${showAmount(amountInSats.toBigDecimal().setScale(1))}"
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
@@ -436,7 +481,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
account.defaultZapType
defaultZapType
)
onDismiss()
}
@@ -448,7 +493,7 @@ fun FilteredZapAmountChoicePopup(
)
) {
Text(
"${showAmount(amountInSats.toBigDecimal().setScale(1))}",
text = zapAmount,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
@@ -462,7 +507,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
account.defaultZapType
defaultZapType
)
onDismiss()
}
@@ -477,118 +522,3 @@ fun FilteredZapAmountChoicePopup(
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZapVoteAmountChoicePopup(
baseNote: Note,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
pollOption: Int,
onDismiss: () -> Unit,
onError: (text: String) -> Unit,
onProgress: (percent: Float) -> Unit
) {
val context = LocalContext.current
var inputAmountText by rememberSaveable { mutableStateOf("") }
val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red
)
val colorValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.primary,
unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
Dialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(
dismissOnClickOutside = true,
usePlatformDefaultWidth = false
)
) {
Surface {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(10.dp)
) {
var amount = pollViewModel.inputVoteAmountLong(inputAmountText)
// only prompt for input amount if vote is not atomic
if (!pollViewModel.isVoteAmountAtomic()) {
OutlinedTextField(
value = inputAmountText,
onValueChange = { inputAmountText = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp),
colors = if (pollViewModel.isValidInputVoteAmount(amount)) colorValid else colorInValid,
label = {
Text(
text = stringResource(R.string.poll_zap_amount),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
placeholder = {
Text(
text = pollViewModel.voteAmountPlaceHolderText(context.getString(R.string.sats)),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
)
} else { amount = pollViewModel.valueMaximum?.toLong() }
val isValidInputAmount = pollViewModel.isValidInputVoteAmount(amount)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
enabled = isValidInputAmount,
onClick = {
if (amount != null && isValidInputAmount) {
accountViewModel.zap(
baseNote,
amount * 1000,
pollOption,
"",
context,
onError,
onProgress
)
onDismiss()
}
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
)
) {
Text(
"${showAmount(amount?.toBigDecimal()?.setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.combinedClickable(
onClick = {
if (amount != null && isValidInputAmount) {
accountViewModel.zap(
baseNote,
amount * 1000,
pollOption,
"",
context,
onError,
onProgress
)
onDismiss()
}
},
onLongClick = {}
)
)
}
}
}
}
}

View File

@@ -1,17 +1,21 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.*
@Immutable
data class PollOption(
val option: Int,
val descriptor: String,
@@ -21,21 +25,23 @@ data class PollOption(
val zappedByLoggedIn: Boolean
)
@Stable
class PollNoteViewModel : ViewModel() {
var account: Account? = null
private var account: Account? = null
private var pollNote: Note? = null
var pollEvent: PollNoteEvent? = null
var pollOptions: Map<Int, String>? = null
var valueMaximum: Int? = null
var valueMinimum: Int? = null
private var pollEvent: PollNoteEvent? = null
private var pollOptions: Map<Int, String>? = null
private var valueMaximum: Int? = null
private var valueMinimum: Int? = null
private var closedAt: Int? = null
var consensusThreshold: BigDecimal? = null
private var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
var wasZappedByLoggedInAccount: Boolean = false
private var totalZapped: BigDecimal = BigDecimal.ZERO
private var wasZappedByLoggedInAccount: Boolean = false
var tallies by mutableStateOf<List<PollOption>>(emptyList())
private val _tallies = MutableStateFlow<List<PollOption>>(emptyList())
val tallies = _tallies.asStateFlow()
fun load(acc: Account, note: Note?) {
account = acc
@@ -46,36 +52,35 @@ class PollNoteViewModel : ViewModel() {
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagInt(CLOSED_AT)
refreshTallies()
}
fun refreshTallies() {
suspend fun refreshTallies() {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
tallies = pollOptions?.keys?.map {
val zappedInOption = zappedPollOptionAmount(it)
_tallies.emit(
pollOptions?.keys?.map {
val zappedInOption = zappedPollOptionAmount(it)
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false
val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
} ?: emptyList()
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
} ?: emptyList()
)
}
fun canZap(): Boolean {
val account = account ?: return false
val user = account.userProfile() ?: return false
val note = pollNote ?: return false
return user != note.author && !wasZappedByLoggedInAccount
return account.userProfile() != note.author && !wasZappedByLoggedInAccount
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -154,4 +159,21 @@ class PollNoteViewModel : ViewModel() {
}
} ?: BigDecimal(0)
}
fun createZapOptionsThatMatchThePollingParameters(): List<Long> {
val options = account?.zapAmountChoices?.filter { isValidInputVoteAmount(it) }?.toMutableList() ?: mutableListOf()
if (options.isEmpty()) {
valueMinimum?.let { minimum ->
valueMaximum?.let { maximum ->
if (minimum != maximum) {
options.add(((minimum + maximum) / 2).toLong())
}
}
}
}
valueMinimum?.let { options.add(it.toLong()) }
valueMaximum?.let { options.add(it.toLong()) }
return options.toSet().sorted()
}
}

View File

@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import android.widget.Toast
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +21,7 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
@@ -56,6 +58,7 @@ import coil.request.CachePolicy
import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -68,9 +71,6 @@ import kotlin.math.roundToInt
@Composable
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
var wantsToReplyTo by remember {
@@ -82,17 +82,17 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, nav: (Strin
}
if (wantsToReplyTo != null) {
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, nav)
NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, accountViewModel, nav)
}
if (wantsToQuote != null) {
NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, nav)
NewPostView({ wantsToQuote = null }, null, wantsToQuote, accountViewModel, nav)
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = CenterVertically) {
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
ReplyReaction(baseNote, grayTint, accountViewModel) {
wantsToReplyTo = baseNote
}
@@ -123,17 +123,6 @@ fun ReplyReaction(
iconSize: Dp = 20.dp,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
val replies = remember(repliesState) { repliesState?.note?.replies } ?: emptySet()
val isWriteable = remember { accountViewModel.isWriteable() }
val replyCount by remember(repliesState) {
derivedStateOf {
showCount(replies.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -148,7 +137,7 @@ fun ReplyReaction(
IconButton(
modifier = iconButtonModifier,
onClick = {
if (isWriteable) {
if (accountViewModel.isWriteable()) {
onPress()
} else {
scope.launch {
@@ -170,14 +159,26 @@ fun ReplyReaction(
}
if (showCounter) {
Text(
" $replyCount",
fontSize = 14.sp,
color = grayTint
)
ReplyCounter(baseNote, grayTint)
}
}
@Composable
fun ReplyCounter(baseNote: Note, textColor: Color) {
val repliesState by baseNote.live().replies.observeAsState()
val replyCount by remember(repliesState) {
derivedStateOf {
" " + showCount(repliesState?.note?.replies?.size)
}
}
Text(
text = replyCount,
fontSize = 14.sp,
color = textColor
)
}
@Composable
fun BoostReaction(
baseNote: Note,
@@ -186,29 +187,6 @@ fun BoostReaction(
iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = remember(boostsState) { boostsState?.note } ?: return
val hasBoosted by remember(boostsState) {
derivedStateOf {
accountViewModel.hasBoosted(baseNote)
}
}
val wasBoostedByLoggedIn by remember(boostsState) {
derivedStateOf {
boostedNote.isBoostedBy(accountViewModel.userProfile())
}
}
val isWriteable = remember { accountViewModel.isWriteable() }
val boostCount by remember(boostsState) {
derivedStateOf {
showCount(boostedNote.boosts.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -218,15 +196,11 @@ fun BoostReaction(
Modifier.size(iconSize)
}
val iconModifier = remember {
Modifier.size(iconSize)
}
IconButton(
modifier = iconButtonModifier,
onClick = {
if (isWriteable) {
if (hasBoosted) {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
accountViewModel.deleteBoostsTo(baseNote)
} else {
wantsToBoost = true
@@ -256,16 +230,45 @@ fun BoostReaction(
)
}
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = iconModifier,
tint = if (wasBoostedByLoggedIn) Color.Unspecified else grayTint
)
BoostIcon(baseNote, iconSize, grayTint, accountViewModel.userProfile())
}
BoostText(baseNote, grayTint)
}
@Composable
fun BoostIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: User) {
val boostsState by baseNote.live().boosts.observeAsState()
val iconTint by remember(boostsState) {
derivedStateOf {
if (boostsState?.note?.isBoostedBy(loggedIn) == true) Color.Unspecified else grayTint
}
}
val iconModifier = remember {
Modifier.size(iconSize)
}
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
modifier = iconModifier,
tint = iconTint
)
}
@Composable
fun BoostText(baseNote: Note, grayTint: Color) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostCount by remember(boostsState) {
derivedStateOf {
" " + showCount(boostsState?.note?.boosts?.size)
}
}
Text(
" $boostCount",
boostCount,
fontSize = 14.sp,
color = grayTint
)
@@ -279,29 +282,6 @@ fun LikeReaction(
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = remember(reactionsState) { reactionsState?.note } ?: return
val hasReacted by remember(reactionsState) {
derivedStateOf {
accountViewModel.hasReactedTo(baseNote)
}
}
val wasReactedByLoggedIn by remember(reactionsState) {
derivedStateOf {
reactedNote.isReactedBy(accountViewModel.userProfile())
}
}
val isWriteable = remember { accountViewModel.isWriteable() }
val reactionCount by remember(reactionsState) {
derivedStateOf {
showCount(reactedNote.reactions.size)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -309,15 +289,11 @@ fun LikeReaction(
Modifier.size(iconSize)
}
val iconModifier = remember {
Modifier.size(heartSize)
}
IconButton(
modifier = iconButtonModifier,
onClick = {
if (isWriteable) {
if (hasReacted) {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
accountViewModel.deleteReactionTo(baseNote)
} else {
accountViewModel.reactTo(baseNote)
@@ -333,25 +309,69 @@ fun LikeReaction(
}
}
) {
if (wasReactedByLoggedIn) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = iconModifier,
tint = grayTint
)
LikeIcon(baseNote, heartSize, grayTint, accountViewModel.userProfile())
}
LikeText(baseNote, grayTint)
}
@Composable
fun LikeIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: User) {
val reactionsState by baseNote.live().reactions.observeAsState()
var wasReactedByLoggedIn by remember(reactionsState) {
mutableStateOf(false)
}
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val newWasReactedByLoggedIn = reactionsState?.note?.isReactedBy(loggedIn) == true
if (wasReactedByLoggedIn != newWasReactedByLoggedIn) {
wasReactedByLoggedIn = newWasReactedByLoggedIn
}
}
}
val iconModifier = remember {
Modifier.size(iconSize)
}
if (wasReactedByLoggedIn) {
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
modifier = iconModifier,
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
modifier = iconModifier,
tint = grayTint
)
}
}
@Composable
fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsState by baseNote.live().reactions.observeAsState()
var reactionsCount by remember(reactionsState) {
mutableStateOf("")
}
LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) {
val newReactionsCount = " " + showCount(reactionsState?.note?.reactions?.size)
if (reactionsCount != newReactionsCount) {
reactionsCount = newReactionsCount
}
}
}
Text(
" $reactionCount",
reactionsCount,
fontSize = 14.sp,
color = grayTint
)
@@ -363,16 +383,12 @@ fun ZapReaction(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel,
textModifier: Modifier = Modifier,
iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = remember(zapsState) { zapsState?.note } ?: return
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
@@ -382,32 +398,6 @@ fun ZapReaction(
var zappingProgress by remember { mutableStateOf(0f) }
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
var zapAmountTxt by remember { mutableStateOf<String>("") }
LaunchedEffect(key1 = zapsState) {
scope.launch(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
if (wasZappedByLoggedInUser != newWasZapped) {
wasZappedByLoggedInUser = newWasZapped
}
}
val newZapAmount = showAmount(account.calculateZappedAmount(zappedNote))
if (newZapAmount != zapAmountTxt) {
zapAmountTxt = newZapAmount
}
if (wasZappedByLoggedInUser) {
if (abs(zappingProgress - 1) < 0.001) {
zappingProgress = 1f
}
}
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
@@ -498,36 +488,99 @@ fun ZapReaction(
}
)
}
if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
}
if (wantsToSetCustomZap) {
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
}
if (wasZappedByLoggedInUser) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(iconSize),
tint = BitcoinOrange
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(Modifier.width(3.dp))
val animatedProgress = animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec
).value
CircularProgressIndicator(
progress = animatedProgress,
modifier = Modifier.size(animationSize),
strokeWidth = 2.dp
)
} else {
if (zappingProgress < 0.1 || zappingProgress > 0.99) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = Modifier.size(iconSize),
tint = grayTint
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
modifier = Modifier.size(animationSize),
strokeWidth = 2.dp
)
ZapIcon(
baseNote,
iconSize,
grayTint,
accountViewModel
)
}
}
ZapAmountText(baseNote, grayTint, accountViewModel)
}
@Composable
private fun ZapIcon(
baseNote: Note,
iconSize: Dp,
grayTint: Color,
accountViewModel: AccountViewModel
) {
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
if (!wasZappedByLoggedInUser) {
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(it)
if (wasZappedByLoggedInUser != newWasZapped) {
wasZappedByLoggedInUser = newWasZapped
}
}
}
}
}
if (wasZappedByLoggedInUser) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = BitcoinOrange
)
} else {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
modifier = remember { Modifier.size(iconSize) },
tint = grayTint
)
}
}
@Composable
private fun ZapAmountText(
baseNote: Note,
grayTint: Color,
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
var zapAmountTxt by remember { mutableStateOf("") }
LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let {
val newZapAmount = showAmount(accountViewModel.calculateZapAmount(it))
if (newZapAmount != zapAmountTxt) {
zapAmountTxt = newZapAmount
}
}
}
}
@@ -535,8 +588,7 @@ fun ZapReaction(
Text(
zapAmountTxt,
fontSize = 14.sp,
color = grayTint,
modifier = textModifier
color = grayTint
)
}

View File

@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
@@ -14,39 +16,50 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, nav: (String) -> Unit) {
var dupMentions by remember { mutableStateOf<List<User>?>(null) }
fun ReplyInformation(
replyTo: List<Note>?,
mentions: List<String>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
launch(Dispatchers.IO) {
sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
?.toSet()?.sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) }
}
}
if (dupMentions != null) {
ReplyInformation(replyTo, dupMentions, account) {
if (sortedMentions != null) {
ReplyInformation(replyTo, sortedMentions) {
nav("User/${it.pubkeyHex}")
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Account, prefix: String = "", onUserTagClick: (User) -> Unit) {
val mentions = dupMentions?.toSet()?.sortedBy { !account.userProfile().isFollowingCached(it) }
var expanded by remember { mutableStateOf((mentions?.size ?: 0) <= 2) }
private fun ReplyInformation(
replyTo: List<Note>?,
sortedMentions: List<User>?,
prefix: String = "",
onUserTagClick: (User) -> Unit
) {
var expanded by remember { mutableStateOf((sortedMentions?.size ?: 0) <= 2) }
FlowRow() {
if (mentions != null && mentions.isNotEmpty()) {
if (sortedMentions != null && sortedMentions.isNotEmpty()) {
if (replyTo != null && replyTo.isNotEmpty()) {
val repliesToDisplay = if (expanded) mentions else mentions.take(2)
val repliesToDisplay = if (expanded) sortedMentions else sortedMentions.take(2)
Text(
stringResource(R.string.replying_to),
@@ -55,57 +68,47 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
)
repliesToDisplay.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
val innerUser = innerUserState?.user
ReplyInfoMention(user, prefix, onUserTagClick)
innerUser?.let { myUser ->
CreateClickableTextWithEmoji(
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
)
if (expanded) {
if (idx < repliesToDisplay.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < repliesToDisplay.size - 1) {
Text(
stringResource(R.string.and),
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
} else {
if (idx < repliesToDisplay.size - 1) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < repliesToDisplay.size) {
Text(
stringResource(R.string.and),
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
if (expanded) {
if (idx < repliesToDisplay.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < repliesToDisplay.size - 1) {
Text(
"${stringResource(R.string.and)}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
} else {
if (idx < repliesToDisplay.size - 1) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < repliesToDisplay.size) {
Text(
"${stringResource(R.string.and)}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
ClickableText(
AnnotatedString("${sortedMentions.size - 2}"),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { expanded = true }
)
ClickableText(
AnnotatedString("${mentions.size - 2}"),
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { expanded = true }
)
Text(
" ${stringResource(R.string.others)}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
Text(
" ${stringResource(R.string.others)}",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
@@ -115,30 +118,38 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
}
@Composable
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<String>, channel: Channel, account: Account, nav: (String) -> Unit) {
fun ReplyInformationChannel(
replyTo: List<Note>?,
mentions: List<String>,
channelHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
sortedMentions = mentions
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.isFollowing(it) }
.sortedBy { accountViewModel.account.isFollowing(it) }
}
}
if (sortedMentions != null) {
ReplyInformationChannel(
replyTo,
sortedMentions,
channel,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
},
onChannelTagClick = {
nav("Channel/${it.idHex}")
}
)
LoadChannel(channelHex) { channel ->
ReplyInformationChannel(
replyTo,
sortedMentions,
channel,
onUserTagClick = {
nav("User/${it.pubkeyHex}")
},
onChannelTagClick = {
nav("Channel/${it.idHex}")
}
)
}
}
}
@@ -157,6 +168,7 @@ fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyInformationChannel(
replyTo: List<Note>?,
@@ -190,36 +202,46 @@ fun ReplyInformationChannel(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
val mentionSet = mentions.toSet()
mentions.forEachIndexed { idx, user ->
ReplyInfoMention(user, prefix, onUserTagClick)
mentionSet.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
CreateClickableTextWithEmoji(
clickablePart = "$prefix@${myUser.toBestDisplayName()}",
tags = myUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onUserTagClick(myUser) }
if (idx < mentions.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentions.size - 1) {
Text(
" ${stringResource(id = R.string.add)} ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
if (idx < mentionSet.size - 2) {
Text(
", ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
} else if (idx < mentionSet.size - 1) {
Text(
" ${stringResource(id = R.string.add)} ",
fontSize = 13.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
}
}
}
}
}
}
@Composable
private fun ReplyInfoMention(
user: User,
prefix: String,
onUserTagClick: (User) -> Unit
) {
val innerUserState by user.live().metadata.observeAsState()
val innerUser = remember(innerUserState) {
innerUserState?.user
} ?: return
CreateClickableTextWithEmoji(
clickablePart = "$prefix${innerUser.toBestDisplayName()}",
tags = innerUser.info?.latestMetadata?.tags,
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(alpha = 0.52f),
fontSize = 13.sp
),
onClick = { onUserTagClick(innerUser) }
)
}

View File

@@ -65,6 +65,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -74,15 +75,14 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope
class UpdateZapAmountViewModel : ViewModel() {
private var account: Account? = null
class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
@@ -90,8 +90,7 @@ class UpdateZapAmountViewModel : ViewModel() {
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
fun load(account: Account) {
this.account = account
fun load() {
this.amountSet = account.zapAmountChoices
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
@@ -185,14 +184,25 @@ class UpdateZapAmountViewModel : ViewModel() {
TextFieldValue(contact.secret ?: "")
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <UpdateZapAmountViewModel : ViewModel> create(modelClass: Class<UpdateZapAmountViewModel>): UpdateZapAmountViewModel {
return UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: String? = null) {
fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val postViewModel: UpdateZapAmountViewModel = viewModel()
val postViewModel: UpdateZapAmountViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex,
factory = UpdateZapAmountViewModel.Factory(accountViewModel.account)
)
val uri = LocalUriHandler.current
val zapTypes = listOf(
@@ -205,8 +215,8 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
LaunchedEffect(account) {
postViewModel.load(account)
LaunchedEffect(accountViewModel) {
postViewModel.load()
if (nip47uri != null) {
try {
postViewModel.updateNIP47(nip47uri)
@@ -341,7 +351,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
) {
TextSpinner(
label = stringResource(id = R.string.zap_type_explainer),
placeholder = zapTypes.filter { it.first == account.defaultZapType }
placeholder = zapTypes.filter { it.first == accountViewModel.defaultZapType() }
.first().second,
options = zapOptions,
explainers = zapOptionExplainers,

View File

@@ -5,23 +5,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UserCompose(
@@ -35,14 +25,6 @@ fun UserCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val coroutineScope = rememberCoroutineScope()
Column(
modifier =
Modifier.clickable(
@@ -53,34 +35,18 @@ fun UserCompose(
modifier = overallModifier,
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, nav, account.userProfile(), 55.dp)
UserPicture(baseUser, nav, accountViewModel, 55.dp)
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
)
AboutDisplay(baseUser)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (userFollows.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } })
}
UserActionOptions(baseUser, accountViewModel)
}
}

View File

@@ -14,10 +14,9 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -27,6 +26,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel
import com.patrykandpatrick.vico.core.entry.ChartEntryModel
@@ -34,6 +34,7 @@ import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer
import com.patrykandpatrick.vico.core.entry.composed.plus
import com.patrykandpatrick.vico.core.entry.entryOf
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -42,11 +43,14 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.time.Instant
@@ -80,47 +84,50 @@ fun UserReactionsRow(
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserReplyReaction(model.replies[model.today()])
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserBoostReaction(model.boosts[model.today()])
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserLikeReaction(model.reactions[model.today()])
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserZapReaction(model.zaps[model.today()])
val zaps by model.zaps.collectAsState()
UserZapReaction(zaps[model.today()])
}
}
}
@Stable
class UserReactionsViewModel : ViewModel() {
var user: User? = null
class UserReactionsViewModel(val account: Account) : ViewModel() {
val user: User = account.userProfile()
var reactions by mutableStateOf<Map<String, Int>>(emptyMap())
var boosts by mutableStateOf<Map<String, Int>>(emptyMap())
var zaps by mutableStateOf<Map<String, BigDecimal>>(emptyMap())
var replies by mutableStateOf<Map<String, Int>>(emptyMap())
private var _reactions = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _boosts = MutableStateFlow<Map<String, Int>>(emptyMap())
private var _zaps = MutableStateFlow<Map<String, BigDecimal>>(emptyMap())
private var _replies = MutableStateFlow<Map<String, Int>>(emptyMap())
var chartModel by mutableStateOf<ComposedChartEntryModel<ChartEntryModel>?>(null)
var axisLabels by mutableStateOf<List<String>>(emptyList())
private var _chartModel = MutableStateFlow<ComposedChartEntryModel<ChartEntryModel>?>(null)
private var _axisLabels = MutableStateFlow<List<String>>(emptyList())
val reactions = _reactions.asStateFlow()
val boosts = _boosts.asStateFlow()
val zaps = _zaps.asStateFlow()
val replies = _replies.asStateFlow()
val chartModel = _chartModel.asStateFlow()
val axisLabels = _axisLabels.asStateFlow()
private var takenIntoAccount = setOf<HexKey>()
private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
fun load(baseUser: User) {
user = baseUser
reactions = emptyMap()
boosts = emptyMap()
zaps = emptyMap()
replies = emptyMap()
takenIntoAccount = emptySet()
}
fun formatDate(createAt: Long): String {
return sdf.format(
Instant.ofEpochSecond(createAt)
@@ -131,8 +138,8 @@ class UserReactionsViewModel : ViewModel() {
fun today() = sdf.format(LocalDateTime.now())
fun initializeSuspend() {
val currentUser = user?.pubkeyHex ?: return
private suspend fun initializeSuspend() {
val currentUser = user.pubkeyHex
val reactions = mutableMapOf<String, Int>()
val boosts = mutableMapOf<String, Int>()
@@ -172,21 +179,21 @@ class UserReactionsViewModel : ViewModel() {
}
this.takenIntoAccount = takenIntoAccount
this.reactions = reactions
this.replies = replies
this.zaps = zaps
this.boosts = boosts
this._reactions.emit(reactions)
this._replies.emit(replies)
this._zaps.emit(zaps)
this._boosts.emit(boosts)
refreshChartModel()
}
fun addToStatsSuspend(newNotes: Set<Note>) {
val currentUser = user?.pubkeyHex ?: return
suspend fun addToStatsSuspend(newNotes: Set<Note>) {
val currentUser = user.pubkeyHex
val reactions = this.reactions.toMutableMap()
val boosts = this.boosts.toMutableMap()
val zaps = this.zaps.toMutableMap()
val replies = this.replies.toMutableMap()
val reactions = this._reactions.value.toMutableMap()
val boosts = this._boosts.value.toMutableMap()
val zaps = this._zaps.value.toMutableMap()
val replies = this._replies.value.toMutableMap()
val takenIntoAccount = this.takenIntoAccount.toMutableSet()
var hasNewElements = false
@@ -227,16 +234,16 @@ class UserReactionsViewModel : ViewModel() {
if (hasNewElements) {
this.takenIntoAccount = takenIntoAccount
this.reactions = reactions
this.replies = replies
this.zaps = zaps
this.boosts = boosts
this._reactions.emit(reactions)
this._replies.emit(replies)
this._zaps.emit(zaps)
this._boosts.emit(boosts)
refreshChartModel()
}
}
private fun refreshChartModel() {
private suspend fun refreshChartModel() {
val day = 24 * 60 * 60L
val now = LocalDateTime.now()
val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE")
@@ -244,43 +251,66 @@ class UserReactionsViewModel : ViewModel() {
val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) }
val listOfCountCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, replies[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, boosts[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, reactions[dateStr]?.toFloat() ?: 0f) }
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) }
)
val listOfValueCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, zaps[dateStr]?.toFloat() ?: 0f) }
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) }
)
val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel()
val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel()
this.axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) }
this.chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2)
this._axisLabels.emit(listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) })
this._chartModel.emit(chartEntryModelProducer1.plus(chartEntryModelProducer2))
}
var collectorJob: Job? = null
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
addToStatsSuspend(newNotes)
_reactions.value = emptyMap()
_boosts.value = emptyMap()
_zaps.value = emptyMap()
_replies.value = emptyMap()
takenIntoAccount = emptySet()
viewModelScope.launch(Dispatchers.IO) {
initializeSuspend()
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateInsertData(newNotes)
}
}
}
}
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) {
addToStatsSuspend(it.flatten().toSet())
}
}
override fun onCleared() {
collectorJob?.cancel()
super.onCleared()
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <UserReactionsViewModel : ViewModel> create(modelClass: Class<UserReactionsViewModel>): UserReactionsViewModel {
return UserReactionsViewModel(account) as UserReactionsViewModel
}
}
}
@Composable
fun UserReplyReaction(
replyCount: Int?
) {
val showCounts = remember { showCount(replyCount) }
val showCounts = remember(replyCount) { showCount(replyCount) }
Icon(
painter = painterResource(R.drawable.ic_comment),
@@ -302,7 +332,7 @@ fun UserReplyReaction(
fun UserBoostReaction(
boostCount: Int?
) {
val showCounts = remember { showCount(boostCount) }
val showCounts = remember(boostCount) { showCount(boostCount) }
Icon(
painter = painterResource(R.drawable.ic_retweeted),
@@ -324,7 +354,7 @@ fun UserBoostReaction(
fun UserLikeReaction(
likeCount: Int?
) {
val showCounts = remember { showCount(likeCount) }
val showCounts = remember(likeCount) { showCount(likeCount) }
Icon(
painter = painterResource(R.drawable.ic_liked),
@@ -346,7 +376,7 @@ fun UserLikeReaction(
fun UserZapReaction(
amount: BigDecimal?
) {
val showAmounts = remember { showAmountAxis(amount) }
val showAmounts = remember(amount) { showAmountAxis(amount) }
Icon(
imageVector = Icons.Default.Bolt,

View File

@@ -16,7 +16,9 @@ import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
val noteState by baseNote.live().metadata.observeAsState()
val author = remember(noteState) { noteState?.note?.author } ?: return
val author = remember(noteState) {
noteState?.note?.author
} ?: return
UsernameDisplay(author, weight)
}

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
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
@@ -18,40 +19,32 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
@Composable
fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val userState by account.userProfile().live().follows.observeAsState()
val userFollows = userState?.user ?: return
val noteState by baseNote.second.live().metadata.observeAsState()
val noteZap = noteState?.note ?: return
val baseNoteRequest by baseNote.first.live().metadata.observeAsState()
val noteZapRequest = baseNoteRequest?.note ?: return
val noteZapRequest = remember(baseNoteRequest) { baseNoteRequest?.note } ?: return
val baseAuthor = noteZapRequest.author
val coroutineScope = rememberCoroutineScope()
var baseAuthor by remember {
mutableStateOf(noteZapRequest.author)
}
if (baseAuthor == null) {
BlankNote()
@@ -59,70 +52,25 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
Column(
modifier =
Modifier.clickable(
onClick = { nav("User/${baseAuthor.pubkeyHex}") }
onClick = { nav("User/${baseAuthor?.pubkeyHex}") }
),
verticalArrangement = Arrangement.Center
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseAuthor, nav, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseAuthor)
}
val baseAuthorState by baseAuthor.live().metadata.observeAsState()
val user = baseAuthorState?.user ?: return
Text(
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = noteZap) {
coroutineScope.launch(Dispatchers.IO) {
zapAmount = (noteZap.event as? LnZapEvent)?.amount
}
}
Column(
modifier = Modifier.padding(start = 10.dp),
verticalArrangement = Arrangement.Center
) {
Text(
"${showAmount(zapAmount)} ${stringResource(R.string.sats)}",
color = BitcoinOrange,
fontSize = 20.sp,
fontWeight = FontWeight.W500
)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
if (account.isHidden(baseAuthor)) {
ShowUserButton {
account.showUser(baseAuthor.pubkeyHex)
LaunchedEffect(Unit) {
launch(Dispatchers.Default) {
(noteZapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(noteZapRequest)
if (decryptedContent != null) {
baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
}
} else if (userFollows.isFollowingCached(baseAuthor)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseAuthor) } }
} else {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } })
}
}
}
baseAuthor?.let {
RenderZapNote(it, baseNote.second, nav, accountViewModel)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
@@ -130,3 +78,118 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
}
}
}
@Composable
private fun RenderZapNote(
baseAuthor: User,
zapNote: Note,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseAuthor, nav, accountViewModel, 55.dp)
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseAuthor)
}
AboutDisplay(baseAuthor)
}
Column(
modifier = Modifier.padding(start = 10.dp),
verticalArrangement = Arrangement.Center
) {
ZapAmount(zapNote)
}
Column(modifier = Modifier.padding(start = 10.dp)) {
UserActionOptions(baseAuthor, accountViewModel)
}
}
}
@Composable
private fun ZapAmount(zapEventNote: Note) {
val noteState by zapEventNote.live().metadata.observeAsState()
val noteZap = remember(noteState) { noteState?.note } ?: return
var zapAmount by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = noteZap) {
launch(Dispatchers.IO) {
zapAmount = showAmountAxis((noteZap.event as? LnZapEvent)?.amount)
}
}
zapAmount?.let {
Text(
text = it,
color = BitcoinOrange,
fontSize = 20.sp,
fontWeight = FontWeight.W500
)
}
}
@Composable
fun UserActionOptions(
baseAuthor: User,
accountViewModel: AccountViewModel
) {
val coroutineScope = rememberCoroutineScope()
val accountState by accountViewModel.accountLiveData.observeAsState()
val isHidden by remember(accountState) {
derivedStateOf {
accountState?.account?.isHidden(baseAuthor) ?: false
}
}
val userState by accountViewModel.account.userProfile().live().follows.observeAsState()
val isFollowing by remember(userState) {
derivedStateOf {
userState?.user?.isFollowingCached(baseAuthor) ?: false
}
}
if (isHidden) {
ShowUserButton {
accountViewModel.show(baseAuthor)
}
} else if (isFollowing) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } }
} else {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } })
}
}
@Composable
fun AboutDisplay(baseAuthor: User) {
val baseAuthorState by baseAuthor.live().metadata.observeAsState()
val userAboutMe by remember(baseAuthorState) {
derivedStateOf {
baseAuthorState?.user?.info?.about ?: ""
}
}
Text(
userAboutMe,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}

View File

@@ -1,139 +0,0 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
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.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by zapSetCard.note.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val noteEvent = note?.event
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
if (note == null) {
BlankNote(Modifier, isInnerNote)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = zapSetCard) {
withContext(Dispatchers.IO) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
}
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
Column(
modifier = Modifier
.background(backgroundColor)
.combinedClickable(
onClick = {
scope.launch {
routeFor(
note,
account.userProfile()
)?.let { nav(it) }
}
},
onLongClick = { popupExpanded = true }
)
) {
Row(
modifier = Modifier
.padding(
start = if (!isInnerNote) 12.dp else 0.dp,
end = if (!isInnerNote) 12.dp else 0.dp,
top = 10.dp
)
) {
// Draws the like picture outside the boosted card.
if (!isInnerNote) {
Box(
modifier = Modifier
.width(55.dp)
.padding(0.dp)
) {
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(id = R.string.zaps),
tint = BitcoinOrange,
modifier = Modifier
.size(25.dp)
.align(Alignment.TopEnd)
)
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
baseNote = it.key,
nav = nav,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
NoteCompose(
baseNote = note,
routeForLastRead = null,
modifier = Modifier.padding(top = 5.dp),
isBoostedNote = true,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
}
}
}
}
}

View File

@@ -1,10 +1,10 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -15,8 +15,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -25,33 +25,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
var isNew by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = zapSetCard.createdAt()) {
withContext(Dispatchers.IO) {
isNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
launch(Dispatchers.IO) {
val newIsNew = zapSetCard.createdAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, zapSetCard.createdAt)
if (newIsNew != isNew) {
isNew = newIsNew
}
}
}
var backgroundColor = if (isNew) {
MaterialTheme.colors.primary.copy(0.12f).compositeOver(MaterialTheme.colors.background)
MaterialTheme.colors.newItemBackgroundColor.compositeOver(MaterialTheme.colors.background)
} else {
MaterialTheme.colors.background
}
@@ -90,16 +91,8 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
FlowRow() {
zapSetCard.zapEvents.forEach {
NoteAuthorPicture(
baseNote = it.key,
nav = nav,
userAccount = account.userProfile(),
size = 35.dp
)
}
}
val zapEvents by remember { derivedStateOf { zapSetCard.zapEvents } }
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav)
}

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
@@ -21,7 +22,7 @@ import nostr.postr.Persona
import nostr.postr.bechToBytes
import java.util.regex.Pattern
class AccountStateViewModel() : ViewModel() {
class AccountStateViewModel(val context: Context) : ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
@@ -85,7 +86,7 @@ class AccountStateViewModel() : ViewModel() {
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
ServiceManager.start(account)
ServiceManager.start(account, context)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)

View File

@@ -32,24 +32,6 @@ class NoteCard(val note: Note) : Card() {
override fun id() = note.idHex
}
@Immutable
class LikeSetCard(val note: Note, val likeEvents: List<Note>) : Card() {
val createdAt = likeEvents.maxOf { it.createdAt() ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + "L" + createdAt
}
@Immutable
class ZapSetCard(val note: Note, val zapEvents: Map<Note, Note>) : Card() {
val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 }
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + "Z" + createdAt
}
@Immutable
class ZapUserSetCard(val user: User, val zapEvents: ImmutableMap<Note, Note>) : Card() {
val createdAt = zapEvents.maxOf { it.value.createdAt() ?: 0 }
@@ -61,27 +43,22 @@ class ZapUserSetCard(val user: User, val zapEvents: ImmutableMap<Note, Note>) :
@Immutable
class MultiSetCard(val note: Note, val boostEvents: ImmutableList<Note>, val likeEvents: ImmutableList<Note>, val zapEvents: ImmutableMap<Note, Note>) : Card() {
val createdAt = maxOf(
val maxCreatedAt = maxOf(
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0,
boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0
)
override fun createdAt(): Long {
return createdAt
}
override fun id() = note.idHex + "X" + createdAt
}
@Immutable
class BoostSetCard(val note: Note, val boostEvents: List<Note>) : Card() {
val createdAt = boostEvents.maxOf { it.createdAt() ?: 0 }
val minCreatedAt = minOf(
zapEvents.minOfOrNull { it.value.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE
)
override fun createdAt(): Long {
return createdAt
return maxCreatedAt
}
override fun id() = note.idHex + "B" + createdAt
override fun id() = note.idHex + "X" + maxCreatedAt + "X" + minCreatedAt
}
@Immutable

View File

@@ -28,83 +28,50 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.note.BadgeCompose
import com.vitorpamplona.amethyst.ui.note.BoostSetCompose
import com.vitorpamplona.amethyst.ui.note.LikeSetCompose
import com.vitorpamplona.amethyst.ui.note.MessageSetCompose
import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun CardFeedView(
fun RefresheableCardView(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
scrollStateKey: String? = null,
scrollToTop: Boolean = false
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.fillMaxSize().pullRefresh(pullRefreshState)) {
Column(modifier = Modifier.fillMaxSize()) {
Crossfade(
modifier = Modifier.fillMaxSize(),
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
val modifier = if (enablePullRefresh) {
Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
refreshing = true
}
}
is CardFeedState.FeedError -> {
FeedError(state.errorMessage) {
refreshing = true
}
}
is CardFeedState.Loaded -> {
if (refreshing) {
refreshing = false
}
FeedLoaded(
state = state,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead,
scrollStateKey = scrollStateKey,
scrollToTop = scrollToTop
)
}
CardFeedState.Loading -> {
LoadingFeed()
}
}
}
Box(modifier) {
Column {
SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}
@Composable
private fun FeedLoaded(
state: CardFeedState.Loaded,
private fun SaveableCardFeedState(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
scrollStateKey: String?,
scrollToTop: Boolean = false
scrollStateKey: String? = null
) {
val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
@@ -112,25 +79,81 @@ private fun FeedLoaded(
rememberLazyListState()
}
if (scrollToTop) {
LaunchedEffect(Unit) {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
listState.scrollToItem(index = 0)
}
}
}
WatchScrollToTop(viewModel, listState)
LazyNotificationList(listState, state, routeForLastRead, accountViewModel, nav)
RenderCardFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
@Composable
private fun LazyNotificationList(
listState: LazyListState,
state: CardFeedState.Loaded,
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
private fun WatchScrollToTop(
viewModel: CardFeedViewModel,
listState: LazyListState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
@Composable
fun RenderCardFeed(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
modifier = Modifier.fillMaxSize(),
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
}
is CardFeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is CardFeedState.Loaded -> {
FeedLoaded(
state = state,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
}
CardFeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
private fun FeedLoaded(
state: CardFeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String
) {
val defaultModifier = remember {
Modifier.fillMaxWidth().defaultMinSize(minHeight = 100.dp)
}
LazyColumn(
modifier = remember { Modifier.fillMaxSize() },
contentPadding = remember {
@@ -142,7 +165,7 @@ private fun LazyNotificationList(
state = listState
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 100.dp)) {
Row(defaultModifier) {
when (item) {
is NoteCard -> NoteCompose(
item.note,
@@ -152,14 +175,6 @@ private fun LazyNotificationList(
routeForLastRead = routeForLastRead
)
is ZapSetCard -> ZapSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
@@ -168,22 +183,6 @@ private fun LazyNotificationList(
routeForLastRead = routeForLastRead
)
is LikeSetCard -> LikeSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BoostSetCard -> BoostSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,

View File

@@ -4,6 +4,7 @@ import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -35,12 +36,35 @@ import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@Stable
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
class NotificationViewModel(val account: Account) : CardFeedViewModel(NotificationFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NotificationViewModel : ViewModel> create(modelClass: Class<NotificationViewModel>): NotificationViewModel {
return NotificationViewModel(account) as NotificationViewModel
}
}
}
@Stable
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
suspend fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
_scrollToTop.emit(_scrollToTop.value + 1)
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
private var lastAccount: Account? = null
private var lastNotes: Set<Note>? = null
@@ -150,7 +174,10 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList()
val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
singleList.chunked(50).map { chunk ->
MultiSetCard(
baseNote,
@@ -234,19 +261,32 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
@OptIn(ExperimentalTime::class)
private val bundler = BundledUpdate(1000, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
private val bundler = BundledUpdate(1000, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
@OptIn(ExperimentalTime::class)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing)
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
}
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
sendToTop()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
}
@OptIn(ExperimentalTime::class)
@@ -290,6 +330,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
fun <T> equalImmutableLists(list1: ImmutableList<T>, list2: ImmutableList<T>): Boolean {
if (list1 === list2) return true
if (list1.size != list2.size) return false
for (i in 0 until list1.size) {
if (list1[i] !== list2[i]) {

View File

@@ -92,6 +92,7 @@ private fun FeedLoaded(
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val notificationCacheState = NotificationCache.live.observeAsState()
val notificationCache = notificationCacheState.value ?: return
@@ -99,9 +100,9 @@ private fun FeedLoaded(
if (markAsRead.value) {
for (note in state.feed.value) {
note.event?.let {
val channel = note.channel()
val route = if (channel != null) {
"Channel/${channel.idHex}"
val channelHex = note.channelHex()
val route = if (channelHex != null) {
"Channel/$channelHex"
} else {
val replyAuthorBase =
(note.event as? PrivateDmEvent)

View File

@@ -2,10 +2,11 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import com.vitorpamplona.amethyst.model.Note
import kotlinx.collections.immutable.ImmutableList
sealed class FeedState {
object Loading : FeedState()
class Loaded(val feed: MutableState<List<Note>>) : FeedState()
class Loaded(val feed: MutableState<ImmutableList<Note>>) : FeedState()
object Empty : FeedState()
class FeedError(val errorMessage: String) : FeedState()
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
@@ -32,20 +33,18 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FeedView(
fun RefresheableView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String?,
scrollStateKey: String? = null,
scrollToTop: Boolean = false,
enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
@@ -58,42 +57,7 @@ fun FeedView(
Box(modifier) {
Column {
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
refresh()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
refresh()
}
}
is FeedState.Loaded -> {
if (refreshing) {
refreshing = false
}
FeedLoaded(
state,
routeForLastRead,
accountViewModel,
nav,
scrollStateKey,
scrollToTop
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
}
if (enablePullRefresh) {
@@ -103,13 +67,12 @@ fun FeedView(
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
routeForLastRead: String?,
private fun SaveableFeedState(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
scrollStateKey: String?,
scrollToTop: Boolean = false
routeForLastRead: String?,
scrollStateKey: String? = null
) {
val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
@@ -117,14 +80,79 @@ private fun FeedLoaded(
rememberLazyListState()
}
if (scrollToTop) {
LaunchedEffect(Unit) {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
listState.scrollToItem(index = 0)
WatchScrollToTop(viewModel, listState)
RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
@Composable
private fun RenderFeed(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String?
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is FeedState.Loaded -> {
FeedLoaded(
state,
listState,
routeForLastRead,
accountViewModel,
nav
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
private fun WatchScrollToTop(
viewModel: FeedViewModel,
listState: LazyListState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
listState: LazyListState,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val baseModifier = remember {
Modifier
}

View File

@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -25,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -35,28 +40,92 @@ import kotlinx.coroutines.launch
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrGlobalFeedViewModel(val account: Account) : FeedViewModel(GlobalFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrGlobalFeedViewModel : ViewModel> create(modelClass: Class<NostrGlobalFeedViewModel>): NostrGlobalFeedViewModel {
return NostrGlobalFeedViewModel(account) as NostrGlobalFeedViewModel
}
}
}
class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrVideoFeedViewModel : ViewModel> create(modelClass: Class<NostrVideoFeedViewModel>): NostrVideoFeedViewModel {
return NostrVideoFeedViewModel(account) as NostrVideoFeedViewModel
}
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
return NostrThreadFeedViewModel(noteId) as NostrThreadFeedViewModel
}
}
}
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
class NostrChatroomListKnownFeedViewModel : FeedViewModel(ChatroomListKnownFeedFilter)
class NostrChatroomListNewFeedViewModel : FeedViewModel(ChatroomListNewFeedFilter)
class NostrHomeFeedViewModel : FeedViewModel(HomeNewThreadFeedFilter)
class NostrHomeRepliesFeedViewModel : FeedViewModel(HomeConversationsFeedFilter)
class NostrChatroomListKnownFeedViewModel(val account: Account) : FeedViewModel(ChatroomListKnownFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatroomListKnownFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListKnownFeedViewModel>): NostrChatroomListKnownFeedViewModel {
return NostrChatroomListKnownFeedViewModel(account) as NostrChatroomListKnownFeedViewModel
}
}
}
class NostrChatroomListNewFeedViewModel(val account: Account) : FeedViewModel(ChatroomListNewFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatroomListNewFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListNewFeedViewModel>): NostrChatroomListNewFeedViewModel {
return NostrChatroomListNewFeedViewModel(account) as NostrChatroomListNewFeedViewModel
}
}
}
@Stable
class NostrHomeFeedViewModel(val account: Account) : FeedViewModel(HomeNewThreadFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHomeFeedViewModel : ViewModel> create(modelClass: Class<NostrHomeFeedViewModel>): NostrHomeFeedViewModel {
return NostrHomeFeedViewModel(account) as NostrHomeFeedViewModel
}
}
}
@Stable
class NostrHomeRepliesFeedViewModel(val account: Account) : FeedViewModel(HomeConversationsFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHomeRepliesFeedViewModel : ViewModel> create(modelClass: Class<NostrHomeRepliesFeedViewModel>): NostrHomeRepliesFeedViewModel {
return NostrHomeRepliesFeedViewModel(account) as NostrHomeRepliesFeedViewModel
}
}
}
class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter)
class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter)
@Stable
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun newListFromDataSource(): List<Note> {
return localFilter.loadTop()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
suspend fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
_scrollToTop.emit(_scrollToTop.value + 1)
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
fun newListFromDataSource(): ImmutableList<Note> {
return localFilter.loadTop().toImmutableList()
}
private fun refresh() {
@@ -71,8 +140,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) {
// Using size as a proxy for has changed.
if (notes != oldNotesState.feed.value) {
if (!equalImmutableLists(notes, oldNotesState.feed.value)) {
updateFeed(notes)
}
} else {
@@ -80,7 +148,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
private fun updateFeed(notes: List<Note>) {
private fun updateFeed(notes: ImmutableList<Note>) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
scope.launch {
val currentState = _feedContent.value
@@ -97,10 +165,20 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
fun refreshFromOldState(newItems: Set<Note>) {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
if (newList !== oldNotesState.feed.value) {
updateFeed(newList)
if (localFilter is AdditiveFeedFilter) {
if (oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet()).toImmutableList()
if (!equalImmutableLists(newList, oldNotesState.feed.value)) {
updateFeed(newList)
}
} else if (oldNotesState is FeedState.Empty) {
val newList = localFilter.updateListWith(emptyList(), newItems.toSet()).toImmutableList()
if (newList.isNotEmpty()) {
updateFeed(newList)
}
} else {
// Refresh Everything
refreshSuspended()
}
} else {
// Refresh Everything
@@ -108,15 +186,24 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing)
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
}
fun invalidateInsertData(newItems: Set<Note>) {
@@ -125,12 +212,14 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
var collectorJob: Job? = null
private var collectorJob: Job? = null
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
if (localFilter is AdditiveFeedFilter &&
(_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty)
) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything

View File

@@ -58,14 +58,14 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate()
bundler.invalidate() {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
var collectorJob: Job? = null

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