Finish the migration from LiveData to Flow

Moves most of the flow updates away from the Main thread.
Removes the need to use the Main thread to access Account.userProfile
This commit is contained in:
Vitor Pamplona
2025-04-24 14:35:22 -04:00
parent e375b09a24
commit 067f353e50
104 changed files with 1166 additions and 1079 deletions
-4
View File
@@ -201,9 +201,6 @@ dependencies {
// Navigation
implementation libs.androidx.navigation.compose
// Observe Live data as State
implementation libs.androidx.runtime.livedata
// Material 3 Design
implementation libs.androidx.material3
implementation libs.androidx.material.icons
@@ -216,7 +213,6 @@ dependencies {
implementation libs.androidx.lifecycle.runtime.ktx
implementation libs.androidx.lifecycle.runtime.compose
implementation libs.androidx.lifecycle.viewmodel.compose
implementation libs.androidx.lifecycle.livedata.ktx
// Zoomable images
implementation libs.zoomable
@@ -72,8 +72,6 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Notes: " +
LocalCache.notes.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.notes.filter { _, it -> it.event != null }.size +
@@ -83,8 +81,6 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Addressables: " +
LocalCache.addressables.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.addressables.filter { _, it -> it.event != null }.size +
@@ -94,8 +90,6 @@ fun debugState(context: Context) {
Log.d(
"STATE DUMP",
"Users: " +
LocalCache.users.filter { _, it -> it.liveSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
@@ -169,3 +163,21 @@ inline fun <T> logTime(
} else {
block()
}
inline fun debug(
tag: String,
debugMessage: String,
) {
if (isDebug) {
Log.d(tag, debugMessage)
}
}
inline fun debug(
tag: String,
debugMessage: () -> String,
) {
if (isDebug) {
Log.d(tag, debugMessage())
}
}
@@ -23,10 +23,6 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import com.fasterxml.jackson.module.kotlin.readValue
import com.fonfon.kgeohash.GeoHash
import com.vitorpamplona.amethyst.Amethyst
@@ -192,6 +188,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -584,7 +581,6 @@ class Account(
@OptIn(ExperimentalCoroutinesApi::class)
val liveKind3FollowsFlow: Flow<LiveFollowList> =
userProfile().flow().follows.stateFlow.transformLatest {
checkNotInMainThread()
emit(buildFollowLists(it.user.latestContactList))
}
@@ -1090,24 +1086,25 @@ class Account(
)
}
val liveHiddenUsers = flowHiddenUsers.asLiveData()
val decryptBookmarks: LiveData<BookmarkListEvent?> by lazy {
userProfile().live().bookmarks.switchMap { userState ->
liveData(Dispatchers.IO) {
val decryptBookmarks: Flow<BookmarkListEvent?> by lazy {
userProfile()
.flow()
.bookmarks.stateFlow
.map { userState ->
if (userState.user.latestBookmarkList == null) {
emit(null)
null
} else {
emit(
val result =
tryAndWait { continuation ->
userState.user.latestBookmarkList?.privateTags(signer) {
continuation.resume(userState.user.latestBookmarkList)
}
},
)
}
}
result
}
}.debounce(1000)
.flowOn(Dispatchers.Default)
}
class EmojiMedia(
@@ -21,9 +21,7 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.commons.data.LargeCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
@@ -43,6 +41,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelData
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
class PublicChatChannel(
@@ -93,7 +92,7 @@ class PublicChatChannel(
return info.picture ?: super.profilePicture()
}
override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).filter { it.contains(prefix, true) }.isNotEmpty()
override fun anyNameStartsWith(prefix: String): Boolean = listOfNotNull(info.name, info.about).any { it.contains(prefix, true) }
}
@Stable
@@ -182,7 +181,7 @@ abstract class Channel(
this.creator = creator
this.updatedMetadataAt = updatedAt
live.invalidateData()
flow.invalidateData()
}
@Synchronized
@@ -227,7 +226,7 @@ abstract class Channel(
abstract fun anyNameStartsWith(prefix: String): Boolean
// Observers line up here.
val live: ChannelLiveData = ChannelLiveData(this)
val flow: ChannelFlow = ChannelFlow(this)
fun pruneOldMessages(): Set<Note> {
val important =
@@ -257,22 +256,24 @@ abstract class Channel(
}
}
class ChannelLiveData(
class ChannelFlow(
val channel: Channel,
) : LiveData<ChannelState>(ChannelState(channel)) {
) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO)
val stateFlow = MutableStateFlow(ChannelState(channel))
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
if (hasActiveObservers()) {
postValue(ChannelState(channel))
stateFlow.emit(ChannelState(channel))
}
}
fun destroy() {
bundler.cancel()
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
class ChannelState(
@@ -87,7 +87,7 @@ class Chatroom {
} else {
// Old messages, keep the last one.
sorted.take(1).toSet()
} + sorted.filter { it.liveSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
} + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent }
val toRemove = roomMessages.minus(toKeep)
roomMessages = toKeep
@@ -938,7 +938,7 @@ object LocalCache : ILocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
author.liveSet?.statuses?.invalidateData()
author.flowSet?.statuses?.invalidateData()
refreshObservers(note)
}
@@ -963,7 +963,7 @@ object LocalCache : ILocalCache {
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.liveSet?.ots?.invalidateData()
version.flowSet?.ots?.invalidateData()
}
refreshObservers(version)
@@ -1245,7 +1245,6 @@ object LocalCache : ILocalCache {
}
deleteNote.clearFlow()
deleteNote.clearLive()
notes.remove(deleteNote.idHex)
}
@@ -1259,7 +1258,6 @@ object LocalCache : ILocalCache {
deleteWraps(noteEvent)
}
it.clearFlow()
it.clearLive()
}
notes.remove(it.id)
@@ -1379,7 +1377,7 @@ object LocalCache : ILocalCache {
mentions.forEach {
// doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData()
it.flowSet?.reports?.invalidateData()
}
}
@@ -1625,7 +1623,7 @@ object LocalCache : ILocalCache {
checkGetOrCreateNote(it.eventId)?.let { editedNote ->
modificationCache.remove(editedNote.idHex)
// must update list of Notes to quickly update the user.
editedNote.liveSet?.edits?.invalidateData()
editedNote.flowSet?.edits?.invalidateData()
}
}
@@ -2056,10 +2054,6 @@ object LocalCache : ILocalCache {
}
fun cleanObservers() {
notes.forEach { _, it -> it.clearLive() }
addressables.forEach { _, it -> it.clearLive() }
users.forEach { _, it -> it.clearLive() }
notes.forEach { _, it -> it.clearFlow() }
addressables.forEach { _, it -> it.clearFlow() }
users.forEach { _, it -> it.clearFlow() }
@@ -2213,8 +2207,8 @@ object LocalCache : ILocalCache {
note.event is ReportEvent ||
note.event is GenericRepostEvent
) &&
note.replyTo?.any { it.liveSet?.isInUse() == true } != true &&
note.liveSet?.isInUse() != true &&
note.replyTo?.any { it.flowSet?.isInUse() == true } != true &&
note.flowSet?.isInUse() != true &&
// don't delete if observing.
note.author?.pubkeyHex !in
accounts &&
@@ -2278,7 +2272,6 @@ object LocalCache : ILocalCache {
}
note.clearFlow()
note.clearLive()
notes.remove(note.idHex)
}
@@ -2313,12 +2306,10 @@ object LocalCache : ILocalCache {
val childrenToBeRemoved = mutableListOf<Note>()
val toBeRemoved =
account.liveHiddenUsers.value
?.hiddenUsers
?.map { userHex ->
account.flowHiddenUsers.value.hiddenUsers
.map { userHex ->
(notes.filter { _, it -> it.event?.pubKey == userHex } + addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet()
}?.flatten()
?: emptyList()
}.flatten()
toBeRemoved.forEach {
removeFromCache(it)
@@ -2337,7 +2328,7 @@ object LocalCache : ILocalCache {
users.forEach { _, user ->
if (
user.pubkeyHex !in loggedIn &&
(user.liveSet == null || user.liveSet?.isInUse() == false) &&
(user.flowSet == null || user.flowSet?.isInUse() == false) &&
user.latestContactList != null
) {
user.latestContactList = null
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.launchAndWaitAll
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
@@ -228,29 +227,36 @@ open class Note(
this.author = author
this.replyTo = replyTo
liveSet?.metadata?.invalidateData()
flowSet?.metadata?.invalidateData()
}
}
fun hasZapsBoostsOrReactions(): Boolean = reactions.isNotEmpty() || zaps.isNotEmpty() || boosts.isNotEmpty()
fun countReactions(): Int {
var total = 0
reactions.forEach { total += it.value.size }
return total
}
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveSet?.replies?.invalidateData()
flowSet?.replies?.invalidateData()
}
}
fun removeReply(note: Note) {
if (note in replies) {
replies = replies - note
liveSet?.replies?.invalidateData()
flowSet?.replies?.invalidateData()
}
}
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.boosts?.invalidateData()
flowSet?.boosts?.invalidateData()
}
}
@@ -281,13 +287,11 @@ open class Note(
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
if (repliesChanged) liveSet?.replies?.invalidateData()
if (reactionsChanged) liveSet?.reactions?.invalidateData()
if (boostsChanged) liveSet?.boosts?.invalidateData()
if (reportsChanged) {
flowSet?.reports?.invalidateData()
}
if (zapsChanged) liveSet?.zaps?.invalidateData()
if (repliesChanged) flowSet?.replies?.invalidateData()
if (reactionsChanged) flowSet?.reactions?.invalidateData()
if (boostsChanged) flowSet?.boosts?.invalidateData()
if (reportsChanged) flowSet?.reports?.invalidateData()
if (zapsChanged) flowSet?.zaps?.invalidateData()
return toBeRemoved
}
@@ -306,7 +310,7 @@ open class Note(
reactions = reactions + Pair(reaction, newList)
}
liveSet?.reactions?.invalidateData()
flowSet?.reactions?.invalidateData()
}
}
}
@@ -327,28 +331,28 @@ open class Note(
if (zaps[note] != null) {
zaps = zaps.minus(note)
updateZapTotal()
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
} else if (zaps.containsValue(note)) {
zaps = zaps.filterValues { it != note }
updateZapTotal()
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
fun removeZapPayment(note: Note) {
if (zapPayments.containsKey(note)) {
zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
zapPayments = zapPayments.filterValues { it != note }
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveSet?.boosts?.invalidateData()
flowSet?.boosts?.invalidateData()
}
}
@@ -375,7 +379,7 @@ open class Note(
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
}
@@ -401,7 +405,7 @@ open class Note(
if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
}
@@ -413,10 +417,10 @@ open class Note(
val listOfAuthors = reactions[reaction]
if (listOfAuthors == null) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.reactions?.invalidateData()
flowSet?.reactions?.invalidateData()
} else if (!listOfAuthors.contains(note)) {
reactions = reactions + Pair(reaction, listOfAuthors + note)
liveSet?.reactions?.invalidateData()
flowSet?.reactions?.invalidateData()
}
}
@@ -866,36 +870,8 @@ open class Note(
return false
}
var liveSet: NoteLiveSet? = null
var flowSet: NoteFlowSet? = null
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
fun live(): NoteLiveSet {
if (liveSet == null) {
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
createOrDestroyLiveSync(false)
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
@@ -970,6 +946,12 @@ class NoteFlowSet(
val metadata = NoteBundledRefresherFlow(u)
val reports = NoteBundledRefresherFlow(u)
val relays = NoteBundledRefresherFlow(u)
val reactions = NoteBundledRefresherFlow(u)
val boosts = NoteBundledRefresherFlow(u)
val replies = NoteBundledRefresherFlow(u)
val zaps = NoteBundledRefresherFlow(u)
val ots = NoteBundledRefresherFlow(u)
val edits = NoteBundledRefresherFlow(u)
@OptIn(ExperimentalCoroutinesApi::class)
fun author() =
@@ -981,31 +963,9 @@ class NoteFlowSet(
}
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
reports.stateFlow.subscriptionCount.value > 0 ||
relays.stateFlow.subscriptionCount.value > 0
fun destroy() {
metadata.destroy()
reports.destroy()
relays.destroy()
}
}
@Stable
class NoteLiveSet(
u: Note,
) {
// Observers line up here.
val metadata = NoteBundledRefresherLiveData(u)
val reactions = NoteBundledRefresherLiveData(u)
val boosts = NoteBundledRefresherLiveData(u)
val replies = NoteBundledRefresherLiveData(u)
val zaps = NoteBundledRefresherLiveData(u)
val ots = NoteBundledRefresherLiveData(u)
val edits = NoteBundledRefresherLiveData(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
metadata.hasObservers() ||
reactions.hasObservers() ||
boosts.hasObservers() ||
@@ -1016,6 +976,8 @@ class NoteLiveSet(
fun destroy() {
metadata.destroy()
reports.destroy()
relays.destroy()
reactions.destroy()
boosts.destroy()
replies.destroy()
@@ -1039,38 +1001,15 @@ class NoteBundledRefresherFlow(
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
stateFlow.emit(NoteState(note))
}
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
@Stable
class NoteBundledRefresherLiveData(
val note: Note,
) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
postValue(NoteState(note))
}
}
}
@Immutable class NoteState(
@Immutable
class NoteState(
val note: Note,
)
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.ammolite.relays.BundledUpdate
@@ -125,7 +124,7 @@ class User(
if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event
liveSet?.bookmarks?.invalidateData()
flowSet?.bookmarks?.invalidateData()
}
fun clearEOSE() {
@@ -139,7 +138,6 @@ class User(
latestContactList = event
// Update following of the current user
liveSet?.follows?.invalidateData()
flowSet?.follows?.invalidateData()
// Update Followers of the past user list
@@ -147,19 +145,18 @@ class User(
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache
.getUserIfExists(it)
?.liveSet
?.flowSet
?.followers
?.invalidateData()
}
(latestContactList)?.unverifiedFollowKeySet()?.forEach {
LocalCache
.getUserIfExists(it)
?.liveSet
?.flowSet
?.followers
?.invalidateData()
}
liveSet?.relays?.invalidateData()
flowSet?.relays?.invalidateData()
}
@@ -169,10 +166,10 @@ class User(
val reportsBy = reports[author]
if (reportsBy == null) {
reports = reports + Pair(author, setOf(note))
liveSet?.reports?.invalidateData()
flowSet?.reports?.invalidateData()
} else if (!reportsBy.contains(note)) {
reports = reports + Pair(author, reportsBy + note)
liveSet?.reports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
@@ -182,7 +179,7 @@ class User(
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.reports?.invalidateData()
flowSet?.reports?.invalidateData()
}
}
}
@@ -193,17 +190,17 @@ class User(
) {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
fun removeZap(zapRequestOrZapEvent: Note) {
if (zaps.containsKey(zapRequestOrZapEvent)) {
zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
} else if (zaps.containsValue(zapRequestOrZapEvent)) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent }
liveSet?.zaps?.invalidateData()
flowSet?.zaps?.invalidateData()
}
}
@@ -256,7 +253,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
flowSet?.messages?.invalidateData()
}
}
@@ -267,7 +264,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData()
flowSet?.messages?.invalidateData()
}
}
@@ -284,7 +281,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
flowSet?.messages?.invalidateData()
}
}
@@ -296,7 +293,7 @@ class User(
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData()
flowSet?.messages?.invalidateData()
}
}
@@ -314,7 +311,7 @@ class User(
here.counter++
}
liveSet?.relayInfo?.invalidateData()
flowSet?.relayInfo?.invalidateData()
}
fun updateUserInfo(
@@ -334,7 +331,6 @@ class User(
}
flowSet?.metadata?.invalidateData()
liveSet?.metadata?.invalidateData()
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
@@ -398,36 +394,8 @@ class User(
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
var liveSet: UserLiveSet? = null
var flowSet: UserFlowSet? = null
fun live(): UserLiveSet {
if (liveSet == null) {
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
createOrDestroyLiveSync(false)
}
}
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = UserLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
@@ -464,43 +432,21 @@ class UserFlowSet(
val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
fun isInUse(): Boolean =
metadata.stateFlow.subscriptionCount.value > 0 ||
relays.stateFlow.subscriptionCount.value > 0 ||
follows.stateFlow.subscriptionCount.value > 0
fun destroy() {
metadata.destroy()
relays.destroy()
follows.destroy()
}
}
@Stable
class UserLiveSet(
u: User,
) {
val metadata = UserBundledRefresherLiveData(u)
// UI Observers line up here.
val follows = UserBundledRefresherLiveData(u)
val followers = UserBundledRefresherLiveData(u)
val reports = UserBundledRefresherLiveData(u)
val messages = UserBundledRefresherLiveData(u)
val relays = UserBundledRefresherLiveData(u)
val relayInfo = UserBundledRefresherLiveData(u)
val zaps = UserBundledRefresherLiveData(u)
val bookmarks = UserBundledRefresherLiveData(u)
val statuses = UserBundledRefresherLiveData(u)
val followers = UserBundledRefresherFlow(u)
val reports = UserBundledRefresherFlow(u)
val messages = UserBundledRefresherFlow(u)
val relayInfo = UserBundledRefresherFlow(u)
val zaps = UserBundledRefresherFlow(u)
val bookmarks = UserBundledRefresherFlow(u)
val statuses = UserBundledRefresherFlow(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
relays.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
reports.hasObservers() ||
messages.hasObservers() ||
relays.hasObservers() ||
relayInfo.hasObservers() ||
zaps.hasObservers() ||
bookmarks.hasObservers() ||
@@ -508,11 +454,11 @@ class UserLiveSet(
fun destroy() {
metadata.destroy()
relays.destroy()
follows.destroy()
followers.destroy()
reports.destroy()
messages.destroy()
relays.destroy()
relayInfo.destroy()
zaps.destroy()
bookmarks.destroy()
@@ -527,27 +473,6 @@ data class RelayInfo(
var counter: Long,
)
class UserBundledRefresherLiveData(
val user: User,
) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
postValue(UserState(user))
}
}
}
@Stable
class UserBundledRefresherFlow(
val user: User,
@@ -569,8 +494,11 @@ class UserBundledRefresherFlow(
stateFlow.emit(UserState(user))
}
}
fun hasObservers() = stateFlow.subscriptionCount.value > 0
}
@Immutable class UserState(
@Immutable
class UserState(
val user: User,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service
import android.content.ContentProviderOperation.newCall
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.location
import android.content.Context
import coil3.util.CoilUtils.result
import com.fonfon.kgeohash.GeoHash
import com.fonfon.kgeohash.toGeoHash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
@@ -27,9 +27,7 @@ import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.provider.LiveFolders.INTENT
import android.util.Log
import androidx.core.content.ContextCompat.registerReceiver
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.coroutines.launch
@@ -30,24 +30,20 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.MutableQuery
fun <T> KeyDataSourceSubscription(
state: T,
dataSource: QueryBasedSubscriptionOrchestrator<T>,
) {
DisposableEffect(state) {
) = DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
dataSource.unsubscribe(state)
}
}
}
@Composable
fun <T : MutableQueryState> KeyDataSourceSubscription(
state: T,
dataSource: MutableQueryBasedSubscriptionOrchestrator<T>,
) {
DisposableEffect(state) {
) = DisposableEffect(state) {
dataSource.subscribe(state)
onDispose {
dataSource.unsubscribe(state)
}
}
}
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.RelayAuthenticator
import kotlin.collections.forEach
class ScreenAuthAccount(
val account: Account,
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.authCommand.model
import androidx.lifecycle.AtomicReference
import java.util.concurrent.atomic.AtomicReference
class ListWithUniqueSetCache<T, U>(
val key: (T) -> U,
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
import android.util.Log
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator
import java.util.concurrent.ConcurrentHashMap
@@ -48,10 +46,6 @@ abstract class QueryBasedSubscriptionOrchestrator<T>(
}
invalidateFilters()
if (isDebug) {
Log.d(this::class.simpleName, "Watch $query (${queries.size} queries)")
}
}
// This is called by main. Keep it really fast.
@@ -65,10 +59,6 @@ abstract class QueryBasedSubscriptionOrchestrator<T>(
if (queries.isEmpty()) {
stop()
}
if (isDebug) {
Log.d(this::class.simpleName, "Unwatch $query (${queries.size} queries)")
}
}
fun forEachSubscriber(action: (T) -> Unit) {
@@ -0,0 +1,62 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@Composable
fun observeAccountIsHiddenWord(
account: Account,
word: String,
): State<Boolean> {
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(account, word) {
account.flowHiddenUsers
.map { word in it.hiddenWords }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(false)
}
@Composable
fun observeAccountIsHiddenUser(
account: Account,
user: User,
): State<Boolean> {
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(account, user) {
account.flowHiddenUsers
.map { it.hiddenUsers.contains(user.pubkeyHex) || it.spammers.contains(user.pubkeyHex) }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(account.isHidden(user))
}
@@ -31,8 +31,6 @@ import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import kotlin.collections.filter
import kotlin.collections.mapNotNull
// This allows multiple screen to be listening to tags, even the same tag
class ChannelFinderQueryState(
@@ -22,13 +22,52 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ChannelState
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.mapLatest
@Composable
fun observeChannel(baseChannel: Channel): State<ChannelState?> {
ChannelFinderFilterAssemblerSubscription(baseChannel)
return baseChannel.live.observeAsState()
return baseChannel.flow.stateFlow.collectAsStateWithLifecycle()
}
@Composable
fun observeChannelPicture(baseChannel: Channel): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
ChannelFinderFilterAssemblerSubscription(baseChannel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(baseChannel) {
baseChannel
.flow.stateFlow
.mapLatest { it.channel.profilePicture() }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(baseChannel.profilePicture())
}
@Composable
fun observeChannelInfo(baseChannel: LiveActivitiesChannel): State<LiveActivitiesEvent?> {
// Subscribe in the relay for changes in the metadata of this user.
ChannelFinderFilterAssemblerSubscription(baseChannel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(baseChannel) {
baseChannel
.flow.stateFlow
.mapLatest { (it.channel as? LiveActivitiesChannel)?.info }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(baseChannel.info)
}
@@ -46,7 +46,6 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import kotlin.collections.mapNotNullTo
// This allows multiple screen to be listening to tags, even the same tag
class EventFinderQueryState(
@@ -22,22 +22,30 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.sample
@Composable
fun observeNote(note: Note): State<NoteState?> {
fun observeNote(note: Note): State<NoteState> {
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().metadata.observeAsState()
return note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
@@ -46,11 +54,15 @@ fun <T : Event> observeNoteEvent(note: Note): State<T?> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { it.note.event as? T? }
.observeAsState(note.event as? T?)
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { it.note.event as? T? }
}
return flow.collectAsStateWithLifecycle(note.event as? T?)
}
@Composable
@@ -61,13 +73,18 @@ fun <T> observeNoteAndMap(
// Subscribe in the relay for changes in this note.
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { map(it.note) }
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { map(it.note) }
.distinctUntilChanged()
.observeAsState(map(note))
.flowOn(Dispatchers.Default)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(map(note))
}
@Composable
@@ -79,14 +96,18 @@ fun <T, U> observeNoteEventAndMap(
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { (it.note.event as? T)?.let { map(it) } }
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { (it.note.event as? T)?.let { map(it) } }
.distinctUntilChanged()
.observeAsState(
(note.event as? T)?.let { map(it) },
)
.flowOn(Dispatchers.Default)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle((note.event as? T)?.let { map(it) })
}
@Composable
@@ -95,12 +116,16 @@ fun observeNoteHasEvent(note: Note): State<Boolean> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.metadata
.map { it.note.event != null }
val flow =
remember(note) {
note
.flow()
.metadata.stateFlow
.mapLatest { it.note.event != null }
.distinctUntilChanged()
.observeAsState(note.event != null)
}
return flow.collectAsStateWithLifecycle(note.event != null)
}
@Composable
@@ -109,7 +134,10 @@ fun observeNoteReplies(note: Note): State<NoteState?> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().replies.observeAsState()
return note
.flow()
.replies.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
@@ -118,12 +146,17 @@ fun observeNoteReplyCount(note: Note): State<Int> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.replies
.map { it.note.reactions.size }
val flow =
remember(note) {
note
.flow()
.reactions.stateFlow
.mapLatest { it.note.replies.size }
.sample(1000)
.distinctUntilChanged()
.observeAsState(note.reactions.size)
}
return flow.collectAsStateWithLifecycle(note.replies.size)
}
@Composable
@@ -132,7 +165,10 @@ fun observeNoteReactions(note: Note): State<NoteState?> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().reactions.observeAsState()
return note
.flow()
.reactions.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
@@ -141,15 +177,19 @@ fun observeNoteReactionCount(note: Note): State<Int> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.reactions
.map {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}.distinctUntilChanged()
.observeAsState(0)
val flow =
remember(note) {
note
.flow()
.reactions.stateFlow
.mapLatest { it.note.countReactions() }
.sample(1000)
.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(note.countReactions())
}
@Composable
@@ -158,7 +198,10 @@ fun observeNoteZaps(note: Note): State<NoteState?> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().zaps.observeAsState()
return note
.flow()
.zaps.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
@@ -167,7 +210,10 @@ fun observeNoteReposts(note: Note): State<NoteState?> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().boosts.observeAsState()
return note
.flow()
.boosts.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
@@ -179,12 +225,17 @@ fun observeNoteRepostsBy(
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.boosts
.map { it.note.isBoostedBy(user) }
val flow =
remember(note) {
note
.flow()
.boosts.stateFlow
.mapLatest { it.note.isBoostedBy(user) }
.distinctUntilChanged()
.observeAsState(note.isBoostedBy(user))
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(note.isBoostedBy(user))
}
@Composable
@@ -193,12 +244,17 @@ fun observeNoteRepostCount(note: Note): State<Int> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.boosts
.map { it.note.boosts.size }
val flow =
remember(note) {
note
.flow()
.boosts.stateFlow
.sample(1000)
.mapLatest { note.boosts.size }
.distinctUntilChanged()
.observeAsState(note.boosts.size)
}
return flow.collectAsStateWithLifecycle(note.boosts.size)
}
@Composable
@@ -207,17 +263,18 @@ fun observeNoteReferences(note: Note): State<Boolean> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note
.live()
.zaps
.combineWith(note.live().boosts, note.live().reactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty() == true ||
boostState?.note?.boosts?.isNotEmpty() == true ||
reactionState?.note?.reactions?.isNotEmpty() == true
val flow =
remember(note) {
combine(
note.flow().zaps.stateFlow,
note.flow().boosts.stateFlow,
note.flow().reactions.stateFlow,
) { zapState, boostState, reactionState ->
zapState.note.hasZapsBoostsOrReactions()
}.distinctUntilChanged()
.observeAsState(
note.zaps.isNotEmpty() || note.boosts.isNotEmpty() || note.reactions.isNotEmpty(),
)
}
return flow.collectAsStateWithLifecycle(note.hasZapsBoostsOrReactions())
}
@Composable
@@ -226,7 +283,11 @@ fun observeNoteOts(note: Note): State<NoteState?> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().ots.observeAsState()
return note
.flow()
.ots
.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
@@ -235,5 +296,9 @@ fun observeNoteEdits(note: Note): State<NoteState?> {
EventFinderFilterAssemblerSubscription(note)
// Subscribe in the LocalCache for changes that arrive in the device
return note.live().edits.observeAsState()
return note
.flow()
.edits
.stateFlow
.collectAsStateWithLifecycle()
}
@@ -22,12 +22,26 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.sample
import java.math.BigDecimal
@Composable
fun observeUser(user: User): State<UserState?> {
@@ -35,7 +49,10 @@ fun observeUser(user: User): State<UserState?> {
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user.live().metadata.observeAsState()
return user
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
@@ -43,13 +60,17 @@ fun observeUserName(user: User): State<String> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.toBestDisplayName() }
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.toBestDisplayName() }
.distinctUntilChanged()
.observeAsState(user.toBestDisplayName())
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(user.toBestDisplayName())
}
@Composable
@@ -57,13 +78,17 @@ fun observeUserNip05(user: User): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.nip05 }
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.nip05 }
.distinctUntilChanged()
.observeAsState(user.info?.nip05)
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(user.info?.nip05)
}
@Composable
@@ -72,12 +97,16 @@ fun observeUserAboutMe(user: User): State<String> {
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.about ?: "" }
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.about ?: "" }
.distinctUntilChanged()
.observeAsState(user.info?.about ?: "")
}
return flow.collectAsStateWithLifecycle(user.info?.about ?: "")
}
@Composable
@@ -86,26 +115,34 @@ fun observeUserInfo(user: User): State<UserMetadata?> {
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info }
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info }
.distinctUntilChanged()
.observeAsState(user.info)
}
return flow.collectAsStateWithLifecycle(user.info)
}
@Composable
fun observeUserBanner(user: User): State<String> {
fun observeUserBanner(user: User): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.banner ?: "" }
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.banner }
.distinctUntilChanged()
.observeAsState(user.info?.banner ?: "")
}
return flow.collectAsStateWithLifecycle(user.info?.banner)
}
@Composable
@@ -114,12 +151,16 @@ fun observeUserPicture(user: User): State<String?> {
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.info?.picture }
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.picture }
.distinctUntilChanged()
.observeAsState(user.info?.picture)
}
return flow.collectAsStateWithLifecycle(user.info?.picture)
}
@Composable
@@ -128,10 +169,400 @@ fun observeUserShortName(user: User): State<String> {
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.live()
.metadata
.map { it.user.toBestShortFirstName() }
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.toBestShortFirstName() }
.distinctUntilChanged()
.observeAsState(user.toBestShortFirstName())
}
return flow.collectAsStateWithLifecycle(user.toBestShortFirstName())
}
@Composable
fun observeUserFollows(user: User): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.follows.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeUserFollowCount(user: User): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.followers.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.transientFollowCount() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(0)
}
@Composable
fun observeUserTagFollows(user: User): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.latestContactList?.countFollowTags() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(0)
}
@Composable
fun observeUserBookmarks(user: User): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.bookmarks.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeUserBookmarkCount(user: User): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.followers.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.latestBookmarkList?.countBookmarks() ?: 0
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(0)
}
@Composable
fun observeUserFollowers(user: User): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.followers.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeUserFollowerCount(user: User): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.followers.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.transientFollowerCount()
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(0)
}
@Composable
fun observeUserIsFollowing(
user1: User,
user2: User,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user1)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user1) {
user1
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.isFollowing(user2)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user1.isFollowing(user2))
}
@Composable
fun observeUserIsFollowingHashtag(
user: User,
hashtag: String,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.isFollowingHashtag(hashtag)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user.isFollowingHashtag(hashtag))
}
@Composable
fun observeUserIsFollowingGeohash(
user: User,
geohash: String,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.isFollowingGeohash(geohash)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user.isFollowingGeohash(geohash))
}
@Composable
fun observeUserIsFollowingChannel(
user: User,
channel: Channel,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user.latestContactList?.isTaggedEvent(channel.idHex) ?: false)
}
@Composable
fun observeUserZaps(user: User): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.zaps.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeUserZapAmount(user: User): State<BigDecimal> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.zaps.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.zappedAmount()
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(BigDecimal.ZERO)
}
@Composable
fun observeUserReports(user: User): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.reports.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun observeUserReportCount(user: User): State<Int> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.reports
.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.reports.values
.sumOf { it.size }
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(0)
}
@Composable
fun observeUserStatuses(user: User): State<ImmutableList<AddressableNote>> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.statuses
.stateFlow
.sample(1000)
.mapLatest { userState ->
LocalCache.findStatusesForUser(userState.user)
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(persistentListOf())
}
@Composable
fun observeUserRelayIntoList(
user: User,
relayUrl: String,
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.relayInfo
.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.latestContactList
?.relays()
?.none { it.key == relayUrl } == true
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(false)
}
@Composable
fun observeUserRoomSubject(
user: User,
room: ChatroomKey,
): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.messages
.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.privateChatrooms[room]?.subject
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user.privateChatrooms[room]?.subject)
}
data class RelayUsage(
val relays: List<String> = emptyList(),
val userRelayList: List<String> = emptyList(),
)
@Composable
fun observeUserRelaysUsing(user: User): State<RelayUsage> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
combine(user.flow().relays.stateFlow, user.flow().relayInfo.stateFlow) { relays, relayInfo ->
val userRelaysBeingUsed = relays.user.relaysBeingUsed.map { it.key }
val currentUserRelays =
relayInfo.user.latestContactList
?.relays()
?.map { RelayUrlFormatter.normalize(it.key) } ?: emptyList()
RelayUsage(userRelaysBeingUsed, currentUserRelays)
}.sample(1000)
.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(RelayUsage())
}
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand
import android.util.Log
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.datasources.SubscriptionOrchestrator
import kotlinx.coroutines.CoroutineScope
@@ -64,10 +62,6 @@ abstract class MutableQueryBasedSubscriptionOrchestrator<T : MutableQueryState>(
}
invalidateFilters()
if (isDebug) {
Log.d(this::class.simpleName, "Watch $query (${queries.size} queries)")
}
}
// This is called by main. Keep it really fast.
@@ -82,10 +76,6 @@ abstract class MutableQueryBasedSubscriptionOrchestrator<T : MutableQueryState>(
if (queries.isEmpty()) {
stop()
}
if (isDebug) {
Log.d(this::class.simpleName, "Unwatch $query (${queries.size} queries)")
}
}
fun forEachSubscriber(action: (T) -> Unit) {
@@ -67,7 +67,6 @@ import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.collections.flatten
@Stable
class SearchQueryState(
@@ -32,6 +32,7 @@ import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk.PICTURES_SUBDIRECTORY
import kotlinx.coroutines.CancellationException
import okhttp3.Call
import okhttp3.Callback
@@ -26,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil3.util.CoilUtils.result
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.R.attr.maxLines
import android.R.attr.onClick
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
@@ -50,7 +50,6 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextFieldDefaults.contentPadding
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -29,7 +29,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
@@ -30,6 +30,7 @@ import androidx.compose.runtime.remember
import androidx.window.core.layout.WindowHeightSizeClass
import androidx.window.core.layout.WindowSizeClass
import androidx.window.core.layout.WindowWidthSizeClass
import com.vitorpamplona.amethyst.ui.components.util.DeviceUtils.screenOrientationIsLocked
object DeviceUtils {
/**
@@ -22,22 +22,9 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.Card
import com.vitorpamplona.quartz.nip01Core.core.Event
val DefaultFeedOrder: Comparator<Note> =
compareByDescending<Note>
{
val noteEvent = it.event
if (noteEvent == null) {
null
} else {
if (noteEvent is Event) {
noteEvent.createdAt
} else {
null
}
}
}.thenBy { it.idHex }
compareByDescending<Note> { it.event?.createdAt }.thenBy { it.idHex }
val DefaultFeedOrderCard: Comparator<Card> =
compareByDescending<Card> { it.createdAt() }.thenBy { it.id() }
@@ -58,7 +58,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -91,6 +90,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.mediaServers.MediaServersListView
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -381,18 +381,12 @@ private fun FollowingAndFollowerCounts(
baseAccountUser: Account,
onClick: () -> Unit,
) {
val followingCount = baseAccountUser.liveKind3Follows.collectAsStateWithLifecycle()
var followerCount by remember { mutableStateOf("--") }
WatchFollower(baseAccountUser = baseAccountUser) { newFollower ->
if (followerCount != newFollower) {
followerCount = newFollower
}
}
Row(
modifier = drawerSpacing.clickable(onClick = onClick),
) {
val followingCount = baseAccountUser.liveKind3Follows.collectAsStateWithLifecycle()
val followerCount by observeUserFollowerCount(baseAccountUser.userProfile())
Text(
text =
followingCount.value.authors.size
@@ -405,7 +399,7 @@ private fun FollowingAndFollowerCounts(
Spacer(modifier = DoubleHorzSpacer)
Text(
text = followerCount,
text = if (followerCount > 0) followerCount.toString() else "--",
fontWeight = FontWeight.Bold,
)
@@ -413,22 +407,6 @@ private fun FollowingAndFollowerCounts(
}
}
@Composable
fun WatchFollower(
baseAccountUser: Account,
onReady: (String) -> Unit,
) {
val accountUserFollowersState by baseAccountUser
.userProfile()
.live()
.followers
.observeAsState()
LaunchedEffect(key1 = accountUserFollowersState) {
onReady(baseAccountUser.followerCount().toString())
}
}
@Composable
fun ListContent(
modifier: Modifier,
@@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size23dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.serialization.Serializable
import kotlin.String
class BottomBarRoute(
val route: Route,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note
import android.R.attr.onClick
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,7 +27,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -40,8 +40,6 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.AddReaction
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.PlayCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -33,12 +32,11 @@ import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteOts
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -139,19 +137,9 @@ fun LoadStatuses(
accountViewModel: AccountViewModel,
content: @Composable (ImmutableList<AddressableNote>) -> Unit,
) {
var statuses: ImmutableList<AddressableNote> by remember { mutableStateOf(persistentListOf()) }
val userStatuses by observeUserStatuses(user)
val userStatus by user.live().statuses.observeAsState()
LaunchedEffect(key1 = userStatus) {
accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses ->
if (!equalImmutableLists(statuses, newStatuses)) {
statuses = newStatuses
}
}
}
content(statuses)
content(userStatuses)
}
@Composable
@@ -54,11 +54,11 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNip05
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon
import com.vitorpamplona.amethyst.ui.note.NIP05FailedVerification
import com.vitorpamplona.amethyst.ui.note.NIP05VerifiedIcon
@@ -136,12 +136,11 @@ fun ObserveDisplayNip05Status(
nav: INav,
) {
val nip05 by observeUserNip05(baseUser)
val statuses by observeUserStatuses(baseUser)
LoadStatuses(baseUser, accountViewModel) { statuses ->
CrossfadeIfEnabled(
targetState = nip05,
modifier = columnModifier,
label = "ObserveDisplayNip05StatusCrossfade",
accountViewModel = accountViewModel,
) {
VerifyAndDisplayNIP05OrStatusLine(
@@ -153,7 +152,6 @@ fun ObserveDisplayNip05Status(
nav,
)
}
}
}
@Composable
@@ -38,7 +38,6 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -50,9 +49,6 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync
import com.vitorpamplona.amethyst.logTime
@@ -60,7 +56,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -137,7 +133,6 @@ import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -1195,15 +1190,8 @@ private fun ChannelNotePicture(
loadProfilePicture: Boolean,
loadRobohash: Boolean,
) {
ChannelFinderFilterAssemblerSubscription(baseChannel, Amethyst.instance.sources.channelFinder)
val model by observeChannelPicture(baseChannel)
val model by
baseChannel.live
.map { it.channel.profilePicture() }
.distinctUntilChanged()
.observeAsState()
Box(Size30Modifier) {
RobohashFallbackAsyncImage(
robot = baseChannel.idHex,
model = model,
@@ -1212,7 +1200,6 @@ private fun ChannelNotePicture(
loadProfilePicture = loadProfilePicture,
loadRobohash = loadRobohash,
)
}
}
@Composable
@@ -93,8 +93,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
@@ -405,28 +403,6 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists(
}
}
fun <T, K, R> LiveData<T>.combineWith(
liveData1: LiveData<K>,
block: (T?, K?) -> R,
): LiveData<R> {
val result = MediatorLiveData<R>()
result.addSource(this) { result.value = block(this.value, liveData1.value) }
result.addSource(liveData1) { result.value = block(this.value, liveData1.value) }
return result
}
fun <T, K, P, R> LiveData<T>.combineWith(
liveData1: LiveData<K>,
liveData2: LiveData<P>,
block: (T?, K?, P?) -> R,
): LiveData<R> {
val result = MediatorLiveData<R>()
result.addSource(this) { result.value = block(this.value, liveData1.value, liveData2.value) }
result.addSource(liveData1) { result.value = block(this.value, liveData1.value, liveData2.value) }
result.addSource(liveData2) { result.value = block(this.value, liveData1.value, liveData2.value) }
return result
}
@Composable
private fun RenderShowIndividualReactionsButton(
wantsToSeeReactions: MutableState<Boolean>,
@@ -223,12 +223,10 @@ class UpdateZapAmountViewModel : ViewModel() {
fun updateNIP47(uri: String) {
val contact = Nip47WalletConnect.parse(uri)
if (contact != null) {
walletConnectPubkey = TextFieldValue(contact.pubKeyHex)
walletConnectRelay = TextFieldValue(contact.relayUri ?: "")
walletConnectRelay = TextFieldValue(contact.relayUri)
walletConnectSecret = TextFieldValue(contact.secret ?: "")
}
}
}
@Composable
@@ -30,7 +30,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -43,15 +42,16 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserAboutMe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.WatchIsHiddenUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapReqResponse
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -161,13 +161,12 @@ fun UserActionOptions(
baseAuthor: User,
accountViewModel: AccountViewModel,
) {
WatchIsHiddenUser(baseAuthor, accountViewModel) { isHidden ->
val isHidden by observeAccountIsHiddenUser(accountViewModel.account, baseAuthor)
if (isHidden) {
ShowUserButton { accountViewModel.show(baseAuthor) }
} else {
ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel)
}
}
}
@Composable
@@ -175,24 +174,9 @@ fun ShowFollowingOrUnfollowingButton(
baseAuthor: User,
accountViewModel: AccountViewModel,
) {
var isFollowing by remember { mutableStateOf(false) }
val accountFollowsState by accountViewModel.account
.userProfile()
.live()
.follows
.observeAsState()
var isFollowing = observeUserIsFollowing(accountViewModel.account.userProfile(), baseAuthor)
LaunchedEffect(key1 = accountFollowsState) {
launch(Dispatchers.Default) {
val newShowFollowingMark = accountFollowsState?.user?.isFollowing(baseAuthor) == true
if (newShowFollowingMark != isFollowing) {
isFollowing = newShowFollowingMark
}
}
}
if (isFollowing) {
if (isFollowing.value) {
UnfollowButton {
if (!accountViewModel.isWriteable()) {
accountViewModel.toastManager.toast(
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
@@ -29,7 +29,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ShowChart
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ShowChart
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -23,12 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.zapsplits
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.collections.all
import kotlin.collections.getOrNull
import kotlin.collections.lastIndex
import kotlin.compareTo
import kotlin.math.abs
import kotlin.text.toDouble
class SplitBuilder<T> {
var items: List<SplitItem<T>> by mutableStateOf(emptyList())
@@ -50,7 +50,7 @@ fun DefaultImageHeader(
) {
WatchAuthor(baseNote = note) {
Box {
BannerImage(it)
BannerImage(it, Modifier.fillMaxWidth().heightIn(max = 200.dp))
Box(authorNotePictureForImageHeader.align(Alignment.BottomStart)) {
BaseUserPicture(it, Size55dp, accountViewModel, Modifier)
@@ -62,11 +62,19 @@ fun DefaultImageHeader(
@Composable
fun BannerImage(
author: User,
imageModifier: Modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp),
modifier: Modifier = Modifier,
) {
val banner by observeUserBanner(author)
if (banner.isNotBlank()) {
BannerImage(banner, modifier)
}
@Composable
fun BannerImage(
banner: String?,
modifier: Modifier = Modifier,
) {
if (!banner.isNullOrBlank()) {
AsyncImage(
model = banner,
contentDescription =
@@ -75,7 +83,7 @@ fun BannerImage(
banner,
),
contentScale = ContentScale.Crop,
modifier = imageModifier,
modifier = modifier,
placeholder = painterResource(R.drawable.profile_banner),
)
} else {
@@ -83,7 +91,7 @@ fun BannerImage(
painter = painterResource(R.drawable.profile_banner),
contentDescription = stringRes(R.string.profile_banner),
contentScale = ContentScale.Crop,
modifier = imageModifier,
modifier = modifier,
)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import android.R.attr.maxLines
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.LocalTextStyle
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.elements
import android.R.attr.maxLines
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,7 +30,6 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
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
@@ -42,6 +41,8 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarks
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -59,6 +60,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun MoreOptionsButton(
@@ -380,22 +382,12 @@ fun WatchBookmarksFollowsAndAccount(
accountViewModel: AccountViewModel,
onNew: (DropDownParams) -> Unit,
) {
val followState by accountViewModel
.userProfile()
.live()
.follows
.observeAsState()
val bookmarkState by accountViewModel
.userProfile()
.live()
.bookmarks
.observeAsState()
val showSensitiveContent by accountViewModel
.showSensitiveContent()
.collectAsStateWithLifecycle()
val followState by observeUserFollows(accountViewModel.userProfile())
val bookmarkState by observeUserBookmarks(accountViewModel.userProfile())
val showSensitiveContent by accountViewModel.showSensitiveContent().collectAsStateWithLifecycle()
LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = showSensitiveContent) {
launch(Dispatchers.IO) {
withContext(Dispatchers.IO) {
accountViewModel.isInPrivateBookmarks(note) {
val newState =
DropDownParams(
@@ -212,9 +212,7 @@ fun LongCommunityHeader(
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (
newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)
) {
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
@@ -30,9 +30,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -45,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelayIntoList
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
@@ -279,21 +278,7 @@ private fun RelayOptionsAction(
accountViewModel: AccountViewModel,
nav: INav,
) {
val userStateRelayInfo by accountViewModel.account
.userProfile()
.live()
.relayInfo
.observeAsState()
val isCurrentlyOnTheUsersList by
remember(userStateRelayInfo) {
derivedStateOf {
userStateRelayInfo
?.user
?.latestContactList
?.relays()
?.none { it.key == relay } == true
}
}
val isCurrentlyOnTheUsersList by observeUserRelayIntoList(accountViewModel.userProfile(), relay)
if (isCurrentlyOnTheUsersList) {
AddRelayButton {
@@ -22,8 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
@@ -33,10 +33,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -67,7 +67,7 @@ fun BookmarkListScreen(
factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account),
)
val userState by accountViewModel.account.decryptBookmarks.observeAsState()
val userState by accountViewModel.account.decryptBookmarks.collectAsStateWithLifecycle(null)
LaunchedEffect(userState) {
publicFeedViewModel.invalidateData()
@@ -60,7 +60,6 @@ class ChatroomFilterAssembler(
)
fun createMessagesFromMeFilter(key: ChatroomQueryState): TypedFilter? =
if (key.room != null) {
TypedFilter(
types = setOf(FeedType.PRIVATE_DMS),
filter =
@@ -75,9 +74,6 @@ class ChatroomFilterAssembler(
?.relayList,
),
)
} else {
null
}
fun clearEOSEs(account: Account) {
latestEOSEs.removeDataFor(account.userProfile())
@@ -24,14 +24,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRoomSubject
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserShortName
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -49,14 +47,7 @@ fun RoomNameOnlyDisplay(
fontWeight: FontWeight = FontWeight.Bold,
accountViewModel: AccountViewModel,
) {
val roomSubject by
accountViewModel
.userProfile()
.live()
.messages
.map { it.user.privateChatrooms[room]?.subject }
.distinctUntilChanged()
.observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
val roomSubject by observeUserRoomSubject(accountViewModel.userProfile(), room)
CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) {
if (!it.isNullOrBlank()) {
@@ -107,16 +98,9 @@ fun RoomNameDisplay(
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val roomSubject by
accountViewModel
.userProfile()
.live()
.messages
.map { it.user.privateChatrooms[room]?.subject }
.distinctUntilChanged()
.observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
val roomSubject by observeUserRoomSubject(accountViewModel.userProfile(), room)
CrossfadeIfEnabled(targetState = roomSubject, modifier, label = "RoomNameDisplay", accountViewModel = accountViewModel) {
CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) {
if (!it.isNullOrBlank()) {
if (room.users.size > 1) {
DisplayRoomSubject(it)
@@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -21,8 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import android.content.Context
import androidx.core.app.PendingIntentCompat.send
import coil3.util.CoilUtils.result
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
@@ -32,7 +32,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -41,12 +40,11 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -67,7 +65,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent
@Composable
fun LongPublicChatChannelHeader(
@@ -181,44 +178,42 @@ fun LongChannelActionOptions(
accountViewModel: AccountViewModel,
nav: INav,
) {
val isMe by
remember(accountViewModel) {
derivedStateOf { channel.creator == accountViewModel.account.userProfile() }
}
OpenChatButton(channel, accountViewModel, nav)
LinkChatButton(channel, accountViewModel, nav)
ShareChatButton(channel, accountViewModel, nav)
if (isMe) {
EditButton(channel, accountViewModel, nav)
EditButtonIfIamCreator(channel, accountViewModel, nav)
LeaveButtonIfFollowing(channel, accountViewModel, nav)
}
@Composable
fun EditButtonIfIamCreator(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isMe by
remember(accountViewModel) {
derivedStateOf { channel.creator == accountViewModel.account.userProfile() }
}
WatchChannelFollows(channel, accountViewModel) { isFollowing ->
if (isFollowing) {
LeaveChatButton(channel, accountViewModel, nav)
}
if (isMe) {
EditButton(channel, accountViewModel, nav)
}
}
@Composable
fun WatchChannelFollows(
fun LeaveButtonIfFollowing(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
content: @Composable (Boolean) -> Unit,
nav: INav,
) {
val isFollowing by
accountViewModel
.userProfile()
.live()
.follows
.map { it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false }
.distinctUntilChanged()
.observeAsState(
accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false,
)
val isFollowing by observeUserIsFollowingChannel(accountViewModel.userProfile(), channel)
content(isFollowing)
if (isFollowing) {
LeaveChatButton(channel, accountViewModel, nav)
}
}
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.popBack
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -129,9 +130,18 @@ fun ShortChannelActionOptions(
}
}
WatchChannelFollows(channel, accountViewModel) { isFollowing ->
JoinChatButtonIfNotAlreadyJoined(channel, accountViewModel, nav)
}
@Composable
fun JoinChatButtonIfNotAlreadyJoined(
channel: PublicChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isFollowing by observeUserIsFollowingChannel(accountViewModel.userProfile(), channel)
if (!isFollowing) {
JoinChatButton(channel, accountViewModel, nav)
}
}
}
@@ -27,13 +27,10 @@ import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
@@ -28,8 +28,6 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@@ -51,9 +51,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.collections.isNotEmpty
import kotlin.collections.map
import kotlin.collections.plus
import kotlin.coroutines.cancellation.CancellationException
class ChannelMetadataViewModel : ViewModel() {
@@ -24,15 +24,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
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.Alignment
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelInfo
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -45,47 +42,48 @@ fun ShowVideoStreaming(
accountViewModel: AccountViewModel,
) {
baseChannel.info?.let {
SensitivityWarning(
event = it,
accountViewModel = accountViewModel,
) {
ChannelFinderFilterAssemblerSubscription(baseChannel, accountViewModel.dataSources().channelFinder)
val streamingInfoEvent by
baseChannel.live
.map {
(it.channel as? LiveActivitiesChannel)?.info
}.distinctUntilChanged()
.observeAsState(baseChannel.info)
val streamingInfoEvent by observeChannelInfo(baseChannel)
streamingInfoEvent?.let { event ->
event.streaming()?.let { url ->
CrossfadeCheckIfVideoIsOnline(url, accountViewModel) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = StreamingHeaderModifier,
) {
val zoomableUrlVideo =
remember(streamingInfoEvent) {
MediaUrlVideo(
url = url,
description = baseChannel.toBestDisplayName(),
description = event.title() ?: baseChannel.toBestDisplayName(),
artworkUri = event.image(),
authorName = baseChannel.creatorName(),
uri = baseChannel.toNAddr(),
)
}
SensitivityWarning(
event = event,
accountViewModel = accountViewModel,
) {
RenderStreaming(zoomableUrlVideo, accountViewModel)
}
}
}
}
}
@Composable
private fun RenderStreaming(
media: MediaUrlVideo,
accountViewModel: AccountViewModel,
) {
CrossfadeCheckIfVideoIsOnline(media.url, accountViewModel) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = StreamingHeaderModifier,
) {
ZoomableContentView(
content = zoomableUrlVideo,
content = media,
roundedCorner = false,
contentScale = ContentScale.FillWidth,
accountViewModel = accountViewModel,
)
}
}
}
}
}
}
}
@@ -48,7 +48,6 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.Route.NewGroupDM
@@ -71,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import kotlin.collections.get
class ChatroomListKnownFeedFilter(
val account: Account,
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import kotlin.collections.get
class ChatroomListNewFeedFilter(
val account: Account,
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane
import androidx.compose.material3.DrawerState
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.ui.navigation.EmptyNav.nav
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import kotlinx.coroutines.CoroutineScope
@@ -28,7 +28,6 @@ import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import kotlin.collections.ifEmpty
// This allows multiple screen to be listening to tags, even the same tag
class CommunityQueryState(
@@ -43,7 +43,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.collections.flatten
// This allows multiple screen to be listening to tags, even the same tag
class DiscoveryQueryState(
@@ -24,14 +24,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -123,15 +121,7 @@ fun GeoHashActionOptions(
tag: String,
accountViewModel: AccountViewModel,
) {
val userState by accountViewModel
.userProfile()
.live()
.follows
.observeAsState()
val isFollowingTag by
remember(userState, tag) {
derivedStateOf { userState?.user?.isFollowingGeohash(tag) ?: false }
}
val isFollowingTag by observeUserIsFollowingGeohash(accountViewModel.userProfile(), tag)
if (isFollowingTag) {
UnfollowButton {
@@ -28,15 +28,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingHashtag
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -140,15 +138,7 @@ fun HashtagActionOptions(
tag: String,
accountViewModel: AccountViewModel,
) {
val userState by accountViewModel
.userProfile()
.live()
.follows
.observeAsState()
val isFollowingTag by
remember(userState, tag) {
derivedStateOf { userState?.user?.isFollowingHashtag(tag) ?: false }
}
val isFollowingTag by observeUserIsFollowingHashtag(accountViewModel.userProfile(), tag)
if (isFollowingTag) {
UnfollowButton {
@@ -37,7 +37,6 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import kotlin.collections.flatten
// This allows multiple screen to be listening to tags, even the same tag
class HashtagQueryState(
@@ -52,7 +52,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.collections.flatten
// This allows multiple screen to be listening to tags, even the same tag
class HomeQueryState(
@@ -43,7 +43,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -58,11 +57,11 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -400,7 +399,10 @@ private fun RenderScreen(
modifier = tabRowModifier,
divider = { HorizontalDivider(thickness = DividerThickness) },
) {
CreateAndRenderTabs(baseUser, pagerState)
CreateAndRenderTabs(
baseUser,
pagerState,
)
}
HorizontalPager(
state = pagerState,
@@ -470,11 +472,7 @@ fun UpdateThreadsAndRepliesWhenBlockUnblock(
repliesViewModel: UserProfileConversationsFeedViewModel,
accountViewModel: AccountViewModel,
) {
val isHidden by
accountViewModel.account.liveHiddenUsers
.map {
it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex)
}.observeAsState(accountViewModel.account.isHidden(baseUser))
val isHidden by observeAccountIsHiddenUser(accountViewModel.account, baseUser)
LaunchedEffect(key1 = isHidden) {
threadsViewModel.invalidateData()
@@ -22,33 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.bookmarks
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarkCount
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun BookmarkTabHeader(baseUser: User) {
val userState by baseUser.live().bookmarks.observeAsState()
var userBookmarks by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val newBookmarks = userState?.user?.latestBookmarkList?.countBookmarks() ?: 0
if (newBookmarks != userBookmarks) {
userBookmarks = newBookmarks
}
}
}
val userBookmarks by observeUserBookmarkCount(baseUser)
Text(text = "$userBookmarks ${stringRes(R.string.bookmarks)}")
}
@@ -52,7 +52,6 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
import kotlin.collections.flatten
// This allows multiple screen to be listening to tags, even the same tag
class UserProfileQueryState(
@@ -22,34 +22,22 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.followers
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun FollowersTabHeader(baseUser: User) {
val userState by baseUser.live().followers.observeAsState()
var followerCount by remember { mutableStateOf("--") }
val followerCount by observeUserFollowerCount(baseUser)
val text = stringRes(R.string.followers)
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val newFollower = (userState?.user?.transientFollowerCount()?.toString() ?: "--") + " " + text
if (followerCount != newFollower) {
followerCount = newFollower
}
}
val text =
if (followerCount > 0) {
stringRes(R.string.number_followers, followerCount)
} else {
stringRes(R.string.number_followers, "--")
}
Text(text = followerCount)
Text(text = text)
}
@@ -25,9 +25,9 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowers
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
@@ -52,7 +52,7 @@ private fun WatchFollowerChanges(
baseUser: User,
feedViewModel: UserFeedViewModel,
) {
val userState by baseUser.live().followers.observeAsState()
val userState by observeUserFollowers(baseUser)
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@@ -22,34 +22,22 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.follows
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowCount
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun FollowTabHeader(baseUser: User) {
val userState by baseUser.live().follows.observeAsState()
var followCount by remember { mutableStateOf("--") }
val followCount by observeUserFollowCount(baseUser)
val text = stringRes(R.string.follows)
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val newFollow = (userState?.user?.transientFollowCount()?.toString() ?: "--") + " " + text
if (followCount != newFollow) {
followCount = newFollow
}
}
val text =
if (followCount > 0) {
stringRes(R.string.number_following, followCount)
} else {
stringRes(R.string.number_following, "--")
}
Text(text = followCount)
Text(text = text)
}
@@ -25,9 +25,9 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
@@ -52,7 +52,7 @@ private fun WatchFollowChanges(
baseUser: User,
feedViewModel: UserFeedViewModel,
) {
val userState by baseUser.live().follows.observeAsState()
val userState by observeUserFollows(baseUser)
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@@ -33,7 +33,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
@@ -22,23 +22,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollows
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun FollowedTagsTabHeader(baseUser: User) {
val userState by baseUser.live().follows.observeAsState()
val usertags by remember(baseUser) {
derivedStateOf {
userState?.user?.latestContactList?.countFollowTags() ?: 0
}
}
val usertags by observeUserTagFollows(baseUser)
Text(text = "$usertags ${stringRes(R.string.followed_tags)}")
}
@@ -22,11 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton
@@ -36,22 +34,8 @@ fun DisplayFollowUnfollowButton(
baseUser: User,
accountViewModel: AccountViewModel,
) {
val isLoggedInFollowingUser by
accountViewModel.account
.userProfile()
.live()
.follows
.map { it.user.isFollowing(baseUser) }
.distinctUntilChanged()
.observeAsState(initial = accountViewModel.account.isFollowing(baseUser))
val isUserFollowingLoggedIn by
baseUser
.live()
.follows
.map { it.user.isFollowing(accountViewModel.account.userProfile()) }
.distinctUntilChanged()
.observeAsState(initial = baseUser.isFollowing(accountViewModel.account.userProfile()))
val isLoggedInFollowingUser by observeUserIsFollowing(accountViewModel.account.userProfile(), baseUser)
val isUserFollowingLoggedIn by observeUserIsFollowing(baseUser, accountViewModel.account.userProfile())
if (isLoggedInFollowingUser) {
UnfollowButton {
@@ -53,10 +53,19 @@ fun DrawBanner(
) {
val banner by observeUserBanner(baseUser)
DrawBanner(banner, accountViewModel)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DrawBanner(
banner: String?,
accountViewModel: AccountViewModel,
) {
if (!banner.isNullOrBlank()) {
val clipboardManager = LocalClipboardManager.current
var zoomImageDialogOpen by remember { mutableStateOf(false) }
if (banner.isNotBlank()) {
AsyncImage(
model = banner,
contentDescription = stringRes(id = R.string.profile_image),
@@ -25,10 +25,10 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenUser
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ShowUserButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.WatchIsHiddenUser
@Composable
fun ProfileActions(
@@ -43,11 +43,11 @@ fun ProfileActions(
EditButton(nav)
}
WatchIsHiddenUser(baseUser, accountViewModel) { isHidden ->
val isHidden by observeAccountIsHiddenUser(accountViewModel.account, baseUser)
if (isHidden) {
ShowUserButton { accountViewModel.showUser(baseUser.pubkeyHex) }
} else {
DisplayFollowUnfollowButton(baseUser, accountViewModel)
}
}
}
@@ -28,13 +28,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.nip02FollowList.ReadWrite
import com.vitorpamplona.quartz.nip65RelayList.RelayUrlFormatter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -51,55 +54,72 @@ class RelayFeedViewModel :
val feedContent = _feedContent.asStateFlow()
var currentUser: User? = null
var currentJob: Job? = null
override val isRefreshing: MutableState<Boolean> = mutableStateOf(false)
fun refresh() {
viewModelScope.launch(Dispatchers.Default) { refreshSuspended() }
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
fun refreshSuspended() {
try {
isRefreshing.value = true
val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList()
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
val newRelaysFromRecord =
currentUser?.latestContactList?.relays()?.entries?.mapNotNullTo(HashSet()) {
val url = RelayUrlFormatter.normalize(it.key)
if (url !in beingUsedSet) {
RelayInfo(url, 0, 0)
} else {
null
}
}
?: emptyList()
val newList = (beingUsed + newRelaysFromRecord).sortedWith(order)
currentUser?.let {
val newList = mergeRelays(it.relaysBeingUsed, it.latestContactList?.relays())
_feedContent.update { newList }
}
} finally {
isRefreshing.value = false
}
}
val listener: (UserState) -> Unit = { invalidateData() }
fun mergeRelays(
relaysBeingUsed: Map<String, RelayInfo>,
relays: Map<String, ReadWrite>?,
): List<RelayInfo> {
val userRelaysBeingUsed = relaysBeingUsed.map { it.value }
val currentUserRelays =
relays?.mapNotNull {
val url = RelayUrlFormatter.normalize(it.key)
if (url !in relaysBeingUsed) {
RelayInfo(url, 0, 0)
} else {
null
}
} ?: emptyList()
return (userRelaysBeingUsed + currentUserRelays).sortedWith(order)
}
fun subscribeTo(user: User) {
if (currentUser != user) {
currentUser = user
user.live().relays.observeForever(listener)
user.live().relayInfo.observeForever(listener)
currentJob?.cancel()
currentJob =
viewModelScope.launch {
combine(currentUser!!.flow().relays.stateFlow, currentUser!!.flow().relayInfo.stateFlow) { relays, relayInfo ->
mergeRelays(relays.user.relaysBeingUsed, relayInfo.user.latestContactList?.relays())
}.debounce(1000)
.collect { newList ->
_feedContent.update { newList }
}
}
invalidateData()
}
}
fun unsubscribeTo(user: User) {
if (currentUser == user) {
user.live().relays.removeObserver(listener)
user.live().relayInfo.removeObserver(listener)
currentUser = null
currentJob?.cancel()
invalidateData()
}
}
@@ -116,6 +136,7 @@ class RelayFeedViewModel :
override fun onCleared() {
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
bundler.cancel()
currentJob?.cancel()
super.onCleared()
}
}
@@ -23,26 +23,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelaysUsing
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun RelaysTabHeader(baseUser: User) {
val userState by baseUser.live().relays.observeAsState()
val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" }
val userState by observeUserRelaysUsing(baseUser)
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays =
remember(userStateRelayInfo) {
userStateRelayInfo
?.user
?.latestContactList
?.relays()
?.size ?: "--"
}
Text(text = "$userRelaysBeingUsed / $userRelays ${stringRes(R.string.relays)}")
Text(text = "${sizeAsString(userState.userRelayList.size)} / ${sizeAsString(userState.relays.size)} ${stringRes(R.string.relays)}")
}
private fun sizeAsString(count: Int) = if (count > 0) count.toString() else "--"
@@ -22,33 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReportCount
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ReportsTabHeader(baseUser: User) {
val userState by baseUser.live().reports.observeAsState()
var userReports by remember { mutableIntStateOf(0) }
val reportCount by observeUserReportCount(baseUser)
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
val newSize = UserProfileReportsFeedFilter(baseUser).feed().size
if (newSize != userReports) {
userReports = newSize
if (reportCount > 0) {
Text(text = stringRes(R.string.number_reports, reportCount))
} else {
Text(text = stringRes(R.string.reports))
}
}
}
Text(text = "$userReports ${stringRes(R.string.reports)}")
}
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserReports
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.reports.dal.UserProfileReportFeedViewModel
@Composable
@@ -32,6 +32,6 @@ fun WatchReportsAndUpdateFeed(
baseUser: User,
feedViewModel: UserProfileReportFeedViewModel,
) {
val userState by baseUser.live().reports.observeAsState()
val userState by observeUserReports(baseUser)
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@@ -25,7 +25,6 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCache.notes
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -1,43 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun WatchIsHiddenUser(
baseUser: User,
accountViewModel: AccountViewModel,
content: @Composable (Boolean) -> Unit,
) {
val isHidden by
accountViewModel.account.liveHiddenUsers
.map {
it.hiddenUsers.contains(baseUser.pubkeyHex) || it.spammers.contains(baseUser.pubkeyHex)
}.observeAsState(accountViewModel.account.isHidden(baseUser))
content(isHidden)
}
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserZaps
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel
@Composable
@@ -32,7 +32,7 @@ fun WatchZapsAndUpdateFeed(
baseUser: User,
feedViewModel: UserProfileZapsFeedViewModel,
) {
val userState by baseUser.live().zaps.observeAsState()
val userState by observeUserZaps(baseUser)
LaunchedEffect(userState) { feedViewModel.invalidateData() }
}
@@ -22,33 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserZapAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
@Composable
fun ZapTabHeader(baseUser: User) {
val userState by baseUser.live().zaps.observeAsState()
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.Default) {
val tempAmount = baseUser.zappedAmount()
if (zapAmount != tempAmount) {
zapAmount = tempAmount
}
}
}
val zapAmount by observeUserZapAmount(baseUser)
Text(text = "${showAmountInteger(zapAmount)} ${stringRes(id = R.string.zaps)}")
}
@@ -42,7 +42,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.collections.plus
@Stable
class Kind3RelayListViewModel : ViewModel() {
@@ -46,7 +46,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -63,10 +62,9 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.observeAccountIsHiddenWord
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.INav
@@ -323,11 +321,7 @@ fun MutedWordActionOptions(
word: String,
accountViewModel: AccountViewModel,
) {
val isMutedWord by
accountViewModel.account.liveHiddenUsers
.map { word in it.hiddenWords }
.distinctUntilChanged()
.observeAsState()
val isMutedWord by observeAccountIsHiddenWord(accountViewModel.account, word)
if (isMutedWord == true) {
ShowWordButton {
@@ -28,8 +28,6 @@ import com.vitorpamplona.ammolite.relays.NostrClient
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.collections.flatten
import kotlin.collections.ifEmpty
// This allows multiple screen to be listening to tags, even the same tag
class ThreadQueryState(
@@ -39,7 +39,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.collections.flatten
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif")
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
@@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.torproject.jni.TorService
/**
* There should be only one instance of the Tor binding per app.

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