diff --git a/README.md b/README.md index c4b82fcdd..c2ac6b03f 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases) - [x] Gift Wraps & Seals (NIP-59) - [x] Versioned Encrypted Payloads (NIP-44) - [x] Expiration Support (NIP-40) -- [x] Status Event (NIP-315) +- [x] Status Event (NIP-38) - [ ] Marketplace (NIP-15) - [ ] Image/Video Capture in the app - [ ] Local Database diff --git a/app/build.gradle b/app/build.gradle index 67fb47dbe..cf1181170 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 34 - versionCode 281 - versionName "0.75.2" + versionCode 287 + versionName "0.75.8" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -171,7 +171,7 @@ dependencies { playImplementation 'com.google.mlkit:translate:17.0.1' // PushNotifications - playImplementation platform('com.google.firebase:firebase-bom:32.2.2') + playImplementation platform('com.google.firebase:firebase-bom:32.2.3') playImplementation 'com.google.firebase:firebase-messaging-ktx' // Charts diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index 632a5b896..65743c61d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -122,7 +122,11 @@ object ServiceManager { isStarted = false } - fun cleanUp() { + fun cleanObservers() { + LocalCache.cleanObservers() + } + + fun trimMemory() { LocalCache.cleanObservers() val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d85e58f73..41dae2185 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -103,14 +103,16 @@ class Account( val showSensitiveContent: Boolean? ) - val liveHiddenUsers: LiveData = live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent -> - val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey) - LiveHiddenUsers( - hiddenUsers = liveBlockedUsers ?: persistentSetOf(), - spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(), - showSensitiveContent = showSensitiveContent - ) - }.distinctUntilChanged() + val liveHiddenUsers: LiveData by lazy { + live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent -> + val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey) + LiveHiddenUsers( + hiddenUsers = liveBlockedUsers ?: persistentSetOf(), + spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(), + showSensitiveContent = showSensitiveContent + ) + }.distinctUntilChanged() + } var userProfileCache: User? = null @@ -1192,6 +1194,21 @@ class Account( LocalCache.consume(event, null) } + fun deleteStatus(oldStatus: AddressableNote) { + if (!isWriteable()) return + val oldEvent = oldStatus.event as? StatusEvent ?: return + + val event = StatusEvent.clear(oldEvent, keyPair.privKey!!) + + Client.send(event) + LocalCache.consume(event, null) + + val event2 = DeletionEvent.create(listOf(event.id), keyPair.privKey!!) + + Client.send(event2) + LocalCache.consume(event2) + } + fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) { if (!isWriteable()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt index 70bc4a706..92d9d7759 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Channel.kt @@ -164,20 +164,12 @@ class ChannelLiveData(val channel: Channel) : LiveData(ChannelStat override fun onActive() { super.onActive() - if (channel is PublicChatChannel) { - NostrSingleChannelDataSource.add(channel.idHex) - } else { - NostrSingleChannelDataSource.add(channel.idHex) - } + NostrSingleChannelDataSource.add(channel) } override fun onInactive() { super.onInactive() - if (channel is PublicChatChannel) { - NostrSingleChannelDataSource.remove(channel.idHex) - } else { - NostrSingleChannelDataSource.remove(channel.idHex) - } + NostrSingleChannelDataSource.remove(channel) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt new file mode 100644 index 000000000..e23b02956 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Chatroom.kt @@ -0,0 +1,63 @@ +package com.vitorpamplona.amethyst.model + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.utils.TimeUtils + +@Stable +class Chatroom() { + var roomMessages: Set = setOf() + var subject: String? = null + var subjectCreatedAt: Long? = null + + @Synchronized + fun addMessageSync(msg: Note) { + checkNotInMainThread() + + if (msg !in roomMessages) { + roomMessages = roomMessages + msg + + val newSubject = msg.event?.subject() + + if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { + subject = newSubject + subjectCreatedAt = msg.createdAt() + } + } + } + + @Synchronized + fun removeMessageSync(msg: Note) { + checkNotInMainThread() + + if (msg !in roomMessages) { + roomMessages = roomMessages + msg + + roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let { + subject = it.event?.subject() + subjectCreatedAt = it.createdAt() + } + } + } + + fun senderIntersects(keySet: Set): Boolean { + return roomMessages.any { it.author?.pubkeyHex in keySet } + } + + fun pruneMessagesToTheLatestOnly(): Set { + val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + + val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { + // Recent messages, keep last 100 + sorted.take(100).toSet() + } else { + // Old messages, keep the last one. + sorted.take(1).toSet() + } + sorted.filter { it.liveSet?.isInUse() ?: false } + + val toRemove = roomMessages.minus(toKeep) + roomMessages = toKeep + return toRemove + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ec6b18328..4139733a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -17,7 +17,6 @@ import com.vitorpamplona.quartz.encoders.bechToBytes import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.events.* -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList @@ -372,7 +371,7 @@ object LocalCache { if (event.createdAt > (note.createdAt() ?: 0)) { note.loadEvent(event, author, emptyList()) - author.liveSet?.statuses?.invalidateData() + author.liveSet?.innerStatuses?.invalidateData() refreshObservers(note) } @@ -662,7 +661,7 @@ object LocalCache { mentions.forEach { // doesn't add to reports, but triggers recounts - it.liveSet?.reports?.invalidateData() + it.liveSet?.innerReports?.invalidateData() } } @@ -1318,8 +1317,6 @@ object LocalCache { fun pruneExpiredEvents() { checkNotInMainThread() - val now = TimeUtils.now() - val toBeRemoved = notes.filter { it.value.event?.isExpired() == true }.values diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 3a4ce8925..542a8d8c9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji @@ -22,6 +22,7 @@ import com.vitorpamplona.quartz.encoders.Nip19 import com.vitorpamplona.quartz.encoders.toNote import com.vitorpamplona.quartz.events.* import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import java.math.BigDecimal import java.time.Instant @@ -126,7 +127,7 @@ open class Note(val idHex: String) { this.author = author this.replyTo = replyTo - liveSet?.metadata?.invalidateData() + liveSet?.innerMetadata?.invalidateData() } } @@ -174,21 +175,21 @@ open class Note(val idHex: String) { fun addReply(note: Note) { if (note !in replies) { replies = replies + note - liveSet?.replies?.invalidateData() + liveSet?.innerReplies?.invalidateData() } } fun removeReply(note: Note) { if (note in replies) { replies = replies - note - liveSet?.replies?.invalidateData() + liveSet?.innerReplies?.invalidateData() } } fun removeBoost(note: Note) { if (note in boosts) { boosts = boosts - note - liveSet?.boosts?.invalidateData() + liveSet?.innerBoosts?.invalidateData() } } @@ -211,11 +212,11 @@ open class Note(val idHex: String) { relays = listOf() lastReactionsDownloadTime = emptyMap() - liveSet?.replies?.invalidateData() - liveSet?.reactions?.invalidateData() - liveSet?.boosts?.invalidateData() - liveSet?.reports?.invalidateData() - liveSet?.zaps?.invalidateData() + liveSet?.innerReplies?.invalidateData() + liveSet?.innerReactions?.invalidateData() + liveSet?.innerBoosts?.invalidateData() + liveSet?.innerReports?.invalidateData() + liveSet?.innerZaps?.invalidateData() return toBeRemoved } @@ -234,7 +235,7 @@ open class Note(val idHex: String) { reactions = reactions + Pair(reaction, newList) } - liveSet?.reactions?.invalidateData() + liveSet?.innerReactions?.invalidateData() } } } @@ -246,7 +247,7 @@ open class Note(val idHex: String) { if (author in reports.keys && reports[author]?.contains(deleteNote) == true) { reports[author]?.let { reports = reports + Pair(author, it.minus(deleteNote)) - liveSet?.reports?.invalidateData() + liveSet?.innerReports?.invalidateData() } } } @@ -254,28 +255,28 @@ open class Note(val idHex: String) { fun removeZap(note: Note) { if (zaps[note] != null) { zaps = zaps.minus(note) - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } else if (zaps.containsValue(note)) { zaps = zaps.filterValues { it != note } - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } fun removeZapPayment(note: Note) { if (zapPayments[note] != null) { zapPayments = zapPayments.minus(note) - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } else if (zapPayments.containsValue(note)) { val toRemove = zapPayments.filterValues { it == note } zapPayments = zapPayments.minus(toRemove.keys) - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } fun addBoost(note: Note) { if (note !in boosts) { boosts = boosts + note - liveSet?.boosts?.invalidateData() + liveSet?.innerBoosts?.invalidateData() } } @@ -297,12 +298,12 @@ open class Note(val idHex: String) { if (zapRequest !in zaps.keys) { val inserted = innerAddZap(zapRequest, zap) if (inserted) { - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } else if (zaps[zapRequest] == null) { val inserted = innerAddZap(zapRequest, zap) if (inserted) { - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } } @@ -325,12 +326,12 @@ open class Note(val idHex: String) { if (zapPaymentRequest !in zapPayments.keys) { val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) if (inserted) { - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } else if (zapPayments[zapPaymentRequest] == null) { val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) if (inserted) { - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } } @@ -341,10 +342,10 @@ open class Note(val idHex: String) { if (reaction !in reactions.keys) { reactions = reactions + Pair(reaction, listOf(note)) - liveSet?.reactions?.invalidateData() + liveSet?.innerReactions?.invalidateData() } else if (reactions[reaction]?.contains(note) == false) { reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note) - liveSet?.reactions?.invalidateData() + liveSet?.innerReactions?.invalidateData() } } @@ -353,17 +354,17 @@ open class Note(val idHex: String) { if (author !in reports.keys) { reports = reports + Pair(author, listOf(note)) - liveSet?.reports?.invalidateData() + liveSet?.innerReports?.invalidateData() } else if (reports[author]?.contains(note) == false) { reports = reports + Pair(author, (reports[author] ?: emptySet()) + note) - liveSet?.reports?.invalidateData() + liveSet?.innerReports?.invalidateData() } } fun addRelay(relay: Relay) { if (relay.url !in relays) { relays = relays + relay.url - liveSet?.relays?.invalidateData() + liveSet?.innerRelays?.invalidateData() } } @@ -463,10 +464,6 @@ open class Note(val idHex: String) { }.flatten() } - fun countReactions(): Int { - return reactions.values.sumOf { it.size } - } - fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal { // Regular Zap Receipts val completedZaps = zaps.asSequence() @@ -657,36 +654,52 @@ open class Note(val idHex: String) { @Stable class NoteLiveSet(u: Note) { // Observers line up here. - val metadata: NoteLiveData = NoteLiveData(u) + val innerMetadata = NoteBundledRefresherLiveData(u) + val innerReactions = NoteBundledRefresherLiveData(u) + val innerBoosts = NoteBundledRefresherLiveData(u) + val innerReplies = NoteBundledRefresherLiveData(u) + val innerReports = NoteBundledRefresherLiveData(u) + val innerRelays = NoteBundledRefresherLiveData(u) + val innerZaps = NoteBundledRefresherLiveData(u) - val authorChanges = metadata.map { + val metadata = innerMetadata.map { it } + val reactions = innerReactions.map { it } + val boosts = innerBoosts.map { it } + val replies = innerReplies.map { it } + val reports = innerReports.map { it } + val relays = innerRelays.map { it } + val zaps = innerZaps.map { it } + + val authorChanges = innerMetadata.map { it.note.author } - val hasEvent = metadata.map { + + val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged() - val reactions: NoteLiveData = NoteLiveData(u) - val boosts: NoteLiveData = NoteLiveData(u) - val replies: NoteLiveData = NoteLiveData(u) - val reports: NoteLiveData = NoteLiveData(u) - val relays: NoteLiveData = NoteLiveData(u) - val zaps: NoteLiveData = NoteLiveData(u) - - val hasReactions = zaps.combineWith(boosts, reactions) { zapState, boostState, reactionState -> + val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState -> zapState?.note?.zaps?.isNotEmpty() ?: false || boostState?.note?.boosts?.isNotEmpty() ?: false || reactionState?.note?.reactions?.isNotEmpty() ?: false }.distinctUntilChanged() - val replyCount = replies.map { + val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged() - val boostCount = boosts.map { + val reactionCount = innerReactions.map { + it.note.reactions.values.sumOf { it.size } + }.distinctUntilChanged() + + val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged() + val boostList = innerBoosts.map { + it.note.boosts.toImmutableList() + }.distinctUntilChanged() + fun isInUse(): Boolean { return metadata.hasObservers() || reactions.hasObservers() || @@ -694,21 +707,28 @@ class NoteLiveSet(u: Note) { replies.hasObservers() || reports.hasObservers() || relays.hasObservers() || - zaps.hasObservers() + zaps.hasObservers() || + authorChanges.hasObservers() || + hasEvent.hasObservers() || + hasReactions.hasObservers() || + replyCount.hasObservers() || + reactionCount.hasObservers() || + boostCount.hasObservers() || + boostList.hasObservers() } fun destroy() { - metadata.destroy() - reactions.destroy() - boosts.destroy() - replies.destroy() - reports.destroy() - relays.destroy() - zaps.destroy() + innerMetadata.destroy() + innerReactions.destroy() + innerBoosts.destroy() + innerReplies.destroy() + innerReports.destroy() + innerRelays.destroy() + innerZaps.destroy() } } -class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { +class NoteBundledRefresherLiveData(val note: Note) : LiveData(NoteState(note)) { // Refreshes observers in batches. private val bundler = BundledUpdate(500, Dispatchers.IO) @@ -730,6 +750,17 @@ class NoteLiveData(val note: Note) : LiveData(NoteState(note)) { } } + fun map( + transform: (NoteState) -> Y + ): NoteLoadingLiveData { + val initialValue = this.value?.let { transform(it) } + val result = NoteLoadingLiveData(note, initialValue) + result.addSource(this) { x -> result.value = transform(x) } + return result + } +} + +class NoteLoadingLiveData(val note: Note, initialValue: Y?) : MediatorLiveData(initialValue) { override fun onActive() { super.onActive() if (note is AddressableNote) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index d46bdcc76..85d608bae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource @@ -23,7 +24,6 @@ import com.vitorpamplona.quartz.events.MetadataEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.toImmutableListOfLists -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.Dispatchers import java.math.BigDecimal @@ -101,7 +101,7 @@ class User(val pubkeyHex: String) { if (event.id == latestBookmarkList?.id) return latestBookmarkList = event - liveSet?.bookmarks?.invalidateData() + liveSet?.innerBookmarks?.invalidateData() } fun updateContactList(event: ContactListEvent) { @@ -111,18 +111,18 @@ class User(val pubkeyHex: String) { latestContactList = event // Update following of the current user - liveSet?.follows?.invalidateData() + liveSet?.innerFollows?.invalidateData() // Update Followers of the past user list // Update Followers of the new contact list (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.followers?.invalidateData() + LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() } (latestContactList)?.unverifiedFollowKeySet()?.forEach { - LocalCache.users[it]?.liveSet?.followers?.invalidateData() + LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData() } - liveSet?.relays?.invalidateData() + liveSet?.innerRelays?.invalidateData() } fun addReport(note: Note) { @@ -130,10 +130,10 @@ class User(val pubkeyHex: String) { if (author !in reports.keys) { reports = reports + Pair(author, setOf(note)) - liveSet?.reports?.invalidateData() + liveSet?.innerReports?.invalidateData() } else if (reports[author]?.contains(note) == false) { reports = reports + Pair(author, (reports[author] ?: emptySet()) + note) - liveSet?.reports?.invalidateData() + liveSet?.innerReports?.invalidateData() } } @@ -143,7 +143,7 @@ class User(val pubkeyHex: String) { if (author in reports.keys && reports[author]?.contains(deleteNote) == true) { reports[author]?.let { reports = reports + Pair(author, it.minus(deleteNote)) - liveSet?.reports?.invalidateData() + liveSet?.innerReports?.invalidateData() } } } @@ -151,20 +151,20 @@ class User(val pubkeyHex: String) { fun addZap(zapRequest: Note, zap: Note?) { if (zapRequest !in zaps.keys) { zaps = zaps + Pair(zapRequest, zap) - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } else if (zapRequest in zaps.keys && zaps[zapRequest] == null) { zaps = zaps + Pair(zapRequest, zap) - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } fun removeZap(zapRequestOrZapEvent: Note) { if (zapRequestOrZapEvent in zaps.keys) { zaps = zaps.minus(zapRequestOrZapEvent) - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } else if (zapRequestOrZapEvent in zaps.values) { zaps = zaps.filter { it.value != zapRequestOrZapEvent } - liveSet?.zaps?.invalidateData() + liveSet?.innerZaps?.invalidateData() } } @@ -218,7 +218,7 @@ class User(val pubkeyHex: String) { val privateChatroom = getOrCreatePrivateChatroom(room) if (msg !in privateChatroom.roomMessages) { privateChatroom.addMessageSync(msg) - liveSet?.messages?.invalidateData() + liveSet?.innerMessages?.invalidateData() } } @@ -226,7 +226,7 @@ class User(val pubkeyHex: String) { val privateChatroom = getOrCreatePrivateChatroom(user) if (msg !in privateChatroom.roomMessages) { privateChatroom.addMessageSync(msg) - liveSet?.messages?.invalidateData() + liveSet?.innerMessages?.invalidateData() } } @@ -240,7 +240,7 @@ class User(val pubkeyHex: String) { val privateChatroom = getOrCreatePrivateChatroom(user) if (msg in privateChatroom.roomMessages) { privateChatroom.removeMessageSync(msg) - liveSet?.messages?.invalidateData() + liveSet?.innerMessages?.invalidateData() } } @@ -255,7 +255,7 @@ class User(val pubkeyHex: String) { here.counter++ } - liveSet?.relayInfo?.invalidateData() + liveSet?.innerRelayInfo?.invalidateData() } fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) { @@ -280,7 +280,7 @@ class User(val pubkeyHex: String) { } } - liveSet?.metadata?.invalidateData() + liveSet?.innerMetadata?.invalidateData() } fun isFollowing(user: User): Boolean { @@ -382,54 +382,70 @@ class User(val pubkeyHex: String) { @Stable class UserLiveSet(u: User) { - // UI Observers line up here. - val follows: UserLiveData = UserLiveData(u) - val followers: UserLiveData = UserLiveData(u) - val reports: UserLiveData = UserLiveData(u) - val messages: UserLiveData = UserLiveData(u) - val relays: UserLiveData = UserLiveData(u) - val relayInfo: UserLiveData = UserLiveData(u) - val metadata: UserLiveData = UserLiveData(u) - val zaps: UserLiveData = UserLiveData(u) - val bookmarks: UserLiveData = UserLiveData(u) - val statuses: UserLiveData = UserLiveData(u) + val innerMetadata = UserBundledRefresherLiveData(u) - val profilePictureChanges = metadata.map { + // UI Observers line up here. + val innerFollows = UserBundledRefresherLiveData(u) + val innerFollowers = UserBundledRefresherLiveData(u) + val innerReports = UserBundledRefresherLiveData(u) + val innerMessages = UserBundledRefresherLiveData(u) + val innerRelays = UserBundledRefresherLiveData(u) + val innerRelayInfo = UserBundledRefresherLiveData(u) + val innerZaps = UserBundledRefresherLiveData(u) + val innerBookmarks = UserBundledRefresherLiveData(u) + val innerStatuses = UserBundledRefresherLiveData(u) + + // UI Observers line up here. + val metadata = innerMetadata.map { it } + val follows = innerFollows.map { it } + val followers = innerFollowers.map { it } + val reports = innerReports.map { it } + val messages = innerMessages.map { it } + val relays = innerRelays.map { it } + val relayInfo = innerRelayInfo.map { it } + val zaps = innerZaps.map { it } + val bookmarks = innerBookmarks.map { it } + val statuses = innerStatuses.map { it } + + val profilePictureChanges = innerMetadata.map { it.user.profilePicture() }.distinctUntilChanged() - val nip05Changes = metadata.map { + val nip05Changes = innerMetadata.map { it.user.nip05() }.distinctUntilChanged() - val userMetadataInfo = metadata.map { + val userMetadataInfo = innerMetadata.map { it.user.info }.distinctUntilChanged() fun isInUse(): Boolean { - return follows.hasObservers() || + return metadata.hasObservers() || + follows.hasObservers() || followers.hasObservers() || reports.hasObservers() || messages.hasObservers() || relays.hasObservers() || relayInfo.hasObservers() || - metadata.hasObservers() || zaps.hasObservers() || + bookmarks.hasObservers() || statuses.hasObservers() || - bookmarks.hasObservers() + profilePictureChanges.hasObservers() || + nip05Changes.hasObservers() || + userMetadataInfo.hasObservers() } fun destroy() { - follows.destroy() - followers.destroy() - reports.destroy() - messages.destroy() - relays.destroy() - relayInfo.destroy() - metadata.destroy() - zaps.destroy() - bookmarks.destroy() - statuses.destroy() + innerMetadata.destroy() + innerFollows.destroy() + innerFollowers.destroy() + innerReports.destroy() + innerMessages.destroy() + innerRelays.destroy() + innerRelayInfo.destroy() + innerZaps.destroy() + innerBookmarks.destroy() + innerStatuses.destroy() } } @@ -440,64 +456,7 @@ data class RelayInfo( var counter: Long ) -@Stable -class Chatroom() { - var roomMessages: Set = setOf() - var subject: String? = null - var subjectCreatedAt: Long? = null - - @Synchronized - fun addMessageSync(msg: Note) { - checkNotInMainThread() - - if (msg !in roomMessages) { - roomMessages = roomMessages + msg - - val newSubject = msg.event?.subject() - - if (newSubject != null && (msg.createdAt() ?: 0) > (subjectCreatedAt ?: 0)) { - subject = newSubject - subjectCreatedAt = msg.createdAt() - } - } - } - - @Synchronized - fun removeMessageSync(msg: Note) { - checkNotInMainThread() - - if (msg !in roomMessages) { - roomMessages = roomMessages + msg - - roomMessages.filter { it.event?.subject() != null }.sortedBy { it.createdAt() }.lastOrNull()?.let { - subject = it.event?.subject() - subjectCreatedAt = it.createdAt() - } - } - } - - fun senderIntersects(keySet: Set): Boolean { - return roomMessages.any { it.author?.pubkeyHex in keySet } - } - - fun pruneMessagesToTheLatestOnly(): Set { - val sorted = roomMessages.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() - - val toKeep = if ((sorted.firstOrNull()?.createdAt() ?: 0) > TimeUtils.oneWeekAgo()) { - // Recent messages, keep last 100 - sorted.take(100).toSet() - } else { - // Old messages, keep the last one. - sorted.take(1).toSet() - } + sorted.filter { it.liveSet?.isInUse() ?: false } - - val toRemove = roomMessages.minus(toKeep) - roomMessages = toKeep - return toRemove - } -} - -class UserLiveData(val user: User) : LiveData(UserState(user)) { +class UserBundledRefresherLiveData(val user: User) : LiveData(UserState(user)) { // Refreshes observers in batches. private val bundler = BundledUpdate(500, Dispatchers.IO) @@ -518,6 +477,17 @@ class UserLiveData(val user: User) : LiveData(UserState(user)) { } } + fun map( + transform: (UserState) -> Y + ): UserLoadingLiveData { + val initialValue = this.value?.let { transform(it) } + val result = UserLoadingLiveData(user, initialValue) + result.addSource(this) { x -> result.value = transform(x) } + return result + } +} + +class UserLoadingLiveData(val user: User, initialValue: Y?) : MediatorLiveData(initialValue) { override fun onActive() { super.onActive() NostrSingleUserDataSource.add(user) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 54e4cac10..7bf7084f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -111,12 +111,14 @@ abstract class NostrDataSource(val debugName: String) { private val bundler = BundledUpdate(300, Dispatchers.IO) fun invalidateFilters() { - bundler.invalidate() { - // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") + scope.launch(Dispatchers.IO) { + bundler.invalidate() { + // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - resetFiltersSuspend() + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + resetFiltersSuspend() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt index d58f40853..ed4c02364 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDiscoveryDataSource.kt @@ -18,53 +18,66 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { val latestEOSEs = EOSEAccount() - fun createLiveStreamFilter(): TypedFilter { - val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + fun createLiveStreamFilter(): List { + val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList() - val followKeys = follows?.map { - it.substring(0, 8) - } - - return TypedFilter( - types = setOf(FeedType.GLOBAL), - filter = JsonFilter( - authors = followKeys, - kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList - ) + return listOfNotNull( + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + authors = follows, + kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + ) + ), + follows?.let { + TypedFilter( + types = setOf(FeedType.GLOBAL), + filter = JsonFilter( + tags = mapOf("p" to it), + kinds = listOf(LiveActivitiesEvent.kind), + limit = 100, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + ) + ) + } ) } - fun createPublicChatFilter(): TypedFilter { - val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) + fun createPublicChatFilter(): List { + val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList() + val followChats = account.selectedChatsFollowList().toList() - val followKeys = follows?.map { - it.substring(0, 8) - } - - return TypedFilter( - types = setOf(FeedType.PUBLIC_CHATS), - filter = JsonFilter( - authors = followKeys, - kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), - limit = 300, - since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + return listOf( + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = JsonFilter( + authors = follows, + kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + ) + ), + TypedFilter( + types = setOf(FeedType.PUBLIC_CHATS), + filter = JsonFilter( + ids = followChats, + kinds = listOf(ChannelCreateEvent.kind), + limit = 300, + since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList + ) ) ) } fun createCommunitiesFilter(): TypedFilter { - val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) - - val followKeys = follows?.map { - it.substring(0, 8) - } + val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList() return TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - authors = followKeys, + authors = follows, kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), limit = 300, since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList @@ -197,16 +210,16 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") { } override fun updateChannelFilters() { - discoveryFeedChannel.typedFilters = listOfNotNull( - createLiveStreamFilter(), - createPublicChatFilter(), - createCommunitiesFilter(), - createLiveStreamTagsFilter(), - createPublicChatsTagsFilter(), - createCommunitiesTagsFilter(), - createCommunitiesGeohashesFilter(), - createPublicChatsGeohashesFilter(), - createLiveStreamGeohashesFilter() + discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus( + listOfNotNull( + createLiveStreamTagsFilter(), + createLiveStreamGeohashesFilter(), + createCommunitiesFilter(), + createPublicChatsTagsFilter(), + createCommunitiesTagsFilter(), + createCommunitiesGeohashesFilter(), + createPublicChatsGeohashesFilter() + ) ).ifEmpty { null } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index 2c49f5fad..183fbb935 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -54,13 +54,8 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { } fun createFollowAccountsFilter(): TypedFilter { - val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) - - val followKeys = follows?.map { - it.substring(0, 8) - } - - val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8)) + val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet() + val followSet = follows.plus(account.userProfile().pubkeyHex).toList() return TypedFilter( types = setOf(FeedType.FOLLOWS), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index 1315a614f..b72da4fe8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -1,7 +1,7 @@ package com.vitorpamplona.amethyst.service +import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.FeedType @@ -11,10 +11,10 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { - private var channelsToWatch = setOf() + private var channelsToWatch = setOf() - private fun createRepliesAndReactionsFilter(): TypedFilter? { - val reactionsToWatch = channelsToWatch.map { it } + private fun createMetadataChangeFilter(): TypedFilter? { + val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex } if (reactionsToWatch.isEmpty()) { return null @@ -32,7 +32,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { val directEventsToLoad = channelsToWatch - .mapNotNull { LocalCache.checkGetOrCreateChannel(it) } .filter { it.notes.isEmpty() && it is PublicChatChannel } val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() @@ -53,7 +52,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { fun createLoadStreamingIfNotLoadedFilter(): List? { val directEventsToLoad = channelsToWatch - .mapNotNull { LocalCache.checkGetOrCreateChannel(it) } .filterIsInstance() .filter { it.info == null } @@ -81,7 +79,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { val singleChannelChannel = requestNewChannel() override fun updateChannelFilters() { - val reactions = createRepliesAndReactionsFilter() + val reactions = createMetadataChangeFilter() val missing = createLoadEventsIfNotLoadedFilter() val missingStreaming = createLoadStreamingIfNotLoadedFilter() @@ -90,13 +88,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { ).ifEmpty { null } } - fun add(eventId: String) { - channelsToWatch = channelsToWatch.plus(eventId) - invalidateFilters() + fun add(eventId: Channel) { + if (eventId !in channelsToWatch) { + channelsToWatch = channelsToWatch.plus(eventId) + invalidateFilters() + } } - fun remove(eventId: String) { - channelsToWatch = channelsToWatch.minus(eventId) - invalidateFilters() + fun remove(eventId: Channel) { + if (eventId in channelsToWatch) { + channelsToWatch = channelsToWatch.minus(eventId) + invalidateFilters() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index 5dfa266f6..a4fc612d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -12,49 +12,60 @@ import com.vitorpamplona.quartz.events.StatusEvent object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { var usersToWatch = setOf() - fun createUserFilter(): List? { + fun createUserMetadataFilter(): List? { if (usersToWatch.isEmpty()) return null - return usersToWatch.filter { it.info?.latestMetadata == null }.map { + val firstTimers = usersToWatch.filter { it.info?.latestMetadata == null }.map { it.pubkeyHex } + + return listOf( TypedFilter( types = COMMON_FEED_TYPES, filter = JsonFilter( - kinds = listOf(MetadataEvent.kind), - authors = listOf(it.pubkeyHex), - limit = 1 + kinds = listOf(MetadataEvent.kind, StatusEvent.kind), + authors = firstTimers, + limit = 10 * firstTimers.size ) ) - } + ) } - fun createUserStatusFilter(): List? { + fun createUserMetadataFilter(minLatestEOSEs: Map): TypedFilter? { if (usersToWatch.isEmpty()) return null - return usersToWatch.map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(StatusEvent.kind), - authors = listOf(it.pubkeyHex), - since = it.latestEOSEs - ) + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = JsonFilter( + kinds = listOf(StatusEvent.kind), + authors = usersToWatch.map { it.pubkeyHex }, + since = minLatestEOSEs ) - } + ) } - fun createUserReportFilter(): List? { + fun createUserStatusFilter(minLatestEOSEs: Map): TypedFilter? { if (usersToWatch.isEmpty()) return null - return usersToWatch.map { - TypedFilter( - types = COMMON_FEED_TYPES, - filter = JsonFilter( - kinds = listOf(ReportEvent.kind), - tags = mapOf("p" to listOf(it.pubkeyHex)), - since = it.latestEOSEs - ) + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = JsonFilter( + kinds = listOf(StatusEvent.kind), + authors = usersToWatch.map { it.pubkeyHex }, + since = minLatestEOSEs ) - } + ) + } + + fun createUserReportFilter(minLatestEOSEs: Map): TypedFilter? { + if (usersToWatch.isEmpty()) return null + + return TypedFilter( + types = COMMON_FEED_TYPES, + filter = JsonFilter( + kinds = listOf(ReportEvent.kind), + tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }), + since = minLatestEOSEs + ) + ) } val userChannel = requestNewChannel() { time, relayUrl -> @@ -66,17 +77,40 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { eose.time = time } } + } + val userChannelFirstTimers = requestNewChannel() { time, relayUrl -> // Many relays operate with limits in the amount of filters. // As information comes, the filters will be rotated to get more data. invalidateFilters() } - val userChannelOnce = requestNewChannel() - override fun updateChannelFilters() { - userChannel.typedFilters = listOfNotNull(createUserReportFilter(), createUserStatusFilter()).flatten().ifEmpty { null } - userChannelOnce.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null } + val minLatestEOSEs = mutableMapOf() + val neverGottenAnEOSE = mutableSetOf() + usersToWatch.forEach { + if (it.latestEOSEs.isEmpty()) { // first time + neverGottenAnEOSE.add(it.pubkeyHex) + } else { + it.latestEOSEs.forEach { + val minEose = minLatestEOSEs[it.key] + if (minEose == null) { + minLatestEOSEs.put(it.key, EOSETime(it.value.time)) + } else if (it.value.time < minEose.time) { + minEose.time = it.value.time + } + } + } + } + + userChannel.typedFilters = listOfNotNull( + createUserMetadataFilter(minLatestEOSEs), + createUserStatusFilter(minLatestEOSEs), + createUserReportFilter(minLatestEOSEs) + ).ifEmpty { null } + userChannelFirstTimers.typedFilters = listOfNotNull( + createUserMetadataFilter() + ).flatten().ifEmpty { null } } fun add(user: User) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index ec7ade398..2032f551e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { } fun loadThread(noteId: String?) { - eventToWatch = noteId + if (eventToWatch != noteId) { + eventToWatch = noteId - invalidateFilters() + invalidateFilters() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt index 1ca8bd199..2fcd5ecb0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt @@ -14,16 +14,12 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") { val latestEOSEs = EOSEAccount() fun createContextualFilter(): TypedFilter? { - val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList) - - val followKeys = follows?.map { - it.substring(0, 6) - } + val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList() return TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - authors = followKeys, + authors = follows, kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), limit = 200, since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 61e8d3d17..6adb42fd7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -57,7 +57,7 @@ class LightningAddressResolver() { if (it.isSuccessful) { onSuccess(it.body.string()) } else { - onError("Could not resolve $lnaddress. Error: ${it.code}. Check if the server up and if the lightning address $lnaddress is correct") + onError("The receiver's lightning service at $url is not available. It was calculated from the lightning address \"${lnaddress}\". Error: ${it.code}. Check if the server up and if the lightning address is correct") } } } catch (e: Exception) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index d73c22b10..5ca2e3af8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -23,7 +23,7 @@ import kotlinx.collections.immutable.persistentSetOf class EventNotificationConsumer(private val applicationContext: Context) { - fun consume(event: Event) { + suspend fun consume(event: Event) { if (LocalCache.notes[event.id] == null) { if (LocalCache.justVerify(event)) { LocalCache.justConsume(event, null) @@ -40,7 +40,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { } } - fun unwrapAndConsume(event: Event, account: Account): Event? { + suspend fun unwrapAndConsume(event: Event, account: Account): Event? { if (!LocalCache.justVerify(event)) return null return when (event) { @@ -65,7 +65,7 @@ class EventNotificationConsumer(private val applicationContext: Context) { } } - private fun unwrapAndNotify(giftWrap: GiftWrapEvent) { + private suspend fun unwrapAndNotify(giftWrap: GiftWrapEvent) { val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return LocalPreferences.allSavedAccounts().forEach { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt index 0fe851b6f..16a97086d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/JsonFilter.kt @@ -60,7 +60,7 @@ class JsonFilter( } else { val jsonObjectSince = factory.objectNode() entries.forEach { sincePairs -> - put(sincePairs.key, "${sincePairs.value}") + jsonObjectSince.put(sincePairs.key, "${sincePairs.value}") } put("since", jsonObjectSince) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index e39b7228f..fd2e82042 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -186,6 +186,7 @@ class MainActivity : AppCompatActivity() { } override fun onPause() { + ServiceManager.cleanObservers() // if (BuildConfig.DEBUG) { debugState(this) // } @@ -212,7 +213,7 @@ class MainActivity : AppCompatActivity() { super.onTrimMemory(level) println("Trim Memory $level") GlobalScope.launch(Dispatchers.Default) { - ServiceManager.cleanUp() + ServiceManager.trimMemory() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt index f585f6e0a..f99259dc0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -1,9 +1,11 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField @@ -11,8 +13,6 @@ import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -21,51 +21,11 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { - var textMax by rememberSaveable { mutableStateOf("") } - var textMin by rememberSaveable { mutableStateOf("") } - - // check for zapMax amounts < 1 - pollViewModel.isValidvalueMaximum.value = true - if (textMax.isNotEmpty()) { - try { - val int = textMax.toInt() - if (int < 1) { - pollViewModel.isValidvalueMaximum.value = false - } else { pollViewModel.valueMaximum = int } - } catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false } - } - - // check for minZap amounts < 1 - pollViewModel.isValidvalueMinimum.value = true - if (textMin.isNotEmpty()) { - try { - val int = textMin.toInt() - if (int < 1) { - pollViewModel.isValidvalueMinimum.value = false - } else { pollViewModel.valueMinimum = int } - } catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false } - } - - // check for zapMin > zapMax - if (textMin.isNotEmpty() && textMax.isNotEmpty()) { - try { - val intMin = textMin.toInt() - val intMax = textMax.toInt() - - if (intMin > intMax) { - pollViewModel.isValidvalueMinimum.value = false - pollViewModel.isValidvalueMaximum.value = false - } - } catch (e: Exception) { - pollViewModel.isValidvalueMinimum.value = false - pollViewModel.isValidvalueMaximum.value = false - } - } - val colorInValid = TextFieldDefaults.outlinedTextFieldColors( focusedBorderColor = MaterialTheme.colors.error, unfocusedBorderColor = Color.Red @@ -80,10 +40,10 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { horizontalArrangement = Arrangement.Center ) { OutlinedTextField( - value = textMin, - onValueChange = { textMin = it }, + value = pollViewModel.valueMinimum?.toString() ?: "", + onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), + modifier = Modifier.weight(1f), colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, label = { Text( @@ -98,11 +58,14 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { ) } ) + + Spacer(modifier = DoubleHorzSpacer) + OutlinedTextField( - value = textMax, - onValueChange = { textMax = it }, + value = pollViewModel.valueMaximum?.toString() ?: "", + onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), + modifier = Modifier.weight(1f), colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, label = { Text( @@ -118,10 +81,25 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { } ) } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.poll_zap_value_min_max_explainer), + color = MaterialTheme.colors.placeholderText, + modifier = Modifier.padding(vertical = 10.dp) + ) + } } @Preview @Composable fun NewPollVoteValueRangePreview() { - NewPollVoteValueRange(NewPostViewModel()) + Column( + modifier = Modifier.fillMaxWidth() + ) { + NewPollVoteValueRange(NewPostViewModel()) + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index ecd83e2ec..2905649a0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -542,6 +542,8 @@ private fun PollField(postViewModel: NewPostViewModel) { NewPollOption(postViewModel, index) } + NewPollVoteValueRange(postViewModel) + Button( onClick = { postViewModel.pollOptions[postViewModel.pollOptions.size] = diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 6ef132dd3..98e27e05f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -74,8 +74,8 @@ open class NewPostViewModel() : ViewModel() { var wantsPoll by mutableStateOf(false) var zapRecipients = mutableStateListOf() var pollOptions = newStateMapPollOptions() - var valueMaximum: Int? = null - var valueMinimum: Int? = null + var valueMaximum by mutableStateOf(null) + var valueMinimum by mutableStateOf(null) var consensusThreshold: Int? = null var closedAt: Int? = null @@ -503,7 +503,7 @@ open class NewPostViewModel() : ViewModel() { return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && (!wantsZapraiser || zapRaiserAmount != null) && (!wantsDirectMessage || !toUsers.text.isNullOrBlank()) && - (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && + (!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) && contentToAddUrl == null } @@ -621,6 +621,50 @@ open class NewPostViewModel() : ViewModel() { nip24 = !nip24 } } + + fun updateMinZapAmountForPoll(textMin: String) { + if (textMin.isNotEmpty()) { + try { + val int = textMin.toInt() + if (int < 1) { + valueMinimum = null + } else { + valueMinimum = int + } + } catch (e: Exception) {} + } else { + valueMinimum = null + } + + checkMinMax() + } + + fun updateMaxZapAmountForPoll(textMax: String) { + if (textMax.isNotEmpty()) { + try { + val int = textMax.toInt() + if (int < 1) { + valueMaximum = null + } else { + valueMaximum = int + } + } catch (e: Exception) {} + } else { + valueMaximum = null + } + + checkMinMax() + } + + fun checkMinMax() { + if ((valueMinimum ?: 0) > (valueMaximum ?: Int.MAX_VALUE)) { + isValidvalueMinimum.value = false + isValidvalueMaximum.value = false + } else { + isValidvalueMinimum.value = true + isValidvalueMaximum.value = true + } + } } enum class GeohashPrecision(val digits: Int) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 3a10db49e..5a5d6db32 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Intent import android.net.Uri +import android.widget.Toast import androidx.compose.animation.Crossfade import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle @@ -12,6 +13,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextDirection import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.R import com.vitorpamplona.quartz.encoders.LnWithdrawalUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -41,10 +43,7 @@ fun MayBeWithdrawal(lnurlWord: String) { @Composable fun ClickableWithdrawal(withdrawalString: String) { val context = LocalContext.current - - val uri = remember(withdrawalString) { - Uri.parse("lightning:$withdrawalString") - } + val scope = rememberCoroutineScope() val withdraw = remember(withdrawalString) { AnnotatedString("$withdrawalString ") @@ -53,9 +52,18 @@ fun ClickableWithdrawal(withdrawalString: String) { ClickableText( text = withdraw, onClick = { - runCatching { - val intent = Intent(Intent.ACTION_VIEW, uri) + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK ContextCompat.startActivity(context, intent, null) + } catch (e: Exception) { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.lightning_wallets_not_found), + Toast.LENGTH_LONG + ).show() + } } }, style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index 87d6179df..52c0f2c50 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Intent import android.net.Uri +import android.widget.Toast import androidx.compose.animation.Crossfade import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column @@ -66,6 +67,7 @@ fun MayBeInvoicePreview(lnbcWord: String) { @Composable fun InvoicePreview(lnInvoice: String, amount: String?) { val context = LocalContext.current + val scope = rememberCoroutineScope() Column( modifier = Modifier @@ -118,9 +120,18 @@ fun InvoicePreview(lnInvoice: String, amount: String?) { .fillMaxWidth() .padding(vertical = 10.dp), onClick = { - runCatching { + try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(context, intent, null) + } catch (e: Exception) { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.lightning_wallets_not_found), + Toast.LENGTH_LONG + ).show() + } } }, shape = QuoteBorder, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt index 8f88efade..b17da032e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreview.kt @@ -12,8 +12,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) { @@ -37,10 +35,8 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). if (urlPreviewState == UrlPreviewState.Loading) { LaunchedEffect(url) { - launch(Dispatchers.IO) { - UrlCachedPreviewer.previewInfo(url) { - urlPreviewState = it - } + accountViewModel.urlPreview(url) { + urlPreviewState = it } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt index 2f7a22ddd..2964850eb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt @@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable -import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter() { @@ -50,7 +49,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter, newItems: Set): List { val (feed, elapsed) = measureTimedValue { val me = account.userProfile() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt index 7b5b8b9b4..7356b3544 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListNewFeedFilter.kt @@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.PrivateDmEvent -import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter() { @@ -38,7 +37,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter .reversed() } - @OptIn(ExperimentalTime::class) override fun updateListWith(oldList: List, newItems: Set): List { val (feed, elapsed) = measureTimedValue { val me = account.userProfile() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt index 86f8bd3aa..1b86baca3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/FeedFilter.kt @@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal import android.util.Log import com.vitorpamplona.amethyst.service.checkNotInMainThread -import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue abstract class FeedFilter { - @OptIn(ExperimentalTime::class) fun loadTop(): List { checkNotInMainThread() @@ -33,7 +31,6 @@ abstract class AdditiveFeedFilter : FeedFilter() { abstract fun applyFilter(collection: Set): Set abstract fun sort(collection: Set): List - @OptIn(ExperimentalTime::class) open fun updateListWith(oldList: List, newItems: Set): List { checkNotInMainThread() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt index 1555feec5..1d5033d2b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GeoHashFeedFilter.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent +import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent @@ -34,6 +35,7 @@ class GeoHashFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil it.event is LongTextNoteEvent || it.event is ChannelMessageEvent || it.event is PrivateDmEvent || + it.event is PollNoteEvent || it.event is AudioHeaderEvent ) && it.event?.isTaggedGeoHash(myTag) == true diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt index 9319908ec..031162e92 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt @@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent +import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent @@ -34,6 +35,7 @@ class HashtagFeedFilter(val tag: String, val account: Account) : AdditiveFeedFil it.event is LongTextNoteEvent || it.event is ChannelMessageEvent || it.event is PrivateDmEvent || + it.event is PollNoteEvent || it.event is AudioHeaderEvent ) && it.event?.isTaggedHash(myTag) == true diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 6775b9562..167bbaf85 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.navigation import android.content.Context import android.content.ContextWrapper import android.content.Intent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -80,7 +83,12 @@ fun AppNavigation( } } - NavHost(navController, startDestination = Route.Home.route) { + NavHost( + navController, + startDestination = Route.Home.route, + enterTransition = { fadeIn(animationSpec = tween(200)) }, + exitTransition = { fadeOut(animationSpec = tween(200)) } + ) { Route.Home.let { route -> composable(route.route, route.arguments, content = { it -> val nip47 = it.arguments?.getString("nip47") @@ -215,6 +223,7 @@ fun AppNavigation( composable(route.route, route.arguments, content = { ChatroomScreen( roomId = it.arguments?.getString("id"), + draftMessage = it.arguments?.getString("message"), accountViewModel = accountViewModel, nav = nav ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index b2976ada3..406eeca5c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -754,9 +754,9 @@ fun debugState(context: Context) { Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB") Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB") - Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size) - Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + "/" + LocalCache.addressables.size) - Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size) + Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.liveSet != null }.size + " / " + LocalCache.notes.filter { it.value.event != null }.size + " / " + LocalCache.notes.size) + Log.d("STATE DUMP", "Addressables: " + LocalCache.addressables.filter { it.value.liveSet != null }.size + " / " + LocalCache.addressables.filter { it.value.event != null }.size + " / " + LocalCache.addressables.size) + Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.liveSet != null }.size + " / " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + " / " + LocalCache.users.size) Log.d("STATE DUMP", "Memory used by Events: " + LocalCache.notes.values.sumOf { it.event?.countMemory() ?: 0 } / (1024 * 1024) + " MB") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index f2f695fb1..a038e28ce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.AlertDialog @@ -56,6 +57,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -278,16 +280,21 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo ) }, keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, capitalization = KeyboardCapitalization.Sentences ), + keyboardActions = KeyboardActions( + onSend = { + accountViewModel.createStatus(currentStatus.value) + focusManager.clearFocus(true) + } + ), singleLine = true, trailingIcon = { if (hasChanged) { UserStatusSendButton() { - scope.launch(Dispatchers.IO) { - accountViewModel.createStatus(currentStatus.value) - focusManager.clearFocus(true) - } + accountViewModel.createStatus(currentStatus.value) + focusManager.clearFocus(true) } } } @@ -319,24 +326,26 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo ) }, keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Send, capitalization = KeyboardCapitalization.Sentences ), + keyboardActions = KeyboardActions( + onSend = { + accountViewModel.updateStatus(it, thisStatus.value) + focusManager.clearFocus(true) + } + ), singleLine = true, trailingIcon = { if (hasChanged) { UserStatusSendButton() { - scope.launch(Dispatchers.IO) { - accountViewModel.updateStatus(it, thisStatus.value) - focusManager.clearFocus(true) - } + accountViewModel.updateStatus(it, thisStatus.value) + focusManager.clearFocus(true) } } else { UserStatusDeleteButton() { - scope.launch(Dispatchers.IO) { - accountViewModel.updateStatus(it, "") - accountViewModel.delete(it, true) - focusManager.clearFocus(true) - } + accountViewModel.deleteStatus(it) + focusManager.clearFocus(true) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 9e4ff9981..f242c3815 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -120,9 +120,12 @@ sealed class Route( ) object Room : Route( - route = "Room/{id}", + route = "Room/{id}?message={message}", icon = R.drawable.ic_moments, - arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList() + arguments = listOf( + navArgument("id") { type = NavType.StringType }, + navArgument("message") { type = NavType.StringType; nullable = true; defaultValue = null } + ).toImmutableList() ) object RoomByAuthor : Route( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 5df050bf0..9c45a6a04 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -625,7 +625,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n Spacer(modifier = StdHorzSpacer) LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel) + ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav) } description?.let { @@ -739,7 +739,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou Spacer(modifier = StdHorzSpacer) LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel) + ZapReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav) } description?.let { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 4eee90c49..ce3dfb52f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -569,7 +569,7 @@ private fun StatusRow( Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) { LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav) Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel) + ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav) Spacer(modifier = DoubleHorzSpacer) ReplyReaction( baseNote = baseNote, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 618e052bf..b5164442b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ImageUrlType @@ -52,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller @@ -70,8 +68,6 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.overPictureBackground import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier import com.vitorpamplona.quartz.events.ImmutableListOfLists -import com.vitorpamplona.quartz.events.LnZapEvent -import com.vitorpamplona.quartz.events.LnZapRequestEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -352,8 +348,7 @@ private fun ParseAuthorCommentAndAmount( } LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { - launch(Dispatchers.IO) { - val newState = loadAmountState(zapRequest, zapEvent, accountViewModel) + accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState -> if (newState != null) { content.value = newState } @@ -363,34 +358,6 @@ private fun ParseAuthorCommentAndAmount( onReady(content) } -private suspend fun loadAmountState( - zapRequest: Note, - zapEvent: Note?, - accountViewModel: AccountViewModel -): ZapAmountCommentNotification? { - (zapRequest.event as? LnZapRequestEvent)?.let { - val decryptedContent = accountViewModel.decryptZap(zapRequest) - val amount = (zapEvent?.event as? LnZapEvent)?.amount - if (decryptedContent != null) { - val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) - return ZapAmountCommentNotification( - newAuthor, - decryptedContent.content.ifBlank { null }, - showAmountAxis(amount) - ) - } else { - if (!zapRequest.event?.content().isNullOrBlank() || amount != null) { - return ZapAmountCommentNotification( - zapRequest.author, - zapRequest.event?.content()?.ifBlank { null }, - showAmountAxis(amount) - ) - } - } - } - return null -} - @Composable private fun RenderState( content: MutableState, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 2acdcfaff..81a0d49c6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.components import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Icon @@ -33,7 +34,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.Nip05Verifier import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadStatuses import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon @@ -45,7 +45,8 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier -import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -53,13 +54,11 @@ import com.vitorpamplona.quartz.events.AddressableEvent import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds @Composable -fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState { +fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String, accountViewModel: AccountViewModel): MutableState { val nip05Verified = remember(userMetadata.nip05) { // starts with null if must verify or already filled in if verified in the last hour val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) { @@ -73,37 +72,9 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu if (nip05Verified.value == null) { LaunchedEffect(key1 = userMetadata.nip05) { - launch(Dispatchers.IO) { - userMetadata.nip05?.ifBlank { null }?.let { nip05 -> - Nip05Verifier().verifyNip05( - nip05, - onSuccess = { - // Marks user as verified - if (it == pubkeyHex) { - userMetadata.nip05Verified = true - userMetadata.nip05LastVerificationTime = TimeUtils.now() - - if (nip05Verified.value != true) { - nip05Verified.value = true - } - } else { - userMetadata.nip05Verified = false - userMetadata.nip05LastVerificationTime = 0 - - if (nip05Verified.value != false) { - nip05Verified.value = false - } - } - }, - onError = { - userMetadata.nip05LastVerificationTime = 0 - userMetadata.nip05Verified = false - - if (nip05Verified.value != false) { - nip05Verified.value = false - } - } - ) + accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus -> + if (nip05Verified.value != newVerificationStatus) { + nip05Verified.value = newVerificationStatus } } } @@ -154,7 +125,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine( Column(modifier = columnModifier) { Row(verticalAlignment = Alignment.CenterVertically) { if (nip05 != null) { - val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex) + val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel) if (nip05Verified.value != true) { DisplayNIP05(nip05, nip05Verified) @@ -180,7 +151,7 @@ fun RotateStatuses( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - var indexToDisplay by remember { + var indexToDisplay by remember(statuses) { mutableIntStateOf(0) } @@ -190,7 +161,7 @@ fun RotateStatuses( LaunchedEffect(Unit) { while (true) { delay(10.seconds) - indexToDisplay = ((indexToDisplay + 1) % (statuses.size + 1)) + indexToDisplay = (indexToDisplay + 1) % statuses.size } } } @@ -233,7 +204,7 @@ fun DisplayStatus( "music" -> Icon( painter = painterResource(id = R.drawable.tunestr), null, - modifier = Size18Modifier, + modifier = Size15Modifier.padding(end = Size5dp), tint = MaterialTheme.colors.placeholderText ) else -> {} @@ -249,6 +220,7 @@ fun DisplayStatus( if (url != null) { val uri = LocalUriHandler.current + Spacer(modifier = StdHorzSpacer) IconButton( modifier = Size15Modifier, onClick = { runCatching { uri.openUri(url.trim()) } } @@ -263,6 +235,7 @@ fun DisplayStatus( } else if (nostrATag != null) { LoadAddressableNote(nostrATag) { note -> if (note != null) { + Spacer(modifier = StdHorzSpacer) IconButton( modifier = Size15Modifier, onClick = { @@ -284,6 +257,7 @@ fun DisplayStatus( } else if (nostrHexID != null) { LoadNote(baseNoteHex = nostrHexID) { if (it != null) { + Spacer(modifier = StdHorzSpacer) IconButton( modifier = Size15Modifier, onClick = { @@ -353,12 +327,12 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState, modifier: } @Composable -fun DisplayNip05ProfileStatus(user: User) { +fun DisplayNip05ProfileStatus(user: User, accountViewModel: AccountViewModel) { val uri = LocalUriHandler.current user.nip05()?.let { nip05 -> if (nip05.split("@").size <= 2) { - val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex) + val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel) Row(verticalAlignment = Alignment.CenterVertically) { NIP05VerifiedSymbol(nip05Verified, Size16Modifier) var domainPadStart = 5.dp diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index c5e2971a5..56128b091 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -165,6 +165,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent +import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent @@ -441,9 +442,7 @@ fun WatchForReports( val noteReportsState by note.live().reports.observeAsState() LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) { - launch(Dispatchers.Default) { - accountViewModel.isNoteAcceptable(note, onChange) - } + accountViewModel.isNoteAcceptable(note, onChange) } } @@ -784,9 +783,9 @@ private fun ShortCommunityActionOptions( nav: (String) -> Unit ) { Spacer(modifier = StdHorzSpacer) - LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) + LikeReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav) Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel) + ZapReaction(baseNote = note, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav) WatchAddressableNoteFollows(note, accountViewModel) { isFollowing -> if (!isFollowing) { @@ -1238,6 +1237,16 @@ fun routeFor(note: Note, loggedIn: User): String? { return null } +fun routeToMessage(user: User, draftMessage: String?, accountViewModel: AccountViewModel): String { + val withKey = ChatroomKey(persistentSetOf(user.pubkeyHex)) + accountViewModel.account.userProfile().createChatroom(withKey) + return if (draftMessage != null) { + "Room/${withKey.hashCode()}?message=$draftMessage" + } else { + "Room/${withKey.hashCode()}" + } +} + fun routeFor(note: Channel): String { return "Channel/${note.idHex}" } @@ -1315,7 +1324,7 @@ fun RenderPoll( nav: (String) -> Unit ) { val noteEvent = note.event as? PollNoteEvent ?: return - val eventContent = remember { noteEvent.content() } + val eventContent = remember(note) { noteEvent.content() } if (makeItShort && accountViewModel.isLoggedUser(note.author)) { Text( @@ -2399,9 +2408,9 @@ private fun ReplyRow( ) { val noteEvent = note.event - val showReply by remember { + val showReply by remember(note) { derivedStateOf { - noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 1ae831a26..13253cfb2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -98,9 +98,7 @@ private fun WatchZapsAndUpdateTallies( val zapsState by baseNote.live().zaps.observeAsState() LaunchedEffect(key1 = zapsState) { - launch(Dispatchers.Default) { - pollViewModel.refreshTallies() - } + pollViewModel.refreshTallies() } } @@ -132,8 +130,7 @@ private fun OptionNote( ZapVote( baseNote, poolOption, - accountViewModel, - pollViewModel, + pollViewModel = pollViewModel, nonClickablePrepend = { RenderOptionAfterVote( poolOption.descriptor, @@ -147,18 +144,21 @@ private fun OptionNote( ) }, clickablePrepend = { - } + }, + accountViewModel = accountViewModel, + nav = nav ) } else { ZapVote( baseNote, poolOption, - accountViewModel, - pollViewModel, + pollViewModel = pollViewModel, nonClickablePrepend = {}, clickablePrepend = { RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav) - } + }, + accountViewModel = accountViewModel, + nav = nav ) } } @@ -269,11 +269,12 @@ private fun RenderOptionBeforeVote( fun ZapVote( baseNote: Note, poolOption: PollOption, - accountViewModel: AccountViewModel, - pollViewModel: PollNoteViewModel, modifier: Modifier = Modifier, + pollViewModel: PollNoteViewModel, nonClickablePrepend: @Composable () -> Unit, - clickablePrepend: @Composable () -> Unit + clickablePrepend: @Composable () -> Unit, + accountViewModel: AccountViewModel, + nav: (String) -> Unit ) { val isLoggedUser by remember { derivedStateOf { @@ -283,6 +284,7 @@ fun ZapVote( var wantsToZap by remember { mutableStateOf(false) } var zappingProgress by remember { mutableStateOf(0f) } + var showErrorMessageDialog by remember { mutableStateOf(null) } val context = LocalContext.current val scope = rememberCoroutineScope() @@ -384,7 +386,7 @@ fun ZapVote( onError = { scope.launch { zappingProgress = 0f - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + showErrorMessageDialog = it } }, onProgress = { @@ -395,6 +397,19 @@ fun ZapVote( ) } + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringResource(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + baseNote.author?.let { + nav(routeToMessage(it, showErrorMessageDialog, accountViewModel)) + } + }, + onDismiss = { showErrorMessageDialog = null } + ) + } + clickablePrepend() if (poolOption.zappedByLoggedIn) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index e9675f200..e5cd1c436 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -3,13 +3,16 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.events.* import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import java.math.BigDecimal import java.math.RoundingMode @@ -52,12 +55,12 @@ class PollNoteViewModel : ViewModel() { closedAt = pollEvent?.getTagInt(CLOSED_AT) } - suspend fun refreshTallies() { - totalZapped = totalZapped() - wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false + fun refreshTallies() { + viewModelScope.launch(Dispatchers.Default) { + totalZapped = totalZapped() + wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false - _tallies.emit( - pollOptions?.keys?.map { + val newOptions = pollOptions?.keys?.map { val zappedInOption = zappedPollOptionAmount(it) val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { @@ -71,8 +74,12 @@ class PollNoteViewModel : ViewModel() { val consensus = consensusThreshold != null && myTally >= consensusThreshold!! PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn) - } ?: emptyList() - ) + } + + _tallies.emit( + newOptions ?: emptyList() + ) + } } fun canZap(): Boolean { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 43a5a4fea..e12302cd9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -40,6 +40,7 @@ import androidx.compose.material.ProgressIndicatorDefaults import androidx.compose.material.Text import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -214,7 +215,7 @@ private fun InnerReactionRow( ) { val (value, elapsed) = measureTimedValue { Row(verticalAlignment = CenterVertically) { - ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel) + ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav) } } Log.d("Rendering Metrics", "Reaction Zaps: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") @@ -245,7 +246,7 @@ private fun LoadAndDisplayZapraiser( wantsToSeeReactions: MutableState, accountViewModel: AccountViewModel ) { - val zapraiserAmount by remember { + val zapraiserAmount by remember(baseNote) { derivedStateOf { baseNote.event?.zapraiserAmount() ?: 0 } @@ -265,42 +266,26 @@ private fun LoadAndDisplayZapraiser( } } +@Immutable +data class ZapraiserStatus(val progress: Float, val left: String) + @Composable fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) { val zapsState by baseNote.live().zaps.observeAsState() - var zapraiserProgress by remember { mutableStateOf(0F) } - var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") } + var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) } LaunchedEffect(key1 = zapsState) { - launch(Dispatchers.Default) { - zapsState?.note?.let { - val newZapAmount = accountViewModel.calculateZapAmount(it) - var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() - - if (percentage > 1) { - percentage = 1f - } - - if (Math.abs(zapraiserProgress - percentage) > 0.001) { - val newZapraiserProgress = percentage - val newZapraiserLeft = if (percentage > 0.99) { - "0" - } else { - showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) - } - if (zapraiserLeft != newZapraiserLeft) { - zapraiserLeft = newZapraiserLeft - } - if (zapraiserProgress != newZapraiserProgress) { - zapraiserProgress = newZapraiserProgress - } + zapsState?.note?.let { + accountViewModel.calculateZapraiser(baseNote) { newStatus -> + if (zapraiserStatus != newStatus) { + zapraiserStatus = newStatus } } } } - val color = if (zapraiserProgress > 0.99) { + val color = if (zapraiserStatus.progress > 0.99) { DarkerGreen } else { MaterialTheme.colors.mediumImportanceLink @@ -311,7 +296,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc .fillMaxWidth() .height(if (details) 24.dp else 4.dp), color = color, - progress = zapraiserProgress + progress = zapraiserStatus.progress ) if (details) { @@ -319,14 +304,14 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc contentAlignment = Center, modifier = TinyBorders ) { - val totalPercentage by remember(zapraiserProgress) { + val totalPercentage by remember(zapraiserStatus) { derivedStateOf { - "${(zapraiserProgress * 100).roundToInt()}%" + "${(zapraiserStatus.progress * 100).roundToInt()}%" } } Text( - text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft), + text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left), modifier = NoSoTinyBorders, color = MaterialTheme.colors.placeholderText, fontSize = Font14SP, @@ -338,15 +323,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc @Composable private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) { - val hasReactions by baseNote.live().zaps.combineWith( - liveData1 = baseNote.live().boosts, - liveData2 = baseNote.live().reactions, - block = { zapsState, boostsState, reactionsState -> - zapsState?.note?.zaps?.isNotEmpty() == true || - boostsState?.note?.boosts?.isNotEmpty() == true || - reactionsState?.note?.reactions?.isNotEmpty() == true - } - ).observeAsState( + val hasReactions by baseNote.live().hasReactions.observeAsState( baseNote.zaps.isNotEmpty() || baseNote.boosts.isNotEmpty() || baseNote.reactions.isNotEmpty() @@ -439,17 +416,16 @@ private fun WatchBoostsAndRenderGallery( nav: (String) -> Unit, accountViewModel: AccountViewModel ) { - val boostsState by baseNote.live().boosts.observeAsState() - val boostsEvents by remember(boostsState) { - derivedStateOf { baseNote.boosts.toImmutableList() } - } + val boostsEvents by baseNote.live().boostList.observeAsState() - if (boostsEvents.isNotEmpty()) { - RenderBoostGallery( - boostsEvents, - nav, - accountViewModel - ) + boostsEvents?.let { + if (it.isNotEmpty()) { + RenderBoostGallery( + it, + nav, + accountViewModel + ) + } } } @@ -909,10 +885,7 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi val reactionsState by baseNote.live().reactions.observeAsState() LaunchedEffect(key1 = reactionsState) { - launch(Dispatchers.Default) { - val reactionNote = reactionsState?.note?.getReactionBy(accountViewModel.userProfile()) - onNewReactionType(reactionNote) - } + accountViewModel.loadReactionTo(reactionsState?.note, onNewReactionType) } } @@ -947,32 +920,9 @@ private fun RenderReactionType( @Composable fun LikeText(baseNote: Note, grayTint: Color) { - val reactionsCount = remember(baseNote) { - mutableStateOf(baseNote.reactions.size) - } + val reactionCount by baseNote.live().reactionCount.observeAsState(0) - val scope = rememberCoroutineScope() - - WatchReactionCountForNote(baseNote) { newReactionsCount -> - if (reactionsCount.value != newReactionsCount) { - scope.launch(Dispatchers.Main) { - reactionsCount.value = newReactionsCount - } - } - } - - SlidingAnimationCount(reactionsCount, grayTint) -} - -@Composable -private fun WatchReactionCountForNote(baseNote: Note, onNewReactionCount: (Int) -> Unit) { - val reactionsState by baseNote.live().reactions.observeAsState() - - LaunchedEffect(key1 = reactionsState) { - launch(Dispatchers.Default) { - onNewReactionCount(reactionsState?.note?.countReactions() ?: 0) - } - } + SlidingAnimationCount(reactionCount, grayTint) } private fun likeClick( @@ -1028,11 +978,13 @@ fun ZapReaction( grayTint: Color, accountViewModel: AccountViewModel, iconSize: Dp = 20.dp, - animationSize: Dp = 14.dp + animationSize: Dp = 14.dp, + nav: (String) -> Unit ) { var wantsToZap by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } + var showErrorMessageDialog by remember { mutableStateOf(null) } val context = LocalContext.current val scope = rememberCoroutineScope() @@ -1084,7 +1036,7 @@ fun ZapReaction( onError = { scope.launch { zappingProgress = 0f - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + showErrorMessageDialog = it } }, onProgress = { @@ -1095,6 +1047,19 @@ fun ZapReaction( ) } + if (showErrorMessageDialog != null) { + ErrorMessageDialog( + title = stringResource(id = R.string.error_dialog_zap_error), + textContent = showErrorMessageDialog ?: "", + onClickStartMessage = { + baseNote.author?.let { + nav(routeToMessage(it, showErrorMessageDialog, accountViewModel)) + } + }, + onDismiss = { showErrorMessageDialog = null } + ) + } + if (wantsToChangeZapAmount) { UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel) } @@ -1217,9 +1182,7 @@ private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel, val zapsState by baseNote.live().zaps.observeAsState() LaunchedEffect(key1 = zapsState) { - launch(Dispatchers.Default) { - onWasZapped(accountViewModel.calculateIfNoteWasZappedByAccount(baseNote)) - } + accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, onWasZapped) } } @@ -1249,9 +1212,7 @@ fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, o val zapsState by baseNote.live().zaps.observeAsState() LaunchedEffect(key1 = zapsState) { - launch(Dispatchers.Default) { - onZapAmount(showAmount(accountViewModel.calculateZapAmount(baseNote))) - } + accountViewModel.calculateZapAmount(baseNote, onZapAmount) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 80cbd63da..591553867 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -4,11 +4,14 @@ import android.widget.Toast import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Done import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType @@ -111,8 +114,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas onError = { zappingProgress = 0f scope.launch { - Toast - .makeText(context, it, Toast.LENGTH_SHORT).show() + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } }, onProgress = { @@ -225,3 +227,51 @@ fun ZapButton(isActive: Boolean, onPost: () -> Unit) { Text(text = "⚡Zap ", color = Color.White) } } + +@Composable +fun ErrorMessageDialog( + title: String, + textContent: String, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onClickStartMessage: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(title) + }, + text = { + Text(textContent) + }, + buttons = { + Row( + modifier = Modifier + .padding(all = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onClickStartMessage) { + Icon( + painter = painterResource(R.drawable.ic_dm), + contentDescription = null + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.error_dialog_talk_to_user)) + } + Button(onClick = onDismiss, colors = buttonColors) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Done, + contentDescription = null + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.error_dialog_button_ok)) + } + } + } + } + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index c7087e1a7..e35a1fcb0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterialApi::class) @Composable @@ -102,11 +101,9 @@ private fun WatchScrollToTop( val scrollToTop by viewModel.scrollToTop.collectAsState() LaunchedEffect(scrollToTop) { - launch { - if (scrollToTop > 0 && viewModel.scrolltoTopPending) { - listState.scrollToItem(index = 0) - viewModel.sentToTop() - } + if (scrollToTop > 0 && viewModel.scrolltoTopPending) { + listState.scrollToItem(index = 0) + viewModel.sentToTop() } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 04d8a249c..4d521730a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue @Stable @@ -270,7 +269,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { private val bundler = BundledUpdate(1000, Dispatchers.IO) private val bundlerInsert = BundledInsert>(1000, Dispatchers.IO) - @OptIn(ExperimentalTime::class) fun invalidateData(ignoreIfDoing: Boolean = false) { bundler.invalidate(ignoreIfDoing) { // adds the time to perform the refresh into this delay @@ -282,7 +280,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - @OptIn(ExperimentalTime::class) fun invalidateDataAndSendToTop() { clear() bundler.invalidate(false) { @@ -296,7 +293,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - @OptIn(ExperimentalTime::class) fun checkKeysInvalidateDataAndSendToTop() { if (lastFeedKey != localFilter.feedKey()) { clear() @@ -312,7 +308,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - @OptIn(ExperimentalTime::class) fun invalidateInsertData(newItems: Set) { bundlerInsert.invalidateList(newItems) { val newObjects = it.flatten().toSet() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index fd1d6aafa..7561b818d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -236,7 +236,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel(), I checkNotInMainThread() lastFeedKey = localFilter.feedKey() - val notes = localFilter.loadTop().toImmutableList() + val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList() val oldNotesState = _feedContent.value if (oldNotesState is FeedState.Loaded) { @@ -301,11 +301,13 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel(), I fun checkKeysInvalidateDataAndSendToTop() { if (lastFeedKey != localFilter.feedKey()) { - bundler.invalidate(false) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - refreshSuspended() - sendToTop() + viewModelScope.launch(Dispatchers.IO) { + bundler.invalidate(false) { + // adds the time to perform the refresh into this delay + // holding off new updates in case of heavy refresh routines. + refreshSuspended() + sendToTop() + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 77204af42..556ff39d3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -16,20 +16,31 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.ConnectivityType +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.UserState +import com.vitorpamplona.amethyst.service.Nip05Verifier +import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.components.UrlPreviewState +import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification +import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus +import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.LnZapEvent +import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.SealedGossipEvent +import com.vitorpamplona.quartz.events.UserMetadata +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableSet @@ -128,21 +139,86 @@ class AccountViewModel(val account: Account) : ViewModel() { return account.delete(account.boostsTo(note), signEvent) } - fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { - return account.calculateIfNoteWasZappedByAccount(zappedNote) + fun calculateIfNoteWasZappedByAccount(zappedNote: Note, onWasZapped: (Boolean) -> Unit) { + viewModelScope.launch(Dispatchers.Default) { + onWasZapped(account.calculateIfNoteWasZappedByAccount(zappedNote)) + } } - fun calculateZapAmount(zappedNote: Note): BigDecimal { + suspend fun calculateZapAmount(zappedNote: Note): BigDecimal { return account.calculateZappedAmount(zappedNote) } + fun calculateZapAmount(zappedNote: Note, onZapAmount: (String) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + onZapAmount(showAmount(account.calculateZappedAmount(zappedNote))) + } + } + + fun calculateZapraiser(zappedNote: Note, onZapraiserStatus: (ZapraiserStatus) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val zapraiserAmount = zappedNote.event?.zapraiserAmount() ?: 0 + val newZapAmount = calculateZapAmount(zappedNote) + var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() + + if (percentage > 1) { + percentage = 1f + } + + val newZapraiserProgress = percentage + val newZapraiserLeft = if (percentage > 0.99) { + "0" + } else { + showAmount((zapraiserAmount * (1 - percentage)).toBigDecimal()) + } + onZapraiserStatus(ZapraiserStatus(newZapraiserProgress, newZapraiserLeft)) + } + } + + fun decryptAmountMessage( + zapRequest: Note, + zapEvent: Note?, + onNewState: (ZapAmountCommentNotification?) -> Unit + ) { + viewModelScope.launch(Dispatchers.IO) { + onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)) + } + } + + private suspend fun innerDecryptAmountMessage( + zapRequest: Note, + zapEvent: Note? + ): ZapAmountCommentNotification? { + (zapRequest.event as? LnZapRequestEvent)?.let { + val decryptedContent = decryptZap(zapRequest) + val amount = (zapEvent?.event as? LnZapEvent)?.amount + if (decryptedContent != null) { + val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) + return ZapAmountCommentNotification( + newAuthor, + decryptedContent.content.ifBlank { null }, + showAmountAxis(amount) + ) + } else { + if (!zapRequest.event?.content().isNullOrBlank() || amount != null) { + return ZapAmountCommentNotification( + zapRequest.author, + zapRequest.event?.content()?.ifBlank { null }, + showAmountAxis(amount) + ) + } + } + } + return null + } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { viewModelScope.launch(Dispatchers.IO) { innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType) } } - suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { + private suspend fun innerZap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() if (lud16.isNullOrBlank()) { @@ -193,9 +269,12 @@ class AccountViewModel(val account: Account) : ViewModel() { onProgress(0f) } } else { - runCatching { + try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK ContextCompat.startActivity(context, intent, null) + } catch (e: Exception) { + onError(context.getString(R.string.lightning_wallets_not_found)) } onProgress(0f) } @@ -347,24 +426,26 @@ class AccountViewModel(val account: Account) : ViewModel() { } fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet) -> Unit) { - val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex - val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true + viewModelScope.launch { + val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex + val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true - if (isFromLoggedIn || isFromLoggedInFollow) { - // No need to process if from trusted people - onReady(true, true, persistentSetOf()) - } else { - val newCanPreview = !note.hasAnyReports() - - val newIsAcceptable = account.isAcceptable(note) - - if (newCanPreview && newIsAcceptable) { - // No need to process reports if nothing is wrong + if (isFromLoggedIn || isFromLoggedInFollow) { + // No need to process if from trusted people onReady(true, true, persistentSetOf()) } else { - val newRelevantReports = account.getRelevantReports(note) + val newCanPreview = !note.hasAnyReports() - onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet()) + val newIsAcceptable = account.isAcceptable(note) + + if (newCanPreview && newIsAcceptable) { + // No need to process reports if nothing is wrong + onReady(true, true, persistentSetOf()) + } else { + val newRelevantReports = account.getRelevantReports(note) + + onReady(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet()) + } } } } @@ -395,11 +476,72 @@ class AccountViewModel(val account: Account) : ViewModel() { } fun createStatus(newStatus: String) { - account.createStatus(newStatus) + viewModelScope.launch(Dispatchers.IO) { + account.createStatus(newStatus) + } } fun updateStatus(it: AddressableNote, newStatus: String) { - account.updateStatus(it, newStatus) + viewModelScope.launch(Dispatchers.IO) { + account.updateStatus(it, newStatus) + } + } + + fun deleteStatus(it: AddressableNote) { + viewModelScope.launch(Dispatchers.IO) { + account.deleteStatus(it) + } + } + + fun checkIfOnline(url: String, onResult: (Boolean) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val isOnline = OnlineChecker.isOnline(url) + onResult(isOnline) + } + } + + fun urlPreview(url: String, onResult: suspend (UrlPreviewState) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + UrlCachedPreviewer.previewInfo(url, onResult) + } + } + + fun loadReactionTo(note: Note?, onNewReactionType: (String?) -> Unit) { + if (note == null) return + + viewModelScope.launch(Dispatchers.Default) { + onNewReactionType(note.getReactionBy(userProfile())) + } + } + + fun verifyNip05(userMetadata: UserMetadata, pubkeyHex: String, onResult: (Boolean) -> Unit) { + val nip05 = userMetadata.nip05?.ifBlank { null } ?: return + + viewModelScope.launch(Dispatchers.IO) { + Nip05Verifier().verifyNip05( + nip05, + onSuccess = { + // Marks user as verified + if (it == pubkeyHex) { + userMetadata.nip05Verified = true + userMetadata.nip05LastVerificationTime = TimeUtils.now() + + onResult(userMetadata.nip05Verified) + } else { + userMetadata.nip05Verified = false + userMetadata.nip05LastVerificationTime = 0 + + onResult(userMetadata.nip05Verified) + } + }, + onError = { + userMetadata.nip05LastVerificationTime = 0 + userMetadata.nip05Verified = false + + onResult(userMetadata.nip05Verified) + } + ) + } } class Factory(val account: Account) : ViewModelProvider.Factory { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 1f9647184..6709b91d9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -626,7 +626,7 @@ fun ShowVideoStreaming( } url?.let { - CheckIfUrlIsOnline(url) { + CheckIfUrlIsOnline(url, accountViewModel) { Row( verticalAlignment = Alignment.CenterVertically, modifier = remember { Modifier.heightIn(max = 300.dp) } @@ -896,7 +896,7 @@ private fun ShortChannelActionOptions( Spacer(modifier = StdHorzSpacer) LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel) + ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav) Spacer(modifier = StdHorzSpacer) } } @@ -971,7 +971,7 @@ private fun LiveChannelActionOptions( LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) Spacer(modifier = StdHorzSpacer) - ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel) + ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index f57985aa5..5719805dd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -106,6 +106,7 @@ import kotlinx.coroutines.withContext @Composable fun ChatroomScreen( roomId: String?, + draftMessage: String? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { @@ -115,6 +116,7 @@ fun ChatroomScreen( it?.let { PrepareChatroomViewModels( room = it, + draftMessage = draftMessage, accountViewModel = accountViewModel, nav = nav ) @@ -125,6 +127,7 @@ fun ChatroomScreen( @Composable fun ChatroomScreenByAuthor( authorPubKeyHex: String?, + draftMessage: String? = null, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { @@ -134,6 +137,7 @@ fun ChatroomScreenByAuthor( it?.let { PrepareChatroomViewModels( room = it, + draftMessage = draftMessage, accountViewModel = accountViewModel, nav = nav ) @@ -171,7 +175,12 @@ fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel } @Composable -fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewModel, nav: (String) -> Unit) { +fun PrepareChatroomViewModels( + room: ChatroomKey, + draftMessage: String?, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { val feedViewModel: NostrChatroomFeedViewModel = viewModel( key = room.hashCode().toString() + "ChatroomViewModels", factory = NostrChatroomFeedViewModel.Factory( @@ -198,6 +207,12 @@ fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewMo } } + if (draftMessage != null) { + LaunchedEffect(key1 = draftMessage) { + newPostModel.message = TextFieldValue(draftMessage) + } + } + ChatroomScreen( room = room, feedViewModel = feedViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt index 36de0257a..3b146279b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt @@ -29,7 +29,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.NostrHomeDataSource -import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog import com.vitorpamplona.amethyst.ui.screen.FeedViewModel @@ -42,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @@ -136,12 +134,14 @@ private fun HomePages( } @Composable -fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) { +fun CheckIfUrlIsOnline(url: String, accountViewModel: AccountViewModel, whenOnline: @Composable () -> Unit) { var online by remember { mutableStateOf(false) } LaunchedEffect(key1 = url) { - launch(Dispatchers.IO) { - online = OnlineChecker.isOnline(url) + accountViewModel.checkIfOnline(url) { isOnline -> + if (online != isOnline) { + online = isOnline + } } } @@ -162,11 +162,9 @@ fun WatchAccountForHomeScreen( val followState by accountViewModel.account.userProfile().live().follows.observeAsState() LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList, followState) { - launch(Dispatchers.IO) { - NostrHomeDataSource.invalidateFilters() - homeFeedViewModel.checkKeysInvalidateDataAndSendToTop() - repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop() - } + NostrHomeDataSource.invalidateFilters() + homeFeedViewModel.checkKeysInvalidateDataAndSendToTop() + repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index d48a598c9..7687ea956 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -73,42 +73,38 @@ fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, nav fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { val noteState by baseNote.live().metadata.observeAsState() - val scope = rememberCoroutineScope() - LaunchedEffect(key1 = noteState) { - scope.launch { - val note = noteState?.note ?: return@launch - var event = note.event - val channelHex = note.channelHex() + val note = noteState?.note ?: return@LaunchedEffect + var event = note.event + val channelHex = note.channelHex() - if (event is GiftWrapEvent) { - event = accountViewModel.unwrap(event) - } + if (event is GiftWrapEvent) { + event = accountViewModel.unwrap(event) + } - if (event is SealedGossipEvent) { - event = accountViewModel.unseal(event) - } + if (event is SealedGossipEvent) { + event = accountViewModel.unseal(event) + } - if (event == null) { - // stay here, loading - } else if (event is ChannelCreateEvent) { - nav("Channel/${note.idHex}") - } else if (event is ChatroomKeyable) { - note.author?.let { - val withKey = (event as ChatroomKeyable) - .chatroomKey(accountViewModel.userProfile().pubkeyHex) + if (event == null) { + // stay here, loading + } else if (event is ChannelCreateEvent) { + nav("Channel/${note.idHex}") + } else if (event is ChatroomKeyable) { + note.author?.let { + val withKey = (event as ChatroomKeyable) + .chatroomKey(accountViewModel.userProfile().pubkeyHex) - withContext(Dispatchers.IO) { - accountViewModel.userProfile().createChatroom(withKey) - } - - nav("Room/${withKey.hashCode()}") + withContext(Dispatchers.IO) { + accountViewModel.userProfile().createChatroom(withKey) } - } else if (channelHex != null) { - nav("Channel/$channelHex") - } else { - nav("Note/${note.idHex}") + + nav("Room/${withKey.hashCode()}") } + } else if (channelHex != null) { + nav("Channel/$channelHex") + } else { + nav("Note/${note.idHex}") } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 46837fe79..084e354d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -955,7 +955,7 @@ private fun DrawAdditionalInfo( DisplayBadges(baseUser, nav) - DisplayNip05ProfileStatus(user) + DisplayNip05ProfileStatus(user, accountViewModel) val website = user.info?.website if (!website.isNullOrEmpty()) { @@ -1085,9 +1085,18 @@ fun DisplayLNAddress( } } } else { - runCatching { + try { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK ContextCompat.startActivity(context, intent, null) + } catch (e: Exception) { + scope.launch { + Toast.makeText( + context, + context.getString(R.string.lightning_wallets_not_found), + Toast.LENGTH_LONG + ).show() + } } } }, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt index 06ea14cec..a7107e819 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ThreadScreen.kt @@ -26,9 +26,10 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri factory = NostrThreadFeedViewModel.Factory(noteId) ) + NostrThreadDataSource.loadThread(noteId) + LaunchedEffect(noteId) { - NostrThreadDataSource.loadThread(noteId) - feedViewModel.invalidateData() + feedViewModel.invalidateData(true) } DisposableEffect(accountViewModel) { @@ -37,7 +38,7 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri println("Thread Start") NostrThreadDataSource.loadThread(noteId) NostrThreadDataSource.start() - feedViewModel.invalidateData() + feedViewModel.invalidateData(true) } if (event == Lifecycle.Event.ON_PAUSE) { println("Thread Stop") diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 1ce0efd78..e42350ae1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -467,7 +467,7 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St wantsToQuote = baseNote } LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, nav, iconSize = 40.dp, heartSize = Size35dp, 28.sp) - ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = Size35dp) + ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = Size35dp, nav = nav) ViewCountReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, barChartSize = 39.dp, viewCountColorFilter = MaterialTheme.colors.onBackgroundColorFilter) } } diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3cc7fe6d9..0c7928d6e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -95,7 +95,7 @@ Chargement… L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats "Répondre ici.. " - Copie l\'ID de note dans le presse-papiers pour le partage + Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr Copier l\'ID de la chaîne (Note) dans le presse-papiers Modifie les métadonnées du canal Joindre @@ -474,9 +474,9 @@ Préférences de l\'application Langage Thème - Chargement automatique des images/gifs - Démarrage automatique des vidéos - Prévisualisation automatique des liens + Prévisualisation des images + Lecture vidéo + Prévisualisation des URLs Charger l\'image Spammeurs @@ -499,4 +499,41 @@ Ajoute un Geohash de votre emplacement au message. Le public saura que vous êtes à moins de 5km de l\'emplacement actuel Ajoute un avertissement de contenu sensible avant de montrer votre contenu. C\'est idéal pour tout contenu NSFW ou contenu que certaines personnes peuvent trouver offensant ou dérangeant + + Nouvelle Fonctionnalité + Pour activer ce mode, Amethyst doit envoyer un message NIP-24 (GiftWrapped, Sealed Direct et Group Messages). Le protocole NIP-24 est nouveau et la plupart des clients ne l\'ont pas encore mis en oeuvre. Assurez-vous que le destinataire utilise un client compatible. + Activer + + Public + Privé + À + Sujet + Sujet de la conversation + "@Utilisateur1, @Utilisateur2, @Utilisateur3" + + Membres de ce groupe + Explication aux membres + Changement de nom pour les nouveaux objectifs. + + Pour l\'interface de l\'App + Sombre, Clair ou thème Système + Charger automatiquement les images et les GIFs + Lire automatiquement les vidéos et les GIFs + Afficher la prévisualisation d\'URL + Quand charger les images + + Copier l\'URL dans le presse-papiers + Copier l\'ID de la note dans le presse-papiers + + Créé le + Règles + + Mettez à jour votre statut + + Erreur d\'analyse du message d\'erreur + Les votes sont pondérés par le montant du zap. Vous pouvez définir un montant minimum pour éviter les spammeurs et un montant maximum pour éviter qu\'un grand nombre de zappeurs ne prenne le contrôle du sondage. Utilisez le même montant dans les deux champs pour vous assurer que chaque vote a la même valeur. Laissez le champ vide pour accepter n\'importe quel montant. + + Impossible d\'envoyer un zap + Contacter l\'Utilisateur + Ok diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9098a2aee..e746df58b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -356,7 +356,7 @@ \n4. Ha szükséges az Orbot-ban változtasd meg a portot \n5. Ezen a felületen állítsd be a Socks portot \n6. Kattíts az Aktiválás gombra, hogy az Orbot-ot átjátszóként használd - + ít Orbot Socks portja Érvénytelen Port szám Használd az Orbot-ot @@ -476,4 +476,48 @@ A Nostr cím ellenőrzésre került A Nostr cím ellenőrzése sikeretelen A Nostr cím ellenőrzése + Mind kijelölése/kijelölés visszavonása + Alapértelmezett + A folytatáshoz válassz egy csomópontot + + Zap-ek továbbítása: + A funkciót támogató kliensek a Zap-eket az Ön tárcája helyett, az alábbi LN-címre vagy felhasználói profilra továbbítják + + A Hely megjelenítése mint + A bejegyzéshez az Ön tartózkodási helyének Geohash-ét hozzáadja. A közönség tudni fogja, hogy az aktuális helytől 5 km-en (3 mérföldön) belül van + + A kényes tartalom miatt, azon megjelenítése előtt figyelmeztetést ad. Ez ideális minden Felnőtt tartalomhoz vagy olyan tartalomhoz, amelyet egyesek sértőnek vagy zavarónak találhatnak + + Új funkció + Az Amethystnek ennek a módnak az aktiválásához NIP-24 üzenetet kell küldenie (GiftWrapped, Zárolt direkt és csoportos üzeneteket). A NIP-24 új, és a legtöbb kliens még nem implementálta. Győződj meg arról, hogy a fogadó fél kompatibilis klienst használ. + Aktiválás + + Publikus + Privát + Címzett + Téma + A beszélgetés témája + "@Felhasználó1, @Felhasználó2, @UFelhasználó3" + + A csoport tagjai + Magyarázat a csoport tagjainak + Az új célok érdekében, a név megváltoztatása. + + Az applikáció felülete + Sötét, Világos vagy Rendszer által használt téma + Képek és GIF-ek automatikus betöltése + A videók és a GIF-ek automatikus lejátszása + URL előnézetek megjelenítése + Mikor kell a képeket betölteni + + Az URL vágólapra másolása + A bejegyzésazonosító vágólapra másolása + + Létrehozva + Szabályok + + Állapotod változtatása + + Hiba a hibaüzenet elemzésekor + A szavazatokat a Zap-ek összegével súlyozzuk. Beállíthatsz egy minimális összeget, hogy a kéretlen leveleket elkerüld, és egy maximális összeget annak elkerülésére, hogy a szavazás feletti irányítást a nagy Zapperek vegyék át. Mindkét mezőben ugyanazt az összeget használd, hogy minden szavazat azonos értéket kapjon. Bármilyen összeg elfogadásához, hagyjd üresen. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2af5d61c4..07b4bea2f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -555,4 +555,11 @@ Login with Amber Update your status + + Error parsing error message + Votes are weighted by the zap amount. You can set a minimum amount to avoid spammers and a maximum amount to avoid a large zappers taking over the poll. Use the same amount in both fields to make sure every vote is valued the same amount. Leave it empty to accept any amount. + + Unable to send zap + Message the User + Ok diff --git a/build.gradle b/build.gradle index f00613616..7e7e64f1c 100644 --- a/build.gradle +++ b/build.gradle @@ -5,13 +5,13 @@ buildscript { fragment_version = "1.6.1" lifecycle_version = '2.6.1' compose_ui_version = '1.5.0' - nav_version = "2.7.0" + nav_version = "2.7.1" room_version = "2.4.3" accompanist_version = '0.30.1' coil_version = '2.4.0' - vico_version = '1.9.2' + vico_version = '1.11.0' exoplayer_version = '1.1.1' - media3_version = '1.1.0' + media3_version = '1.1.1' core_ktx_version = '1.10.1' } dependencies { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 7026382df..5aa91ebf3 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -72,7 +72,7 @@ open class Event( override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } override fun firstTaggedEvent() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.let { it[1] } override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] } - override fun firstTaggedAddress() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { + override fun firstTaggedAddress() = tags.firstOrNull { it.size > 1 && it[0] == "a" }?.let { val aTagValue = it[1] val relay = it.getOrNull(2) @@ -218,7 +218,7 @@ open class Event( return try { hasCorrectIDHash() && hasVerifedSignature() } catch (e: Exception) { - Log.e("Event", "Event $id does not have a valid signature: ${toJson()}", e) + Log.w("Event", "Event $id does not have a valid signature: ${toJson()}", e) false } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt index 299335c96..9ad3a2c55 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/StatusEvent.kt @@ -50,5 +50,18 @@ class StatusEvent( val sig = CryptoUtils.sign(id, privateKey) return StatusEvent(id.toHexKey(), pubKey, createdAt, tags, newStatus, sig.toHexKey()) } + + fun clear( + event: StatusEvent, + privateKey: ByteArray, + createdAt: Long = TimeUtils.now() + ): StatusEvent { + val msg = "" + val tags = event.tags.filter { it.size > 1 && it[0] == "d" } + val pubKey = event.pubKey() + val id = generateId(pubKey, createdAt, kind, tags, msg) + val sig = CryptoUtils.sign(id, privateKey) + return StatusEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) + } } }