diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index fabffc54e..ea996a3c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -567,16 +567,15 @@ class Account( LocalCache.consume(signedEvent, null) } - fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) { + fun sendPrivateMessage(message: String, toUser: User, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null, wantsToMarkAsSensitive: Boolean) { if (!isWriteable()) return - val user = LocalCache.users[toUser] ?: return val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } val mentionsHex = mentions?.map { it.pubkeyHex } val signedEvent = PrivateDmEvent.create( - recipientPubKey = user.pubkey(), - publishedRecipientPubKey = user.pubkey(), + recipientPubKey = toUser.pubkey(), + publishedRecipientPubKey = toUser.pubkey(), msg = message, replyTos = repliesToHex, mentions = mentionsHex, @@ -1135,7 +1134,9 @@ class Account( // saves contact list for the next time. userProfile().live().follows.observeForever { - updateContactListTo(userProfile().latestContactList) + GlobalScope.launch(Dispatchers.IO) { + updateContactListTo(userProfile().latestContactList) + } } // imports transient blocks due to spam. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index a7e45495c..c24f4c08f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -3,7 +3,9 @@ package com.vitorpamplona.amethyst.model import android.util.Log import android.util.LruCache import androidx.lifecycle.LiveData +import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledUpdate import kotlinx.coroutines.Dispatchers @@ -16,6 +18,8 @@ class AntiSpamFilter { @Synchronized fun isSpam(event: Event, relay: Relay?): Boolean { + checkNotInMainThread() + val idHex = event.id // if short message, ok @@ -28,6 +32,7 @@ class AntiSpamFilter { val hash = (event.content + event.tags.flatten().joinToString(",")).hashCode() if ((recentMessages[hash] != null && recentMessages[hash] != idHex) || spamMessages[hash] != null) { + Log.w("Potential SPAM Message for sharing", "${Nip19.createNEvent(event.id, event.pubKey, event.kind, null)}") Log.w("Potential SPAM Message", "${event.id} ${recentMessages[hash]} ${spamMessages[hash] != null} ${relay?.url} ${event.content.replace("\n", " | ")}") // Log down offenders diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 40d011339..ad73be435 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -32,6 +32,8 @@ object LocalCache { ConcurrentHashMap Unit>>(10) fun checkGetOrCreateUser(key: String): User? { + checkNotInMainThread() + if (isValidHexNpub(key)) { return getOrCreateUser(key) } @@ -40,6 +42,8 @@ object LocalCache { @Synchronized fun getOrCreateUser(key: HexKey): User { + // checkNotInMainThread() + return users[key] ?: run { val answer = User(key) users.put(key, answer) @@ -48,6 +52,8 @@ object LocalCache { } fun checkGetOrCreateNote(key: String): Note? { + checkNotInMainThread() + if (ATag.isATag(key)) { return checkGetOrCreateAddressableNote(key) } @@ -59,6 +65,8 @@ object LocalCache { @Synchronized fun getOrCreateNote(idHex: String): Note { + checkNotInMainThread() + return notes[idHex] ?: run { val answer = Note(idHex) notes.put(idHex, answer) @@ -67,6 +75,8 @@ object LocalCache { } fun checkGetOrCreateChannel(key: String): Channel? { + checkNotInMainThread() + if (isValidHexNpub(key)) { return getOrCreateChannel(key) } @@ -85,6 +95,8 @@ object LocalCache { @Synchronized fun getOrCreateChannel(key: String): Channel { + checkNotInMainThread() + return channels[key] ?: run { val answer = Channel(key) channels.put(key, answer) @@ -108,6 +120,8 @@ object LocalCache { @Synchronized fun getOrCreateAddressableNote(key: ATag): AddressableNote { + checkNotInMainThread() + // we can't use naddr here because naddr might include relay info and // the preferred relay should not be part of the index. return addressables[key.toTag()] ?: run { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 5bce8ec62..9609954ea 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback import com.vitorpamplona.amethyst.service.previews.UrlInfoItem @@ -11,6 +12,8 @@ object UrlCachedPreviewer { private set fun previewInfo(url: String, callback: IUrlPreviewCallback? = null) { + checkNotInMainThread() + cache[url]?.let { callback?.onComplete(it) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 34b86a3bf..0d4264abb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource +import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.model.BookmarkListEvent import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.LnZapEvent @@ -185,6 +186,8 @@ class User(val pubkeyHex: String) { @Synchronized private fun getOrCreatePrivateChatroom(user: User): Chatroom { + checkNotInMainThread() + return privateChatrooms[user] ?: run { val privateChatroom = Chatroom(setOf()) privateChatrooms = privateChatrooms + Pair(user, privateChatroom) @@ -194,6 +197,8 @@ class User(val pubkeyHex: String) { @Synchronized fun addMessage(user: User, msg: Note) { + checkNotInMainThread() + val privateChatroom = getOrCreatePrivateChatroom(user) if (msg !in privateChatroom.roomMessages) { privateChatroom.roomMessages = privateChatroom.roomMessages + msg @@ -203,6 +208,8 @@ class User(val pubkeyHex: String) { @Synchronized fun removeMessage(user: User, msg: Note) { + checkNotInMainThread() + val privateChatroom = getOrCreatePrivateChatroom(user) if (msg in privateChatroom.roomMessages) { privateChatroom.roomMessages = privateChatroom.roomMessages - msg diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt index a1903785b..36542b8d7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/MainThreadChecker.kt @@ -1,9 +1,10 @@ package com.vitorpamplona.amethyst.service import android.os.Looper +import com.vitorpamplona.amethyst.BuildConfig fun checkNotInMainThread() { - if (isMainThread()) throw OnMainThreadException("It should not be in the MainThread") + if (BuildConfig.DEBUG && isMainThread()) throw OnMainThreadException("It should not be in the MainThread") } fun isMainThread() = Looper.myLooper() == Looper.getMainLooper() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt index 554e3ba92..5bc2e95c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChannelDataSource.kt @@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter @@ -12,9 +11,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") { var account: Account? = null var channel: Channel? = null - fun loadMessagesBetween(account: Account, channelId: String) { + fun loadMessagesBetween(account: Account, channel: Channel) { this.account = account - channel = LocalCache.getOrCreateChannel(channelId) + this.channel = channel resetFilters() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt index b94abf0bb..48b60c33d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomDataSource.kt @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.relays.FeedType @@ -12,9 +11,9 @@ object NostrChatroomDataSource : NostrDataSource("ChatroomFeed") { lateinit var account: Account var withUser: User? = null - fun loadMessagesBetween(accountIn: Account, userId: String) { + fun loadMessagesBetween(accountIn: Account, user: User) { account = accountIn - withUser = LocalCache.users[userId] + withUser = user resetFilters() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 3c8e460f0..9b65dd7cd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -123,6 +123,8 @@ abstract class NostrDataSource(val debugName: String) { } fun resetFiltersSuspend() { + checkNotInMainThread() + // saves the channels that are currently active val activeSubscriptions = subscriptions.values.filter { it.typedFilters != null } // saves the current content to only update if it changes diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 03be8b972..368cc5f4e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -13,15 +13,22 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.ui.note.showAmount +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch class EventNotificationConsumer(private val applicationContext: Context) { fun consume(event: Event) { - // adds to database - LocalCache.consume(event, null) + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + // adds to database + LocalCache.consume(event, null) - when (event) { - is PrivateDmEvent -> notify(event) - is LnZapEvent -> notify(event) + when (event) { + is PrivateDmEvent -> notify(event) + is LnZapEvent -> notify(event) + } } } @@ -54,6 +61,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { private fun notify(event: LnZapEvent) { val noteZapEvent = LocalCache.notes[event.id] ?: return + val noteZapRequest = event.zapRequest?.id?.let { LocalCache.checkGetOrCreateNote(it) } val noteZapped = event.zappedPost().firstOrNull()?.let { LocalCache.checkGetOrCreateNote(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 33a02c40e..f300f41f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.relays import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi @@ -32,6 +33,8 @@ object Client : RelayPool.Listener { @Synchronized fun connect(relays: Array) { + checkNotInMainThread() + RelayPool.register(this) RelayPool.unloadRelays() RelayPool.loadRelays(relays.toList()) @@ -54,6 +57,8 @@ object Client : RelayPool.Listener { subscriptionId: String = UUID.randomUUID().toString().substring(0..10), filters: List = listOf() ) { + checkNotInMainThread() + subscriptions = subscriptions + Pair(subscriptionId, filters) RelayPool.sendFilter(subscriptionId) } @@ -62,11 +67,15 @@ object Client : RelayPool.Listener { subscriptionId: String = UUID.randomUUID().toString().substring(0..10), filters: List = listOf() ) { + checkNotInMainThread() + subscriptions = subscriptions + Pair(subscriptionId, filters) RelayPool.sendFilterOnlyIfDisconnected() } fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set? = null, onDone: (() -> Unit)? = null) { + checkNotInMainThread() + if (relay == null) { RelayPool.send(signedEvent) } else { @@ -91,7 +100,7 @@ object Client : RelayPool.Listener { } @OptIn(DelicateCoroutinesApi::class) - fun newSporadicRelay(url: String, feedTypes: Set?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) { + private fun newSporadicRelay(url: String, feedTypes: Set?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) { val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy()) RelayPool.addRelay(relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 41fe2823b..54a337708 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import com.vitorpamplona.amethyst.service.model.RelayAuthEvent @@ -69,6 +70,7 @@ class Relay( @Synchronized fun requestAndWatch() { + checkNotInMainThread() requestAndWatch { // Sends everything. Client.allSubscriptions().forEach { @@ -79,6 +81,7 @@ class Relay( @Synchronized fun requestAndWatch(onConnected: (Relay) -> Unit) { + checkNotInMainThread() if (socket != null) return try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt index fe08507f8..b6b3e947b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt @@ -14,6 +14,7 @@ import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -26,6 +27,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, channel: Channel? = null) { @@ -53,10 +56,14 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan onClose() }) + val scope = rememberCoroutineScope() + PostButton( onPost = { - postViewModel.create() - onClose() + scope.launch(Dispatchers.IO) { + postViewModel.create() + onClose() + } }, postViewModel.channelName.value.text.isNotBlank() ) @@ -100,7 +107,9 @@ fun NewChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, chan OutlinedTextField( label = { Text(text = stringResource(R.string.description)) }, - modifier = Modifier.fillMaxWidth().height(100.dp), + modifier = Modifier + .fillMaxWidth() + .height(100.dp), value = postViewModel.channelDescription.value, onValueChange = { postViewModel.channelDescription.value = it }, placeholder = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 686055eef..8dc648cb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -143,8 +143,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n PostButton( onPost = { - postViewModel.sendPost() - onClose() + scope.launch(Dispatchers.IO) { + postViewModel.sendPost() + onClose() + } }, isActive = postViewModel.canPost() ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index ba690b86f..477843320 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -open class NewPostViewModel : ViewModel() { +open class NewPostViewModel() : ViewModel() { var account: Account? = null var originalNote: Note? = null @@ -132,7 +132,7 @@ open class NewPostViewModel : ViewModel() { } else if (originalNote?.channelHex() != null) { account?.sendChannelMessage(tagger.message, tagger.channelHex!!, tagger.replyTos, tagger.mentions, zapReceiver, wantsToMarkAsSensitive) } else if (originalNote?.event is PrivateDmEvent) { - account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive) + account?.sendPrivateMessage(tagger.message, originalNote!!.author!!, originalNote!!, tagger.mentions, zapReceiver, wantsToMarkAsSensitive) } else { account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver, wantsToMarkAsSensitive) } @@ -226,7 +226,11 @@ open class NewPostViewModel : ViewModel() { userSuggestionsMainMessage = true if (lastWord.startsWith("@") && lastWord.length > 2) { NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed() + } } else { NostrSearchEventOrUserDataSource.clear() userSuggestions = emptyList() @@ -242,7 +246,15 @@ open class NewPostViewModel : ViewModel() { userSuggestionsMainMessage = false if (lastWord.startsWith("@") && lastWord.length > 2) { NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith( + compareBy( + { account?.isFollowing(it) }, + { it.toBestDisplayName() } + ) + ).reversed() + } } else { NostrSearchEventOrUserDataSource.clear() userSuggestions = emptyList() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index b9fd830e5..c46cf3518 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -68,7 +68,9 @@ fun ClickableRoute( DisplayEvent(nip19, nav) } else { Text( - "@${nip19.hex}${nip19.additionalChars} " + remember { + "@${nip19.hex}${nip19.additionalChars} " + } ) } } @@ -93,19 +95,20 @@ private fun DisplayEvent( val note = remember(noteState) { noteState?.note } ?: return val channelHex = remember(noteState) { note.channelHex() } val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } + val addedCharts = remember { "${nip19.additionalChars} " } if (note.event is ChannelCreateEvent) { CreateClickableText( clickablePart = noteIdDisplayNote, - suffix = "${nip19.additionalChars} ", - route = "Channel/${nip19.hex}", + suffix = addedCharts, + route = remember(noteState) { "Channel/${nip19.hex}" }, nav = nav ) } else if (note.event is PrivateDmEvent) { CreateClickableText( clickablePart = noteIdDisplayNote, - suffix = "${nip19.additionalChars} ", - route = "Room/${note.author?.pubkeyHex}", + suffix = addedCharts, + route = remember(noteState) { "Room/${note.author?.pubkeyHex}" }, nav = nav ) } else if (channelHex != null) { @@ -119,16 +122,16 @@ private fun DisplayEvent( CreateClickableText( clickablePart = channelDisplayName, - suffix = "${nip19.additionalChars} ", - route = "Channel/${baseChannel.idHex}", + suffix = addedCharts, + route = remember(noteState) { "Channel/${baseChannel.idHex}" }, nav = nav ) } } else { CreateClickableText( clickablePart = noteIdDisplayNote, - suffix = "${nip19.additionalChars} ", - route = "Event/${nip19.hex}", + suffix = addedCharts, + route = remember(noteState) { "Event/${nip19.hex}" }, nav = nav ) } @@ -136,7 +139,9 @@ private fun DisplayEvent( if (noteBase == null) { Text( - "@${nip19.hex}${nip19.additionalChars} " + remember { + "@${nip19.hex}${nip19.additionalChars} " + } ) } } @@ -157,21 +162,22 @@ private fun DisplayNote( noteBase?.let { val noteState by it.live().metadata.observeAsState() val note = remember(noteState) { noteState?.note } ?: return - val channelHex = note.channelHex() + val channelHex = remember(noteState) { note.channelHex() } val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" } + val addedCharts = remember { "${nip19.additionalChars} " } if (note.event is ChannelCreateEvent) { CreateClickableText( clickablePart = noteIdDisplayNote, - suffix = "${nip19.additionalChars} ", - route = "Channel/${nip19.hex}", + suffix = addedCharts, + route = remember(noteState) { "Channel/${nip19.hex}" }, nav = nav ) } else if (note.event is PrivateDmEvent) { CreateClickableText( clickablePart = noteIdDisplayNote, - suffix = "${nip19.additionalChars} ", - route = "Room/${note.author?.pubkeyHex}", + suffix = addedCharts, + route = remember(noteState) { "Room/${note.author?.pubkeyHex}" }, nav = nav ) } else if (channelHex != null) { @@ -185,16 +191,16 @@ private fun DisplayNote( CreateClickableText( clickablePart = channelDisplayName, - suffix = "${nip19.additionalChars} ", - route = "Channel/${baseChannel.idHex}", + suffix = addedCharts, + route = remember(noteState) { "Channel/${baseChannel.idHex}" }, nav = nav ) } } else { CreateClickableText( clickablePart = noteIdDisplayNote, - suffix = "${nip19.additionalChars} ", - route = "Note/${nip19.hex}", + suffix = addedCharts, + route = remember(noteState) { "Note/${nip19.hex}" }, nav = nav ) } @@ -202,7 +208,9 @@ private fun DisplayNote( if (noteBase == null) { Text( - "@${nip19.hex}${nip19.additionalChars} " + remember { + "@${nip19.hex}${nip19.additionalChars} " + } ) } } @@ -222,19 +230,24 @@ private fun DisplayAddress( noteBase?.let { val noteState by it.live().metadata.observeAsState() - val note = remember(noteState) { noteState?.note } ?: return + + val route = remember(noteState) { "Note/${nip19.hex}" } + val displayName = remember(noteState) { "@${noteState?.note?.idDisplayNote()}" } + val addedCharts = remember { "${nip19.additionalChars} " } CreateClickableText( - clickablePart = "@${note.idDisplayNote()}", - suffix = "${nip19.additionalChars} ", - route = "Note/${nip19.hex}", + clickablePart = displayName, + suffix = addedCharts, + route = route, nav = nav ) } if (noteBase == null) { Text( - "@${nip19.hex}${nip19.additionalChars} " + remember { + "@${nip19.hex}${nip19.additionalChars} " + } ) } } @@ -257,11 +270,14 @@ private fun DisplayUser( val route = remember(userState) { "User/${it.pubkeyHex}" } val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + val addedCharts = remember { + "${nip19.additionalChars} " + } if (userDisplayName != null) { CreateClickableTextWithEmoji( clickablePart = userDisplayName, - suffix = "${nip19.additionalChars} ", + suffix = addedCharts, tags = userTags, route = route, nav = nav @@ -271,7 +287,9 @@ private fun DisplayUser( if (userBase == null) { Text( - "@${nip19.hex}${nip19.additionalChars} " + remember { + "@${nip19.hex}${nip19.additionalChars} " + } ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index d6a082a08..e0283e3f6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString @@ -11,10 +12,16 @@ import androidx.compose.ui.text.AnnotatedString fun ClickableUrl(urlText: String, url: String) { val uri = LocalUriHandler.current - val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" + val doubleCheckedUrl = remember(url) { + if (url.contains("://")) url else "https://$url" + } + + val text = remember(urlText) { + AnnotatedString(urlText) + } ClickableText( - text = AnnotatedString(urlText), + text = text, onClick = { runCatching { uri.openUri(doubleCheckedUrl) } }, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt index 8b8ea0ff4..e18e99b17 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUserTag.kt @@ -6,6 +6,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.ui.text.AnnotatedString import com.vitorpamplona.amethyst.model.User @@ -14,10 +15,19 @@ fun ClickableUserTag( user: User, nav: (String) -> Unit ) { + val route = remember { + "User/${user.pubkeyHex}" + } + val innerUserState by user.live().metadata.observeAsState() + + val userName = remember(innerUserState) { + AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}") + } + ClickableText( - text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"), - onClick = { nav("User/${innerUserState?.user?.pubkeyHex}") }, + text = userName, + onClick = { nav(route) }, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 51a335c3e..3b32ce8c8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -13,14 +13,14 @@ import androidx.compose.ui.text.style.TextDirection import androidx.core.content.ContextCompat import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch @Composable fun MayBeWithdrawal(lnurlWord: String) { var lnWithdrawal by remember { mutableStateOf(null) } LaunchedEffect(key1 = lnurlWord) { - withContext(Dispatchers.IO) { + launch(Dispatchers.IO) { lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord) } } @@ -38,11 +38,19 @@ fun MayBeWithdrawal(lnurlWord: String) { fun ClickableWithdrawal(withdrawalString: String) { val context = LocalContext.current + val uri = remember(withdrawalString) { + Uri.parse("lightning:$withdrawalString") + } + + val withdraw = remember(withdrawalString) { + AnnotatedString("$withdrawalString ") + } + ClickableText( - text = AnnotatedString("$withdrawalString "), + text = withdraw, onClick = { runCatching { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString")) + val intent = Intent(Intent.ACTION_VIEW, uri) ContextCompat.startActivity(context, intent, null) } }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index e4fa8eb88..695e67b35 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.collections.immutable.ImmutableList const val SHORT_TEXT_LENGTH = 350 @@ -34,8 +35,8 @@ const val SHORT_TEXT_LENGTH = 350 fun ExpandableRichTextViewer( content: String, canPreview: Boolean, - modifier: Modifier = Modifier, - tags: List>, + modifier: Modifier, + tags: ImmutableList>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -87,18 +88,26 @@ fun ExpandableRichTextViewer( ) ) ) { - Button( - modifier = Modifier.padding(top = 10.dp), - onClick = { showFullText = !showFullText }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f).compositeOver(MaterialTheme.colors.background) - ), - contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) - ) { - Text(text = stringResource(R.string.show_more), color = Color.White) + ShowMoreButton() { + showFullText = !showFullText } } } } } + +@Composable +private fun ShowMoreButton(onClick: () -> Unit) { + Button( + modifier = Modifier.padding(top = 10.dp), + onClick = onClick, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + .compositeOver(MaterialTheme.colors.background) + ), + contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp) + ) { + Text(text = stringResource(R.string.show_more), color = Color.White) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index fe1ea9517..4a2cdd2c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -97,8 +97,8 @@ fun isMarkdown(content: String): Boolean { fun RichTextViewer( content: String, canPreview: Boolean, - modifier: Modifier = Modifier, - tags: List>, + modifier: Modifier, + tags: ImmutableList>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -126,7 +126,7 @@ data class RichTextViewerState( @Composable private fun RenderRegular( content: String, - tags: List>, + tags: ImmutableList>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, @@ -217,7 +217,7 @@ private fun RenderWord( backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit, - tags: List> + tags: ImmutableList> ) { val type = remember(word) { if (state.imagesForPager[word] != null) { @@ -267,7 +267,7 @@ private fun RenderWordWithoutPreview( type: WordType, word: String, state: RichTextViewerState, - tags: List>, + tags: ImmutableList>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -300,7 +300,7 @@ private fun RenderWordWithPreview( type: WordType, word: String, state: RichTextViewerState, - tags: List>, + tags: ImmutableList>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -382,7 +382,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) { } @Composable -private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: List>?, nav: (String) -> Unit) { +private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: ImmutableList>?, nav: (String) -> Unit) { val myMarkDownStyle = richTextDefaults.copy( codeBlockStyle = richTextDefaults.codeBlockStyle?.copy( textStyle = TextStyle( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt index 21ea7ef48..8908b1f48 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChannelFeedFilter.kt @@ -1,23 +1,12 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Note -object ChannelFeedFilter : AdditiveFeedFilter() { - lateinit var account: Account - var channelId: String? = null - - fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) { - this.account = accountLoggedIn - this.channelId = channelId - } - +class ChannelFeedFilter(val channel: Channel, val account: Account) : AdditiveFeedFilter() { // returns the last Note of each user. override fun feed(): List { - val processingChannel = channelId ?: return emptyList() - val channel = LocalCache.getOrCreateChannel(processingChannel) - return channel.notes .values .filter { account.isAcceptable(it) } @@ -26,9 +15,6 @@ object ChannelFeedFilter : AdditiveFeedFilter() { } override fun applyFilter(collection: Set): Set { - val processingChannel = channelId ?: return emptySet() - val channel = LocalCache.getOrCreateChannel(processingChannel) - return collection .filter { it.idHex in channel.notes.keys && account.isAcceptable(it) } .toSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt index 2aa617fe0..600f4173c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomFeedFilter.kt @@ -1,51 +1,29 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User -object ChatroomFeedFilter : AdditiveFeedFilter() { - var account: Account? = null - var withUser: String? = null - - fun loadMessagesBetween(accountIn: Account, userId: String) { - account = accountIn - withUser = userId - } - +class ChatroomFeedFilter(val withUser: User, val account: Account) : AdditiveFeedFilter() { // returns the last Note of each user. override fun feed(): List { - val processingUser = withUser ?: return emptyList() - - val myAccount = account - val myUser = LocalCache.checkGetOrCreateUser(processingUser) - - if (myAccount == null || myUser == null) return emptyList() - - val messages = myAccount + val messages = account .userProfile() - .privateChatrooms[myUser] ?: return emptyList() + .privateChatrooms[withUser] ?: return emptyList() return messages.roomMessages - .filter { myAccount.isAcceptable(it) } + .filter { account.isAcceptable(it) } .sortedWith(compareBy({ it.createdAt() }, { it.idHex })) .reversed() } override fun applyFilter(collection: Set): Set { - val processingUser = withUser ?: return emptySet() - - val myAccount = account - val myUser = LocalCache.checkGetOrCreateUser(processingUser) - - if (myAccount == null || myUser == null) return emptySet() - - val messages = myAccount + val messages = account .userProfile() - .privateChatrooms[myUser] ?: return emptySet() + .privateChatrooms[withUser] ?: return emptySet() return collection - .filter { it in messages.roomMessages && account?.isAcceptable(it) == true } + .filter { it in messages.roomMessages && account.isAcceptable(it) == true } .toSet() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 4e1916fb6..5c6f09ed2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -1,12 +1,15 @@ package com.vitorpamplona.amethyst.ui.dal import android.util.Log +import com.vitorpamplona.amethyst.service.checkNotInMainThread import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue abstract class FeedFilter { @OptIn(ExperimentalTime::class) fun loadTop(): List { + checkNotInMainThread() + val (feed, elapsed) = measureTimedValue { feed() } @@ -24,6 +27,8 @@ abstract class AdditiveFeedFilter : FeedFilter() { @OptIn(ExperimentalTime::class) open fun updateListWith(oldList: List, newItems: Set): List { + checkNotInMainThread() + val (feed, elapsed) = measureTimedValue { val newItemsToBeAdded = applyFilter(newItems) if (newItemsToBeAdded.isNotEmpty()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index a37e9f78b..6e8b03049 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource +import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.model.PeopleListEvent import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.RelayPool @@ -347,6 +348,8 @@ class FollowListViewModel(val account: Account) : ViewModel() { } private suspend fun refreshFollows() { + checkNotInMainThread() + val newFollowLists = LocalCache.addressables.mapNotNull { val event = (it.value.event as? PeopleListEvent) // Has to have an list diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index 3663c9084..18be1d823 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -23,7 +23,6 @@ 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 @@ -42,8 +41,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent @@ -62,11 +63,6 @@ fun ChatroomCompose( val noteState by baseNote.live().metadata.observeAsState() val note = noteState?.note - val notificationCacheState = NotificationCache.live.observeAsState() - val notificationCache = notificationCacheState.value ?: return - - val scope = rememberCoroutineScope() - val channelHex by remember(noteState) { derivedStateOf { noteState?.note?.channelHex() @@ -77,125 +73,168 @@ fun ChatroomCompose( BlankNote(Modifier) } else if (channelHex != null) { LoadChannel(baseChannelHex = channelHex!!) { channel -> - val authorState by note.author!!.live().metadata.observeAsState() - val authorName = remember(authorState) { - authorState?.user?.toBestDisplayName() - } - - val chanHex = remember { channel.idHex } - - 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() - } - - var hasNewMessages by remember { mutableStateOf(false) } - - LaunchedEffect(key1 = notificationCache, key2 = note) { - scope.launch(Dispatchers.IO) { - note.createdAt()?.let { timestamp -> - hasNewMessages = - timestamp > notificationCache.cache.load("Channel/$chanHex") - } - } - } - - ChannelName( - channelIdHex = chanHex, - channelPicture = channelPicture, - channelTitle = { modifier -> - Text( - text = buildAnnotatedString { - withStyle( - SpanStyle( - fontWeight = FontWeight.Bold - ) - ) { - append(channelName) - } - - withStyle( - SpanStyle( - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontWeight = FontWeight.Normal - ) - ) { - append(" ${stringResource(id = R.string.public_chat)}") - } - }, - fontWeight = FontWeight.Bold, - modifier = modifier, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - }, - channelLastTime = note.createdAt(), - channelLastContent = "$authorName: $description", - hasNewMessages = hasNewMessages, - onClick = { nav("Channel/$chanHex") } - ) + ChannelRoomCompose(note, channel, nav) } } else { - val replyAuthorBase = - (note.event as? PrivateDmEvent) - ?.verifiedRecipientPubKey() - ?.let { LocalCache.getOrCreateUser(it) } + val userRoomHex = remember(noteState, accountViewModel) { + (note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.userProfile().pubkeyHex) + } ?: return - var userToComposeOn = note.author!! - - if (replyAuthorBase != null) { - if (note.author == accountViewModel.userProfile()) { - userToComposeOn = replyAuthorBase - } + LoadUser(userRoomHex) { user -> + UserRoomCompose(note, user, accountViewModel, nav) } + } +} - val noteEvent = note.event +@Composable +private fun ChannelRoomCompose( + note: Note, + channel: Channel, + nav: (String) -> Unit +) { + val authorState by note.author!!.live().metadata.observeAsState() + val authorName = remember(authorState) { + authorState?.user?.toBestDisplayName() + } - userToComposeOn.let { user -> - var hasNewMessages by remember { mutableStateOf(false) } + val chanHex = remember { channel.idHex } - LaunchedEffect(key1 = notificationCache, key2 = note) { - scope.launch(Dispatchers.IO) { - noteEvent?.let { - hasNewMessages = it.createdAt() > notificationCache.cache.load( - "Room/${userToComposeOn.pubkeyHex}" - ) - } + 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 route = remember(note) { + "Channel/$chanHex" + } + + 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() + } + + var hasNewMessages by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = note) { + launch(Dispatchers.IO) { + note.createdAt()?.let { timestamp -> + val lastTime = NotificationCache.load(route) + val newHasNewMessages = timestamp > lastTime + if (hasNewMessages != newHasNewMessages) { + hasNewMessages = newHasNewMessages } } - - ChannelName( - channelPicture = { - UserPicture( - userToComposeOn, - accountViewModel = accountViewModel, - size = 55.dp - ) - }, - channelTitle = { UsernameDisplay(userToComposeOn, it) }, - channelLastTime = note.createdAt(), - channelLastContent = accountViewModel.decrypt(note), - hasNewMessages = hasNewMessages, - onClick = { nav("Room/${user.pubkeyHex}") } - ) } } + + ChannelName( + channelIdHex = chanHex, + channelPicture = channelPicture, + channelTitle = { modifier -> + Text( + text = buildAnnotatedString { + withStyle( + SpanStyle( + fontWeight = FontWeight.Bold + ) + ) { + append(channelName) + } + + withStyle( + SpanStyle( + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontWeight = FontWeight.Normal + ) + ) { + append(" ${stringResource(id = R.string.public_chat)}") + } + }, + fontWeight = FontWeight.Bold, + modifier = modifier, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + }, + channelLastTime = remember(note) { note.createdAt() }, + channelLastContent = remember(note) { "$authorName: $description" }, + hasNewMessages = hasNewMessages, + onClick = { nav(route) } + ) +} + +@Composable +private fun UserRoomCompose( + note: Note, + user: User, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val noteEvent = note.event + + var hasNewMessages by remember { mutableStateOf(false) } + + val route = remember(user) { + "Room/${user.pubkeyHex}" + } + + LaunchedEffect(key1 = note) { + launch(Dispatchers.IO) { + noteEvent?.let { + val lastTime = NotificationCache.load(route) + + val newHasNewMessages = it.createdAt() > lastTime + if (hasNewMessages != newHasNewMessages) { + hasNewMessages = newHasNewMessages + } + } + } + } + + ChannelName( + channelPicture = { + UserPicture( + user, + accountViewModel = accountViewModel, + size = 55.dp + ) + }, + channelTitle = { UsernameDisplay(user, it) }, + channelLastTime = remember(note) { note.createdAt() }, + channelLastContent = remember(note) { accountViewModel.decrypt(note) }, + hasNewMessages = hasNewMessages, + onClick = { nav(route) } + ) +} + +@Composable +fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) { + var user by remember(baseUserHex) { + mutableStateOf(null) + } + + LaunchedEffect(key1 = baseUserHex) { + if (user == null) { + launch(Dispatchers.IO) { + user = LocalCache.checkGetOrCreateUser(baseUserHex) + } + } + } + + user?.let { + content(it) + } } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 345be78f2..67a30bea1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -53,7 +53,6 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent @@ -66,6 +65,7 @@ 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.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -265,7 +265,6 @@ fun ChatroomMessageCompose( else -> { RenderRegularTextNote( note, - loggedIn, isAcceptableAndCanPreview.second, backgroundBubbleColor, accountViewModel, @@ -359,13 +358,12 @@ fun ChatTimeAgo(time: Long) { @Composable private fun RenderRegularTextNote( note: Note, - loggedIn: User, canPreview: Boolean, backgroundBubbleColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val tags = remember { note.event?.tags() ?: emptyList() } + val tags = remember(note.event) { note.event?.tags()?.toImmutableList() ?: emptyList>().toImmutableList() } val eventContent = remember { accountViewModel.decrypt(note) } val modifier = remember { Modifier.padding(top = 5.dp) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 7f0a97cbd..e4e556557 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -55,6 +55,7 @@ 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.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -361,7 +362,7 @@ private fun AuthorPictureAndComment( TranslatableRichTextViewer( content = it, canPreview = true, - tags = remember { emptyList() }, + tags = remember { persistentListOf() }, modifier = remember { Modifier.fillMaxWidth() }, backgroundColor = backgroundColor, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 74e4c8ccd..a13a8f2a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -139,6 +139,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Following import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -155,7 +156,7 @@ import kotlin.time.ExperimentalTime fun NoteCompose( baseNote: Note, routeForLastRead: String? = null, - modifier: Modifier = Modifier, + modifier: Modifier = remember { Modifier }, isBoostedNote: Boolean = false, isQuotedNote: Boolean = false, unPackReply: Boolean = true, @@ -214,13 +215,12 @@ fun CheckHiddenNoteCompose( nav: (String) -> Unit ) { val accountState by accountViewModel.accountLiveData.observeAsState() - val account = remember(accountState) { accountState?.account } ?: return val isHidden by remember(accountState) { derivedStateOf { val isSensitive = note.event?.isSensitive() ?: false - account.isHidden(note.author!!) || (isSensitive && account.showSensitiveContent == false) + accountState?.account?.isHidden(note.author!!) == true || (isSensitive && accountState?.account?.showSensitiveContent == false) } } @@ -347,7 +347,7 @@ private fun WatchForReports( } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun NormalNote( baseNote: Note, @@ -366,8 +366,6 @@ fun NormalNote( val noteEvent = remember { baseNote.event } val channelHex = remember { baseNote.channelHex() } - var popupExpanded by remember { mutableStateOf(false) } - if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && channelHex != null) { ChannelHeader(channelHex = channelHex, accountViewModel = accountViewModel, nav = nav) } else if (noteEvent is BadgeDefinitionEvent) { @@ -378,11 +376,12 @@ fun NormalNote( FileStorageHeaderDisplay(baseNote) } else { var isNew by remember { mutableStateOf(false) } + var popupExpanded by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() LaunchedEffect(key1 = routeForLastRead) { - scope.launch(Dispatchers.IO) { + launch(Dispatchers.IO) { routeForLastRead?.let { val lastTime = NotificationCache.load(it) @@ -596,8 +595,8 @@ private fun RenderTextEvent( hasSensitiveContent = hasSensitiveContent, accountViewModel = accountViewModel ) { - val modifier = remember(note.event) { Modifier.fillMaxWidth() } - val tags = remember(note.event) { note.event?.tags() ?: emptyList() } + val modifier = remember(note) { Modifier.fillMaxWidth() } + val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList>().toImmutableList() } TranslatableRichTextViewer( content = eventContent, @@ -646,18 +645,21 @@ private fun RenderPoll( ) } else { val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false } + + val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList>().toImmutableList() } + SensitivityWarning( hasSensitiveContent = hasSensitiveContent, accountViewModel = accountViewModel ) { TranslatableRichTextViewer( - eventContent, + content = eventContent, canPreview = canPreview && !makeItShort, - Modifier.fillMaxWidth(), - noteEvent.tags(), - backgroundColor, - accountViewModel, - nav + modifier = remember { Modifier.fillMaxWidth() }, + tags = tags, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav ) PollNote( @@ -669,7 +671,8 @@ private fun RenderPoll( ) } - DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, nav) + var hashtags = remember { noteEvent.hashtags() } + DisplayUncitedHashtags(hashtags, eventContent, nav) } if (!makeItShort) { @@ -823,10 +826,11 @@ fun RenderAppDefinition( Row( modifier = Modifier.padding(top = 5.dp, bottom = 5.dp) ) { + val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList>().toImmutableList() } TranslatableRichTextViewer( content = it, canPreview = false, - tags = remember { note.event?.tags() ?: emptyList() }, + tags = tags, backgroundColor = MaterialTheme.colors.background, accountViewModel = accountViewModel, nav = nav @@ -889,7 +893,8 @@ private fun RenderPrivateMessage( val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() } val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } - val tags = remember(note.event?.id()) { note.event?.tags() ?: emptyList() } + + val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList>().toImmutableList() } if (eventContent != null) { if (makeItShort && isAuthorTheLoggedUser) { @@ -930,7 +935,7 @@ private fun RenderPrivateMessage( ), canPreview = !makeItShort, Modifier.fillMaxWidth(), - emptyList(), + persistentListOf(), backgroundColor, accountViewModel, nav @@ -1203,7 +1208,7 @@ fun PinListHeader( ) { val noteEvent = baseNote.event as? PinListEvent ?: return - var pins by remember { mutableStateOf(noteEvent.pins()) } + val pins by remember { mutableStateOf(noteEvent.pins()) } var expanded by remember { mutableStateOf(false) @@ -1242,7 +1247,7 @@ fun PinListHeader( TranslatableRichTextViewer( content = pin, canPreview = true, - tags = remember { emptyList() }, + tags = remember { persistentListOf() }, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav @@ -1352,7 +1357,7 @@ private fun RenderReport( content = content, canPreview = true, modifier = remember { Modifier }, - tags = remember { emptyList() }, + tags = remember { persistentListOf() }, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav @@ -1690,7 +1695,7 @@ fun DisplayHighlight( quote, canPreview = canPreview && !makeItShort, Modifier.fillMaxWidth(), - emptyList(), + persistentListOf(), backgroundColor, accountViewModel, nav @@ -2600,6 +2605,8 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } } + val scope = rememberCoroutineScope() + if (!state.isFollowingAuthor) { DropdownMenuItem(onClick = { accountViewModel.follow( @@ -2610,13 +2617,32 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } Divider() } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) { + DropdownMenuItem( + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")) + onDismiss() + } + } + ) { Text(stringResource(R.string.copy_text)) } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")); onDismiss() }) { + DropdownMenuItem( + onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + onDismiss() + } + } + ) { Text(stringResource(R.string.copy_user_pubkey)) } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())); onDismiss() }) { + DropdownMenuItem(onClick = { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())) + onDismiss() + } + }) { Text(stringResource(R.string.copy_note_id)) } DropdownMenuItem(onClick = { @@ -2638,30 +2664,30 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } Divider() if (state.isPrivateBookmarkNote) { - DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePrivateBookmark(note); onDismiss() } }) { Text(stringResource(R.string.remove_from_private_bookmarks)) } } else { - DropdownMenuItem(onClick = { accountViewModel.addPrivateBookmark(note); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPrivateBookmark(note); onDismiss() } }) { Text(stringResource(R.string.add_to_private_bookmarks)) } } if (state.isPublicBookmarkNote) { - DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.removePublicBookmark(note); onDismiss() } }) { Text(stringResource(R.string.remove_from_public_bookmarks)) } } else { - DropdownMenuItem(onClick = { accountViewModel.addPublicBookmark(note); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.addPublicBookmark(note); onDismiss() } }) { Text(stringResource(R.string.add_to_public_bookmarks)) } } Divider() - DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.broadcast(note); onDismiss() } }) { Text(stringResource(R.string.broadcast)) } Divider() if (state.isLoggedUser) { - DropdownMenuItem(onClick = { accountViewModel.delete(note); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.delete(note); onDismiss() } }) { Text(stringResource(R.string.request_deletion)) } } else { @@ -2671,17 +2697,17 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } Divider() if (state.showSensitiveContent == null || state.showSensitiveContent == true) { - DropdownMenuItem(onClick = { accountViewModel.hideSensitiveContent(); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.hideSensitiveContent(); onDismiss() } }) { Text(stringResource(R.string.content_warning_hide_all_sensitive_content)) } } if (state.showSensitiveContent == null || state.showSensitiveContent == false) { - DropdownMenuItem(onClick = { accountViewModel.disableContentWarnings(); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.disableContentWarnings(); onDismiss() } }) { Text(stringResource(R.string.content_warning_show_all_sensitive_content)) } } if (state.showSensitiveContent != null) { - DropdownMenuItem(onClick = { accountViewModel.seeContentWarnings(); onDismiss() }) { + DropdownMenuItem(onClick = { scope.launch(Dispatchers.IO) { accountViewModel.seeContentWarnings(); onDismiss() } }) { Text(stringResource(R.string.content_warning_see_warnings)) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index c7cfdfe2d..eda860107 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.components.SelectTextDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog import com.vitorpamplona.amethyst.ui.theme.WarningColor +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch private fun lightenColor(color: Color, amount: Float): Color { @@ -154,25 +155,31 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni icon = Icons.Default.ContentCopy, label = stringResource(R.string.quick_action_copy_text) ) { - clipboardManager.setText( - AnnotatedString( - accountViewModel.decrypt(note) ?: "" + scope.launch(Dispatchers.IO) { + clipboardManager.setText( + AnnotatedString( + accountViewModel.decrypt(note) ?: "" + ) ) - ) - showToast(R.string.copied_note_text_to_clipboard) - onDismiss() + showToast(R.string.copied_note_text_to_clipboard) + onDismiss() + } } VerticalDivider(primaryLight) NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) { - clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) - showToast(R.string.copied_user_id_to_clipboard) - onDismiss() + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) + showToast(R.string.copied_user_id_to_clipboard) + onDismiss() + } } VerticalDivider(primaryLight) NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) { - clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) - showToast(R.string.copied_note_id_to_clipboard) - onDismiss() + scope.launch(Dispatchers.IO) { + clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) + showToast(R.string.copied_note_id_to_clipboard) + onDismiss() + } } if (!isOwnNote) { @@ -180,8 +187,10 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni NoteQuickActionItem(Icons.Default.Block, stringResource(R.string.quick_action_block)) { if (accountViewModel.hideBlockAlertDialog) { - note.author?.let { accountViewModel.hide(it) } - onDismiss() + scope.launch(Dispatchers.IO) { + note.author?.let { accountViewModel.hide(it) } + onDismiss() + } } else { showBlockAlertDialog = true } @@ -198,21 +207,27 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni if (isOwnNote) { NoteQuickActionItem(Icons.Default.Delete, stringResource(R.string.quick_action_delete)) { if (accountViewModel.hideDeleteRequestDialog) { - accountViewModel.delete(note) - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.delete(note) + onDismiss() + } } else { showDeleteAlertDialog = true } } } else if (isFollowingUser) { NoteQuickActionItem(Icons.Default.PersonRemove, stringResource(R.string.quick_action_unfollow)) { - accountViewModel.unfollow(note.author!!) - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.unfollow(note.author!!) + onDismiss() + } } } else { NoteQuickActionItem(Icons.Default.PersonAdd, stringResource(R.string.quick_action_follow)) { - accountViewModel.follow(note.author!!) - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.follow(note.author!!) + onDismiss() + } } } @@ -221,9 +236,11 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni icon = ImageVector.vectorResource(id = R.drawable.relays), label = stringResource(R.string.broadcast) ) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } } VerticalDivider(primaryLight) NoteQuickActionItem(icon = Icons.Default.Share, label = stringResource(R.string.quick_action_share)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index f33f2ea12..aeeb37fdf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -30,6 +30,8 @@ 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.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.* @@ -109,7 +111,7 @@ private fun OptionNote( backgroundColor: Color, nav: (String) -> Unit ) { - val tags = remember { baseNote.event?.tags() ?: emptyList() } + val tags = remember(baseNote) { baseNote.event?.tags()?.toImmutableList() ?: emptyList>().toImmutableList() } Row( verticalAlignment = Alignment.CenterVertically, @@ -163,7 +165,7 @@ private fun RenderOptionAfterVote( totalRatio: Float, color: Color, canPreview: Boolean, - tags: List>, + tags: ImmutableList>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -230,7 +232,7 @@ private fun RenderOptionAfterVote( private fun RenderOptionBeforeVote( description: String, canPreview: Boolean, - tags: List>, + tags: ImmutableList>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 64c3a48b0..5b0bb6f71 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.note +import android.content.Context import android.widget.Toast import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi @@ -62,6 +63,7 @@ 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 +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.math.BigDecimal @@ -201,7 +203,9 @@ fun BoostReaction( onClick = { if (accountViewModel.isWriteable()) { if (accountViewModel.hasBoosted(baseNote)) { - accountViewModel.deleteBoostsTo(baseNote) + scope.launch(Dispatchers.IO) { + accountViewModel.deleteBoostsTo(baseNote) + } } else { wantsToBoost = true } @@ -293,10 +297,12 @@ fun LikeReaction( modifier = iconButtonModifier, onClick = { if (accountViewModel.isWriteable()) { - if (accountViewModel.hasReactedTo(baseNote)) { - accountViewModel.deleteReactionTo(baseNote) - } else { - accountViewModel.reactTo(baseNote) + scope.launch(Dispatchers.IO) { + if (accountViewModel.hasReactedTo(baseNote)) { + accountViewModel.deleteReactionTo(baseNote) + } else { + accountViewModel.reactTo(baseNote) + } } } else { scope.launch { @@ -386,9 +392,6 @@ fun ZapReaction( iconSize: Dp = 20.dp, animationSize: Dp = 14.dp ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = remember(accountState) { accountState?.account } ?: return - var wantsToZap by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } @@ -407,53 +410,18 @@ fun ZapReaction( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = false, radius = 24.dp), onClick = { - if (account.zapAmountChoices.isEmpty()) { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.no_zap_amount_setup_long_press_to_change), - Toast.LENGTH_SHORT - ) - .show() + zapClick( + baseNote, + accountViewModel, + scope, + context, + onZappingProgress = { progress: Float -> + zappingProgress = progress + }, + onMultipleChoices = { + wantsToZap = true } - } else if (!accountViewModel.isWriteable()) { - scope.launch { - Toast - .makeText( - context, - context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), - Toast.LENGTH_SHORT - ) - .show() - } - } else if (account.zapAmountChoices.size == 1) { - scope.launch(Dispatchers.IO) { - accountViewModel.zap( - baseNote, - account.zapAmountChoices.first() * 1000, - null, - "", - context, - onError = { - scope.launch { - zappingProgress = 0f - Toast - .makeText(context, it, Toast.LENGTH_SHORT) - .show() - } - }, - onProgress = { - scope.launch(Dispatchers.Main) { - zappingProgress = it - } - }, - zapType = account.defaultZapType - ) - } - } else if (account.zapAmountChoices.size > 1) { - wantsToZap = true - } + ) }, onLongClick = { wantsToChangeZapAmount = true @@ -494,7 +462,7 @@ fun ZapReaction( } if (wantsToSetCustomZap) { - ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote) + ZapCustomDialog({ wantsToSetCustomZap = false }, accountViewModel, baseNote) } if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { @@ -507,7 +475,7 @@ fun ZapReaction( CircularProgressIndicator( progress = animatedProgress, - modifier = Modifier.size(animationSize), + modifier = remember { Modifier.size(animationSize) }, strokeWidth = 2.dp ) } else { @@ -523,6 +491,63 @@ fun ZapReaction( ZapAmountText(baseNote, grayTint, accountViewModel) } +private fun zapClick( + baseNote: Note, + accountViewModel: AccountViewModel, + scope: CoroutineScope, + context: Context, + onZappingProgress: (Float) -> Unit, + onMultipleChoices: () -> Unit +) { + if (accountViewModel.account.zapAmountChoices.isEmpty()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.no_zap_amount_setup_long_press_to_change), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (!accountViewModel.isWriteable()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (accountViewModel.account.zapAmountChoices.size == 1) { + scope.launch(Dispatchers.IO) { + accountViewModel.zap( + baseNote, + accountViewModel.account.zapAmountChoices.first() * 1000, + null, + "", + context, + onError = { + scope.launch { + onZappingProgress(0f) + Toast + .makeText(context, it, Toast.LENGTH_SHORT) + .show() + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { + onZappingProgress(it) + } + }, + zapType = accountViewModel.account.defaultZapType + ) + } + } else if (accountViewModel.account.zapAmountChoices.size > 1) { + onMultipleChoices() + } +} + @Composable private fun ZapIcon( baseNote: Note, @@ -593,7 +618,7 @@ private fun ZapAmountText( } @Composable -public fun ViewCountReaction( +fun ViewCountReaction( idHex: String, grayTint: Color, iconSize: Dp = 20.dp, @@ -653,12 +678,15 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo offset = IntOffset(0, -50), onDismissRequest = { onDismiss() } ) { - FlowRow() { + FlowRow { + val scope = rememberCoroutineScope() Button( modifier = Modifier.padding(horizontal = 3.dp), onClick = { - accountViewModel.boost(baseNote) - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.boost(baseNote) + onDismiss() + } }, shape = RoundedCornerShape(20.dp), colors = ButtonDefaults diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 5518a3aa6..2f069628c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -56,13 +56,13 @@ class ZapOptionstViewModel : ViewModel() { } @Composable -fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) { +fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, baseNote: Note) { val context = LocalContext.current val scope = rememberCoroutineScope() val postViewModel: ZapOptionstViewModel = viewModel() - LaunchedEffect(account) { - postViewModel.load(account) + LaunchedEffect(accountViewModel) { + postViewModel.load(accountViewModel.account) } var zappingProgress by remember { mutableStateOf(0f) } @@ -76,7 +76,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc val zapOptions = zapTypes.map { it.second } val zapOptionExplainers = zapTypes.map { it.third } - var selectedZapType by remember { mutableStateOf(account.defaultZapType) } + var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) } Dialog( onDismissRequest = { onClose() }, @@ -158,7 +158,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc TextSpinner( label = stringResource(id = R.string.zap_type), - placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second, + placeholder = zapTypes.filter { it.first == accountViewModel.account.defaultZapType }.first().second, options = zapOptions, explainers = zapOptionExplainers, onSelect = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index c86754153..f07f795e5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -150,7 +150,7 @@ fun UserActionOptions( baseAuthor: User, accountViewModel: AccountViewModel ) { - val coroutineScope = rememberCoroutineScope() + val scope = rememberCoroutineScope() val accountState by accountViewModel.accountLiveData.observeAsState() val isHidden by remember(accountState) { @@ -168,12 +168,14 @@ fun UserActionOptions( if (isHidden) { ShowUserButton { - accountViewModel.show(baseAuthor) + scope.launch(Dispatchers.IO) { + accountViewModel.show(baseAuthor) + } } } else if (isFollowing) { - UnfollowButton { coroutineScope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } } + UnfollowButton { scope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } } } else { - FollowButton({ coroutineScope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } }) + FollowButton({ scope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } }) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index 8c9d37f6d..47f8d0264 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember 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.unit.dp import com.vitorpamplona.amethyst.ui.note.BadgeCompose import com.vitorpamplona.amethyst.ui.note.MessageSetCompose @@ -45,8 +46,15 @@ fun RefresheableCardView( enablePullRefresh: Boolean = true ) { var refreshing by remember { mutableStateOf(false) } - val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) + val pullRefreshState = rememberPullRefreshState( + refreshing, + onRefresh = + { + refreshing = true + viewModel.invalidateData() + refreshing = false + } + ) val modifier = if (enablePullRefresh) { Modifier.pullRefresh(pullRefreshState) @@ -151,7 +159,9 @@ private fun FeedLoaded( routeForLastRead: String ) { val defaultModifier = remember { - Modifier.fillMaxWidth().defaultMinSize(minHeight = 100.dp) + Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 100.dp) } LazyColumn( @@ -168,7 +178,7 @@ private fun FeedLoaded( Row(defaultModifier) { when (item) { is NoteCard -> NoteCompose( - item.note, + item, isBoostedNote = false, accountViewModel = accountViewModel, nav = nav, @@ -208,3 +218,36 @@ private fun FeedLoaded( } } } + +@Composable +fun NoteCompose( + baseNote: NoteCard, + routeForLastRead: String? = null, + modifier: Modifier = remember { Modifier }, + isBoostedNote: Boolean = false, + isQuotedNote: Boolean = false, + unPackReply: Boolean = true, + makeItShort: Boolean = false, + addMarginTop: Boolean = true, + parentBackgroundColor: Color? = null, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val note = remember(baseNote) { + baseNote.note + } + + NoteCompose( + baseNote = note, + routeForLastRead = routeForLastRead, + modifier = modifier, + isBoostedNote = isBoostedNote, + isQuotedNote = isQuotedNote, + unPackReply = unPackReply, + makeItShort = makeItShort, + addMarginTop = addMarginTop, + parentBackgroundColor = parentBackgroundColor, + accountViewModel = accountViewModel, + nav = nav + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index ff929707e..e4435cdef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -2,16 +2,14 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.Note @@ -19,57 +17,86 @@ import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit, routeForLastRead: String, onWantsToReply: (Note) -> Unit) { - val feedState by viewModel.feedContent.collectAsState() - - var isRefreshing by remember { mutableStateOf(false) } - - val listState = rememberForeverLazyListState(routeForLastRead) - - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - viewModel.invalidateData() - isRefreshing = false +fun RefreshingChatroomFeedView( + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit, + scrollStateKey: String? = null, + enablePullRefresh: Boolean = true +) { + RefresheableView(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderChatroomFeedView(viewModel, accountViewModel, listState, nav, routeForLastRead, onWantsToReply) } } +} - Column() { - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is FeedState.Empty -> { - FeedEmpty { - isRefreshing = true - } - } - is FeedState.FeedError -> { - FeedError(state.errorMessage) { - isRefreshing = true - } - } - is FeedState.Loaded -> { - LaunchedEffect(state.feed.value.firstOrNull()) { - if (listState.firstVisibleItemIndex <= 1) { - listState.animateScrollToItem(0) - } - } +@Composable +fun RenderChatroomFeedView( + viewModel: FeedViewModel, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit +) { + val feedState by viewModel.feedContent.collectAsState() - LazyColumn( - contentPadding = PaddingValues( - top = 10.dp, - bottom = 10.dp - ), - reverseLayout = true, - state = listState - ) { - itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, nav = nav, onWantsToReply = onWantsToReply) - } - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty { + viewModel.invalidateData() } - is FeedState.Loading -> { - LoadingFeed() + } + is FeedState.FeedError -> { + FeedError(state.errorMessage) { + viewModel.invalidateData() } } + is FeedState.Loaded -> { + ChatroomFeedLoaded(state, accountViewModel, listState, nav, routeForLastRead, onWantsToReply) + } + is FeedState.Loading -> { + LoadingFeed() + } + } + } +} + +@Composable +fun ChatroomFeedLoaded( + state: FeedState.Loaded, + accountViewModel: AccountViewModel, + listState: LazyListState, + nav: (String) -> Unit, + routeForLastRead: String, + onWantsToReply: (Note) -> Unit +) { + LaunchedEffect(state.feed.value.firstOrNull()) { + if (listState.firstVisibleItemIndex <= 1) { + listState.animateScrollToItem(0) + } + } + + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ), + reverseLayout = true, + state = listState + ) { + itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> + ChatroomMessageCompose( + baseNote = item, + routeForLastRead = routeForLastRead, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = onWantsToReply + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index c95f01b6d..8d917ea4a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -5,6 +5,9 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -16,7 +19,6 @@ 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -25,7 +27,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.NotificationCache -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.note.ChatroomCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -90,12 +91,6 @@ private fun FeedLoaded( ) { val listState = rememberLazyListState() - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return - - val notificationCacheState = NotificationCache.live.observeAsState() - val notificationCache = notificationCacheState.value ?: return - LaunchedEffect(key1 = markAsRead.value) { if (markAsRead.value) { for (note in state.feed.value) { @@ -104,21 +99,11 @@ private fun FeedLoaded( val route = if (channelHex != null) { "Channel/$channelHex" } else { - val replyAuthorBase = - (note.event as? PrivateDmEvent) - ?.verifiedRecipientPubKey() - ?.let { LocalCache.getOrCreateUser(it) } - - var userToComposeOn = note.author!! - if (replyAuthorBase != null) { - if (note.author == account.userProfile()) { - userToComposeOn = replyAuthorBase - } - } - "Room/${userToComposeOn.pubkeyHex}" + val roomUser = (note.event as? PrivateDmEvent)?.talkingWith(accountViewModel.account.userProfile().pubkeyHex) + "Room/$roomUser" } - notificationCache.cache.markAsRead(route, it.createdAt()) + NotificationCache.markAsRead(route, it.createdAt()) } } markAsRead.value = false @@ -136,11 +121,13 @@ private fun FeedLoaded( state.feed.value, key = { index, item -> if (index == 0) index else item.idHex } ) { _, item -> - ChatroomCompose( - item, - accountViewModel = accountViewModel, - nav = nav - ) + Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp)) { + ChatroomCompose( + item, + accountViewModel = accountViewModel, + nav = nav + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 291ee3b4c..77540d059 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn @@ -34,15 +36,27 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +@Composable +fun RefresheableFeedView( + viewModel: FeedViewModel, + routeForLastRead: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + + scrollStateKey: String? = null, + enablePullRefresh: Boolean = true +) { + RefresheableView(viewModel, enablePullRefresh) { + SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey) + } +} + @OptIn(ExperimentalMaterialApi::class) @Composable fun RefresheableView( - viewModel: FeedViewModel, - accountViewModel: AccountViewModel, - nav: (String) -> Unit, - routeForLastRead: String?, - scrollStateKey: String? = null, - enablePullRefresh: Boolean = true + viewModel: InvalidatableViewModel, + enablePullRefresh: Boolean = true, + content: @Composable () -> Unit ) { var refreshing by remember { mutableStateOf(false) } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } @@ -56,7 +70,7 @@ fun RefresheableView( Box(modifier) { Column { - SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey) + content() } if (enablePullRefresh) { @@ -72,6 +86,17 @@ private fun SaveableFeedState( nav: (String) -> Unit, routeForLastRead: String?, scrollStateKey: String? = null +) { + SaveableFeedState(viewModel, scrollStateKey) { listState -> + RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) + } +} + +@Composable +fun SaveableFeedState( + viewModel: FeedViewModel, + scrollStateKey: String? = null, + content: @Composable (LazyListState) -> Unit ) { val listState = if (scrollStateKey != null) { rememberForeverLazyListState(scrollStateKey) @@ -81,7 +106,7 @@ private fun SaveableFeedState( WatchScrollToTop(viewModel, listState) - RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead) + content(listState) } @Composable @@ -163,14 +188,16 @@ private fun FeedLoaded( state = listState ) { itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - NoteCompose( - item, - routeForLastRead = routeForLastRead, - modifier = baseModifier, - isBoostedNote = false, - accountViewModel = accountViewModel, - nav = nav - ) + Row(Modifier.fillMaxWidth().defaultMinSize(minHeight = 75.dp)) { + NoteCompose( + item, + routeForLastRead = routeForLastRead, + modifier = baseModifier, + isBoostedNote = false, + accountViewModel = accountViewModel, + nav = nav + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 48b8ea54f..418a669ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -41,8 +42,21 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter) -class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter) +class NostrChannelFeedViewModel(val channel: Channel, val account: Account) : FeedViewModel(ChannelFeedFilter(channel, account)) { + class Factory(val channel: Channel, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChannelFeedViewModel { + return NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel + } + } +} +class NostrChatroomFeedViewModel(val user: User, val account: Account) : FeedViewModel(ChatroomFeedFilter(user, account)) { + class Factory(val user: User, val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrChatRoomFeedViewModel { + return NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel + } + } +} + class NostrGlobalFeedViewModel(val account: Account) : FeedViewModel(GlobalFeedFilter(account)) { class Factory(val account: Account) : ViewModelProvider.Factory { override fun create(modelClass: Class): NostrGlobalFeedViewModel { @@ -115,7 +129,7 @@ class NostrUserAppRecommendationsFeedViewModel(val user: User) : FeedViewModel(U } @Stable -abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { +abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel(), InvalidatableViewModel { private val _feedContent = MutableStateFlow(FeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -204,7 +218,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val bundler = BundledUpdate(250, Dispatchers.IO) private val bundlerInsert = BundledInsert>(250, Dispatchers.IO) - fun invalidateData(ignoreIfDoing: Boolean = false) { + override fun invalidateData(ignoreIfDoing: Boolean) { bundler.invalidate(ignoreIfDoing) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 16be111cd..e75dcc99c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @OptIn(ExperimentalMaterialApi::class) @@ -388,10 +389,7 @@ fun NoteMaster( if (eventContent != null) { val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false } - - val tags = remember(note) { - note.event?.tags() ?: emptyList() - } + val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList>().toImmutableList() } SensitivityWarning( hasSensitiveContent = hasSensitiveContent, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt index b5f80f666..d460ae34c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt @@ -2,29 +2,18 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -@OptIn(ExperimentalMaterialApi::class) @Composable fun RefreshingFeedUserFeedView( viewModel: UserFeedViewModel, @@ -32,24 +21,8 @@ fun RefreshingFeedUserFeedView( nav: (String) -> Unit, enablePullRefresh: Boolean = true ) { - var refreshing by remember { mutableStateOf(false) } - val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - - val modifier = if (enablePullRefresh) { - Modifier.pullRefresh(pullRefreshState) - } else { - Modifier - } - - Box(modifier) { - Column() { - UserFeedView(viewModel, accountViewModel, nav) - } - - if (enablePullRefresh) { - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) - } + RefresheableView(viewModel, enablePullRefresh) { + UserFeedView(viewModel, accountViewModel, nav) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index a82145806..48835c45b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -47,7 +47,7 @@ class NostrHiddenAccountsFeedViewModel(val account: Account) : UserFeedViewModel } } -open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { +open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel(), InvalidatableViewModel { private val _feedContent = MutableStateFlow(UserFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -91,8 +91,8 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private val bundler = BundledUpdate(250, Dispatchers.IO) - fun invalidateData() { - bundler.invalidate() { + override fun invalidateData(ignoreIfDoing: Boolean) { + bundler.invalidate(ignoreIfDoing) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. refreshSuspended() @@ -114,3 +114,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { super.onCleared() } } + +interface InvalidatableViewModel { + fun invalidateData(ignoreIfDoing: Boolean = false) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index adefd2327..ac0ffa1b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -59,10 +59,6 @@ class AccountViewModel(val account: Account) : ViewModel() { account.delete(account.boostsTo(note)) } - fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) { - zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType) - } - fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { return account.calculateIfNoteWasZappedByAccount(zappedNote) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt index 82a9571e7..ef8d6064b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @@ -73,8 +73,8 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit } HorizontalPager(pageCount = 2, state = pagerState) { page -> when (page) { - 0 -> RefresheableView(privateFeedViewModel, accountViewModel, nav, null) - 1 -> RefresheableView(publicFeedViewModel, accountViewModel, nav, null) + 0 -> RefresheableFeedView(privateFeedViewModel, null, accountViewModel, nav) + 1 -> RefresheableFeedView(publicFeedViewModel, null, accountViewModel, nav) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 25bedd3d2..5ca792f29 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -74,13 +74,13 @@ import com.vitorpamplona.amethyst.ui.actions.PostButton import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy -import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose -import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun ChannelScreen( @@ -88,174 +88,256 @@ fun ChannelScreen( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account - val context = LocalContext.current + if (channelId == null) return + + var channelBase by remember { mutableStateOf(null) } + + LaunchedEffect(channelId) { + withContext(Dispatchers.IO) { + val newChannelBase = LocalCache.checkGetOrCreateChannel(channelId) + if (newChannelBase != channelBase) { + channelBase = newChannelBase + } + } + } + + channelBase?.let { + PrepareChannelViewModels( + baseChannel = it, + accountViewModel = accountViewModel, + nav = nav + ) + } +} + +@Composable +fun PrepareChannelViewModels(baseChannel: Channel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val feedViewModel: NostrChannelFeedViewModel = viewModel( + key = baseChannel.idHex + "ChannelFeedViewModel", + factory = NostrChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account + ) + ) + val channelScreenModel: NewPostViewModel = viewModel() + channelScreenModel.account = accountViewModel.account - channelScreenModel.account = account + ChannelScreen( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav + ) +} + +@Composable +fun ChannelScreen( + channel: Channel, + feedViewModel: NostrChannelFeedViewModel, + newPostModel: NewPostViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val context = LocalContext.current + + NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) + + val lifeCycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(Unit) { + NostrChannelDataSource.start() + + feedViewModel.invalidateData() + newPostModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + + DisposableEffect(accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Channel Start") + NostrChannelDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Channel Stop") + + NostrChannelDataSource.clear() + NostrChannelDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + ChannelHeader( + channel, + accountViewModel, + nav = nav + ) - if (account != null && channelId != null) { val replyTo = remember { mutableStateOf(null) } - ChannelFeedFilter.loadMessagesBetween(account, channelId) - NostrChannelDataSource.loadMessagesBetween(account, channelId) - - val channelState by NostrChannelDataSource.channel!!.live.observeAsState() - val channel = channelState?.channel ?: return - - ChannelFeedFilter.loadMessagesBetween(account, channelId) - - val feedViewModel: NostrChannelFeedViewModel = viewModel() - val lifeCycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(Unit) { - ChannelFeedFilter.loadMessagesBetween(account, channelId) - NostrChannelDataSource.loadMessagesBetween(account, channelId) - - feedViewModel.invalidateData() - channelScreenModel.imageUploadingError.collect { error -> - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - - DisposableEffect(channelId) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Channel Start") - ChannelFeedFilter.loadMessagesBetween(account, channelId) - NostrChannelDataSource.loadMessagesBetween(account, channelId) - - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Channel Stop") - - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - ChannelHeader( - channel, - accountViewModel, - nav = nav - ) - - Column( - modifier = Modifier + Column( + modifier = remember { + Modifier .fillMaxHeight() .padding(vertical = 0.dp) .weight(1f, true) - ) { - ChatroomFeedView(feedViewModel, accountViewModel, nav, "Channel/$channelId") { + } + ) { + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.idHex}", + onWantsToReply = { replyTo.value = it } - } + ) + } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - Row( - Modifier - .padding(horizontal = 10.dp) - .animateContentSize(), - verticalAlignment = Alignment.CenterVertically - ) { - val replyingNote = replyTo.value - if (replyingNote != null) { - Column(Modifier.weight(1f)) { - ChatroomMessageCompose( - baseNote = replyingNote, - null, - innerQuote = true, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = { - replyTo.value = it - } - ) - } - - Column(Modifier.padding(end = 10.dp)) { - IconButton( - modifier = Modifier.size(30.dp), - onClick = { replyTo.value = null } - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Modifier - .padding(end = 5.dp) - .size(30.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } - } - } - } - - // LAST ROW - Row( - modifier = Modifier - .padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = channelScreenModel.message, - onValueChange = { - channelScreenModel.updateMessage(it) - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - shape = RoundedCornerShape(25.dp), - modifier = Modifier.weight(1f, true), - placeholder = { - Text( - text = stringResource(R.string.reply_here), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - PostButton( - onPost = { - val tagger = NewMessageTagger(channel.idHex, listOfNotNull(replyTo.value?.author), listOfNotNull(replyTo.value), channelScreenModel.message.text) - tagger.run() - account.sendChannelMessage(tagger.message, channel.idHex, tagger.replyTos, tagger.mentions, wantsToMarkAsSensitive = false) - channelScreenModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.invalidateData() // Don't wait a full second before updating - }, - isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, - modifier = Modifier.padding(end = 10.dp) - ) - }, - leadingIcon = { - UploadFromGallery( - isUploading = channelScreenModel.isUploadingImage, - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = Modifier.padding(start = 5.dp) - ) { - channelScreenModel.upload(it, "", account.defaultFileServer, context) - } - }, - colors = TextFieldDefaults.textFieldColors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) + replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { + replyTo.value = null } } + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow(newPostModel, accountViewModel) { + scope.launch { + val tagger = NewMessageTagger( + channelHex = channel.idHex, + mentions = listOfNotNull(replyTo.value?.author), + replyTos = listOfNotNull(replyTo.value), + message = newPostModel.message.text + ) + tagger.run() + accountViewModel.account.sendChannelMessage( + message = tagger.message, + toChannel = channel.idHex, + replyTo = tagger.replyTos, + mentions = tagger.mentions, + wantsToMarkAsSensitive = false + ) + newPostModel.message = TextFieldValue("") + replyTo.value = null + feedViewModel.sendToTop() + } + } + } +} + +@Composable +fun DisplayReplyingToNote( + replyingNote: Note?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, + onCancel: () -> Unit +) { + Row( + Modifier + .padding(horizontal = 10.dp) + .animateContentSize(), + verticalAlignment = Alignment.CenterVertically + ) { + if (replyingNote != null) { + Column(remember { Modifier.weight(1f) }) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {} + ) + } + + Column(Modifier.padding(end = 10.dp)) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = onCancel + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier + .padding(end = 5.dp) + .size(30.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } + } +} + +@Composable +fun EditFieldRow( + channelScreenModel: NewPostViewModel, + accountViewModel: AccountViewModel, + onSendNewMessage: () -> Unit +) { + val context = LocalContext.current + + Row( + modifier = Modifier + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = channelScreenModel.message, + onValueChange = { + channelScreenModel.updateMessage(it) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + shape = RoundedCornerShape(25.dp), + modifier = Modifier.weight(1f, true), + placeholder = { + Text( + text = stringResource(R.string.reply_here), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + PostButton( + onPost = { + onSendNewMessage() + }, + isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + modifier = Modifier.padding(end = 10.dp) + ) + }, + leadingIcon = { + UploadFromGallery( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = Modifier.padding(start = 5.dp) + ) { + channelScreenModel.upload(it, "", accountViewModel.account.defaultFileServer, context) + } + }, + colors = TextFieldDefaults.textFieldColors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ) + ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 2684abdb1..1d818890a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -1,202 +1,182 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.widget.Toast -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.LocalTextStyle -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.material.TextField -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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 -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel -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.NostrChatroomDataSource import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel -import com.vitorpamplona.amethyst.ui.actions.PostButton -import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status -import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter -import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.screen.ChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.NostrChatRoomFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.RefreshingChatroomFeedView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable -fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val context = LocalContext.current - val chatRoomScreenModel: NewPostViewModel = viewModel() - chatRoomScreenModel.account = accountViewModel.account +fun ChatroomScreen( + userId: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + if (userId == null) return + + var userRoom by remember { mutableStateOf(null) } + + LaunchedEffect(userId) { + withContext(Dispatchers.IO) { + val newUser = LocalCache.checkGetOrCreateUser(userId) + if (newUser != userRoom) { + userRoom = newUser + } + } + } + + userRoom?.let { + PrepareChatroomViewModels( + baseUser = it, + accountViewModel = accountViewModel, + nav = nav + ) + } +} + +@Composable +fun PrepareChatroomViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val feedViewModel: NostrChatroomFeedViewModel = viewModel( + key = baseUser.pubkeyHex + "ChatroomViewModels", + factory = NostrChatroomFeedViewModel.Factory( + baseUser, + accountViewModel.account + ) + ) + + val newPostModel: NewPostViewModel = viewModel() + newPostModel.account = accountViewModel.account + + ChatroomScreen( + baseUser = baseUser, + feedViewModel = feedViewModel, + newPostModel = newPostModel, + accountViewModel = accountViewModel, + nav = nav + ) +} + +@Composable +fun ChatroomScreen( + baseUser: User, + feedViewModel: NostrChatroomFeedViewModel, + newPostModel: NewPostViewModel, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val context = LocalContext.current + + NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, baseUser) + + val lifeCycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(baseUser, accountViewModel) { + NostrChatroomDataSource.start() + + feedViewModel.invalidateData() + newPostModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + + DisposableEffect(baseUser, accountViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Private Message Start") + NostrChatroomDataSource.start() + feedViewModel.invalidateData() + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Private Message Stop") + NostrChatroomDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { + lifeCycleOwner.lifecycle.removeObserver(observer) + } + } + + Column(Modifier.fillMaxHeight()) { + ChatroomHeader(baseUser, accountViewModel, nav = nav) - if (userId != null) { val replyTo = remember { mutableStateOf(null) } - ChatroomFeedFilter.loadMessagesBetween(accountViewModel.account, userId) - NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, userId) - - val feedViewModel: NostrChatRoomFeedViewModel = viewModel() - val lifeCycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(userId) { - feedViewModel.invalidateData() - chatRoomScreenModel.imageUploadingError.collect { error -> - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - - DisposableEffect(userId) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Private Message Start") - NostrChatroomDataSource.start() - feedViewModel.invalidateData() - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Private Message Stop") - NostrChatroomDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { - lifeCycleOwner.lifecycle.removeObserver(observer) - } - } - - Column(Modifier.fillMaxHeight()) { - NostrChatroomDataSource.withUser?.let { - ChatroomHeader(it, accountViewModel, nav = nav) - } - - Column( - modifier = Modifier - .fillMaxHeight() - .padding(vertical = 0.dp) - .weight(1f, true) - ) { - ChatroomFeedView(feedViewModel, accountViewModel, nav, "Room/$userId") { + Column( + modifier = Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + ) { + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Room/${baseUser.pubkeyHex}", + onWantsToReply = { replyTo.value = it } + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { + replyTo.value = null } + } - Spacer(modifier = Modifier.height(10.dp)) + val scope = rememberCoroutineScope() - Row(Modifier.padding(horizontal = 10.dp).animateContentSize(), verticalAlignment = Alignment.CenterVertically) { - val replyingNote = replyTo.value - if (replyingNote != null) { - Column(Modifier.weight(1f)) { - ChatroomMessageCompose( - baseNote = replyingNote, - null, - innerQuote = true, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = { - replyTo.value = it - } - ) - } - - Column(Modifier.padding(end = 10.dp)) { - IconButton( - modifier = Modifier.size(30.dp), - onClick = { replyTo.value = null } - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = Modifier.padding(end = 5.dp).size(30.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } - } - } - } - - // LAST ROW - Row( - modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = chatRoomScreenModel.message, - onValueChange = { chatRoomScreenModel.updateMessage(it) }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences - ), - modifier = Modifier.weight(1f, true), - shape = RoundedCornerShape(25.dp), - placeholder = { - Text( - text = stringResource(id = R.string.reply_here), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - PostButton( - onPost = { - accountViewModel.account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value, null, wantsToMarkAsSensitive = false) - chatRoomScreenModel.message = TextFieldValue("") - replyTo.value = null - feedViewModel.invalidateData() // Don't wait a full second before updating - }, - isActive = chatRoomScreenModel.message.text.isNotBlank() && !chatRoomScreenModel.isUploadingImage, - modifier = Modifier.padding(end = 10.dp) - ) - }, - leadingIcon = { - UploadFromGallery( - isUploading = chatRoomScreenModel.isUploadingImage, - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = Modifier.padding(start = 5.dp) - ) { - chatRoomScreenModel.upload(it, "", accountViewModel.account.defaultFileServer, context) - } - }, - colors = TextFieldDefaults.textFieldColors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) + // LAST ROW + EditFieldRow(newPostModel, accountViewModel) { + scope.launch(Dispatchers.IO) { + accountViewModel.account.sendPrivateMessage( + message = newPostModel.message.text, + toUser = baseUser, + replyingTo = replyTo.value, + mentions = null, + wantsToMarkAsSensitive = false ) + newPostModel.message = TextFieldValue("") + replyTo.value = null + feedViewModel.sendToTop() } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt index 2f5928c9e..f3f6a2fdc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HashtagScreen.kt @@ -27,7 +27,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.service.NostrHashtagDataSource import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -73,7 +73,7 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String modifier = Modifier.padding(vertical = 0.dp) ) { HashtagHeader(tag, accountViewModel) - RefresheableView(feedViewModel, accountViewModel, nav, null) + RefresheableFeedView(feedViewModel, null, accountViewModel, nav) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 8358920fc..ca3a2592e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -34,7 +34,7 @@ import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState import kotlinx.collections.immutable.ImmutableList @@ -121,7 +121,7 @@ private fun HomePages( } HorizontalPager(pageCount = 2, state = pagerState) { page -> - RefresheableView( + RefresheableFeedView( viewModel = tabs[page].viewModel, accountViewModel = accountViewModel, nav = nav, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 1893c7caa..7f64325af 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -91,12 +91,13 @@ import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewM import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -717,7 +718,7 @@ private fun DrawAdditionalInfo( TranslatableRichTextViewer( content = it, canPreview = false, - tags = remember { emptyList() }, + tags = remember { persistentListOf() }, backgroundColor = MaterialTheme.colors.background, accountViewModel = accountViewModel, nav = nav @@ -767,6 +768,7 @@ private fun DisplayLNAddress( userHex, account, onSuccess = { + zapExpanded = false // pay directly if (account.hasWalletConnectSetup()) { account.sendZapPaymentRequestFor(it, null) { response -> @@ -1053,7 +1055,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit Column( modifier = Modifier.padding(vertical = 0.dp) ) { - RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) + RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false) } } } @@ -1070,7 +1072,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U Column( modifier = Modifier.padding(vertical = 0.dp) ) { - RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) + RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false) } } } @@ -1087,7 +1089,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (Strin Column( modifier = Modifier.padding(vertical = 0.dp) ) { - RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) + RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false) } } } @@ -1154,7 +1156,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, nav: (String) Column( modifier = Modifier.padding(vertical = 0.dp) ) { - RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) + RefresheableFeedView(feedViewModel, null, accountViewModel, nav, enablePullRefresh = false) } } } @@ -1310,6 +1312,8 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return + val scope = rememberCoroutineScope() + DropdownMenu( expanded = popupExpanded, onDismissRequest = onDismiss @@ -1322,51 +1326,65 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () -> Divider() if (account.isHidden(user)) { DropdownMenuItem(onClick = { - accountViewModel.show(user) - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.show(user) + onDismiss() + } }) { Text(stringResource(R.string.unblock_user)) } } else { DropdownMenuItem(onClick = { - accountViewModel.hide(user) - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.hide(user) + onDismiss() + } }) { Text(stringResource(id = R.string.block_hide_user)) } } Divider() DropdownMenuItem(onClick = { - accountViewModel.report(user, ReportEvent.ReportType.SPAM) - accountViewModel.hide(user) + scope.launch(Dispatchers.IO) { + accountViewModel.report(user, ReportEvent.ReportType.SPAM) + accountViewModel.hide(user) + } onDismiss() }) { Text(stringResource(id = R.string.report_spam_scam)) } DropdownMenuItem(onClick = { - accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) - accountViewModel.hide(user) + scope.launch(Dispatchers.IO) { + accountViewModel.report(user, ReportEvent.ReportType.PROFANITY) + accountViewModel.hide(user) + } onDismiss() }) { Text(stringResource(R.string.report_hateful_speech)) } DropdownMenuItem(onClick = { - accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) - accountViewModel.hide(user) + scope.launch(Dispatchers.IO) { + accountViewModel.report(user, ReportEvent.ReportType.IMPERSONATION) + accountViewModel.hide(user) + } onDismiss() }) { Text(stringResource(id = R.string.report_impersonation)) } DropdownMenuItem(onClick = { - accountViewModel.report(user, ReportEvent.ReportType.NUDITY) - accountViewModel.hide(user) + scope.launch(Dispatchers.IO) { + accountViewModel.report(user, ReportEvent.ReportType.NUDITY) + accountViewModel.hide(user) + } onDismiss() }) { Text(stringResource(R.string.report_nudity_porn)) } DropdownMenuItem(onClick = { - accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) - accountViewModel.hide(user) + scope.launch(Dispatchers.IO) { + accountViewModel.report(user, ReportEvent.ReportType.ILLEGAL) + accountViewModel.hide(user) + } onDismiss() }) { Text(stringResource(id = R.string.report_illegal_behaviour)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt index 3be8bdf12..5054b1dff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable 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 @@ -41,6 +42,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.ui.theme.WarningColor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit) { @@ -78,6 +81,8 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: ) } ) { pad -> + val scope = rememberCoroutineScope() + Column( modifier = Modifier.padding(16.dp, pad.calculateTopPadding(), 16.dp, pad.calculateBottomPadding()), verticalArrangement = Arrangement.SpaceAround @@ -93,8 +98,10 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: text = stringResource(R.string.report_dialog_block_hide_user_btn), icon = Icons.Default.Block, onClick = { - note.author?.let { accountViewModel.hide(it) } - onDismiss() + scope.launch(Dispatchers.IO) { + note.author?.let { accountViewModel.hide(it) } + onDismiss() + } } ) SpacerH16() @@ -124,14 +131,21 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss: modifier = Modifier.fillMaxWidth() ) SpacerH16() + ActionButton( text = stringResource(R.string.report_dialog_post_report_btn), icon = Icons.Default.Report, enabled = selectedReason in 0..reportTypes.lastIndex, onClick = { - accountViewModel.report(note, reportTypes[selectedReason].first, additionalReason) - note.author?.let { accountViewModel.hide(it) } - onDismiss() + scope.launch(Dispatchers.IO) { + accountViewModel.report( + note, + reportTypes[selectedReason].first, + additionalReason + ) + note.author?.let { accountViewModel.hide(it) } + onDismiss() + } } ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 04efeffe0..a0fb292ac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.RefresheableView +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -113,7 +113,7 @@ fun SearchScreen( modifier = Modifier.padding(vertical = 0.dp) ) { SearchBar(accountViewModel, nav) - RefresheableView(searchFeedViewModel, accountViewModel, nav, null, ScrollStateKeys.GLOBAL_SCREEN) + RefresheableFeedView(searchFeedViewModel, null, accountViewModel, nav, ScrollStateKeys.GLOBAL_SCREEN) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index d3d6c1a71..c79461c7c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -253,11 +253,9 @@ private fun RenderVideoOrPictureNote( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val noteEvent = note.event - var moreActionsExpanded by remember { mutableStateOf(false) } - - Column(Modifier.fillMaxSize(1f)) { - Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Column(remember { Modifier.fillMaxSize(1f) }) { + Row(remember { Modifier.weight(1f) }, verticalAlignment = Alignment.CenterVertically) { + val noteEvent = remember { note.event } if (noteEvent is FileHeaderEvent) { FileHeaderDisplay(note) } else if (noteEvent is FileStorageHeaderEvent) { @@ -266,51 +264,17 @@ private fun RenderVideoOrPictureNote( } } - Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) { - Column(Modifier.weight(1f)) { - Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) { - Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) { - NoteAuthorPicture(note, nav, accountViewModel, 55.dp) - } - - Column( - Modifier - .padding(start = 10.dp, end = 10.dp) - .height(60.dp) - .weight(1f), - verticalArrangement = Arrangement.Center - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - NoteUsernameDisplay(note, Modifier.weight(1f)) - - IconButton( - modifier = Modifier.size(24.dp), - onClick = { moreActionsExpanded = true } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - - NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel) - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f)) - } - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) { - RelayBadges(baseNote = note) - } - } - } + Row(verticalAlignment = Alignment.Bottom, modifier = remember { Modifier.fillMaxSize(1f) }) { + Column(remember { Modifier.weight(1f) }) { + RenderVideoOrPicture(note, nav, accountViewModel) } Column( - Modifier - .width(65.dp) - .padding(bottom = 10.dp), + remember { + Modifier + .width(65.dp) + .padding(bottom = 10.dp) + }, verticalArrangement = Arrangement.Center ) { Row(horizontalArrangement = Arrangement.Center) { @@ -320,6 +284,73 @@ private fun RenderVideoOrPictureNote( } } +@Composable +private fun RenderVideoOrPicture( + note: Note, + nav: (String) -> Unit, + accountViewModel: AccountViewModel +) { + Row(remember { Modifier.padding(10.dp) }, verticalAlignment = Alignment.Bottom) { + Column(remember { Modifier.size(55.dp) }, verticalArrangement = Arrangement.Center) { + NoteAuthorPicture(note, nav, accountViewModel, 55.dp) + } + + Column( + remember { + Modifier + .padding(start = 10.dp, end = 10.dp) + .height(60.dp) + .weight(1f) + }, + verticalArrangement = Arrangement.Center + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) + VideoUserOptionAction(note, accountViewModel) + } + Row(verticalAlignment = Alignment.CenterVertically) { + ObserveDisplayNip05Status( + remember { note.author!! }, + remember { Modifier.weight(1f) } + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 5.dp) + ) { + RelayBadges(baseNote = note) + } + } + } +} + +@Composable +private fun VideoUserOptionAction( + note: Note, + accountViewModel: AccountViewModel +) { + var moreActionsExpanded by remember { mutableStateOf(false) } + + IconButton( + modifier = remember { Modifier.size(24.dp) }, + onClick = { moreActionsExpanded = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = remember { Modifier.size(20.dp) }, + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + NoteDropDownMenu( + note, + moreActionsExpanded, + { moreActionsExpanded = false }, + accountViewModel + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun RelayBadges(baseNote: Note) { diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 97448087c..f89ec59e1 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService import com.vitorpamplona.amethyst.service.lang.ResultOrError import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.Locale @@ -43,7 +44,7 @@ fun TranslatableRichTextViewer( content: String, canPreview: Boolean, modifier: Modifier = Modifier, - tags: List>, + tags: ImmutableList>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -74,7 +75,7 @@ fun TranslatableRichTextViewer( } } - Column() { + Column { ExpandableRichTextViewer( toBeViewed, canPreview, @@ -89,6 +90,8 @@ fun TranslatableRichTextViewer( val source = translatedTextState.sourceLang if (source != null && target != null && source != target) { + val scope = rememberCoroutineScope() + Row( modifier = Modifier .fillMaxWidth() @@ -140,8 +143,10 @@ fun TranslatableRichTextViewer( onDismissRequest = { langSettingsPopupExpanded = false } ) { DropdownMenuItem(onClick = { - accountViewModel.dontTranslateFrom(source) - langSettingsPopupExpanded = false + scope.launch(Dispatchers.IO) { + accountViewModel.dontTranslateFrom(source) + langSettingsPopupExpanded = false + } }) { if (source in accountViewModel.account.dontTranslateFrom) { Icon( @@ -159,8 +164,10 @@ fun TranslatableRichTextViewer( } Divider() DropdownMenuItem(onClick = { - accountViewModel.prefer(source, target, source) - langSettingsPopupExpanded = false + scope.launch(Dispatchers.IO) { + accountViewModel.prefer(source, target, source) + langSettingsPopupExpanded = false + } }) { if (accountViewModel.account.preferenceBetween(source, target) == source) { Icon( @@ -177,8 +184,10 @@ fun TranslatableRichTextViewer( Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName)) } DropdownMenuItem(onClick = { - accountViewModel.prefer(source, target, target) - langSettingsPopupExpanded = false + scope.launch(Dispatchers.IO) { + accountViewModel.prefer(source, target, target) + langSettingsPopupExpanded = false + } }) { if (accountViewModel.account.preferenceBetween(source, target) == target) { Icon( @@ -201,8 +210,10 @@ fun TranslatableRichTextViewer( for (i in 0 until languageList.size()) { languageList.get(i)?.let { lang -> DropdownMenuItem(onClick = { - accountViewModel.translateTo(lang) - langSettingsPopupExpanded = false + scope.launch(Dispatchers.IO) { + accountViewModel.translateTo(lang) + langSettingsPopupExpanded = false + } }) { if (lang.language in accountViewModel.account.translateTo) { Icon(