Merge branch 'main' into amber

This commit is contained in:
greenart7c3
2023-08-28 07:06:52 -03:00
committed by GitHub
61 changed files with 1096 additions and 681 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ height="80">](https://github.com/vitorpamplona/amethyst/releases)
- [x] Gift Wraps & Seals (NIP-59) - [x] Gift Wraps & Seals (NIP-59)
- [x] Versioned Encrypted Payloads (NIP-44) - [x] Versioned Encrypted Payloads (NIP-44)
- [x] Expiration Support (NIP-40) - [x] Expiration Support (NIP-40)
- [x] Status Event (NIP-315) - [x] Status Event (NIP-38)
- [ ] Marketplace (NIP-15) - [ ] Marketplace (NIP-15)
- [ ] Image/Video Capture in the app - [ ] Image/Video Capture in the app
- [ ] Local Database - [ ] Local Database
+3 -3
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 34 targetSdk 34
versionCode 281 versionCode 287
versionName "0.75.2" versionName "0.75.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -171,7 +171,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1' playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications // 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' playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts // Charts
@@ -122,7 +122,11 @@ object ServiceManager {
isStarted = false isStarted = false
} }
fun cleanUp() { fun cleanObservers() {
LocalCache.cleanObservers()
}
fun trimMemory() {
LocalCache.cleanObservers() LocalCache.cleanObservers()
val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet() val accounts = LocalPreferences.allLocalAccountNPubs().mapNotNull { decodePublicKeyAsHexOrNull(it) }.toSet()
@@ -103,7 +103,8 @@ class Account(
val showSensitiveContent: Boolean? val showSensitiveContent: Boolean?
) )
val liveHiddenUsers: LiveData<LiveHiddenUsers> = live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent -> val liveHiddenUsers: LiveData<LiveHiddenUsers> by lazy {
live.combineWith(getBlockListNote().live().metadata) { localLive, liveMuteListEvent ->
val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey) val liveBlockedUsers = (liveMuteListEvent?.note?.event as? PeopleListEvent)?.publicAndPrivateUsers(keyPair.privKey)
LiveHiddenUsers( LiveHiddenUsers(
hiddenUsers = liveBlockedUsers ?: persistentSetOf(), hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
@@ -111,6 +112,7 @@ class Account(
showSensitiveContent = showSensitiveContent showSensitiveContent = showSensitiveContent
) )
}.distinctUntilChanged() }.distinctUntilChanged()
}
var userProfileCache: User? = null var userProfileCache: User? = null
@@ -1192,6 +1194,21 @@ class Account(
LocalCache.consume(event, null) 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) { fun removeEmojiPack(usersEmojiList: Note, emojiList: Note) {
if (!isWriteable()) return if (!isWriteable()) return
@@ -164,20 +164,12 @@ class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelStat
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
if (channel is PublicChatChannel) { NostrSingleChannelDataSource.add(channel)
NostrSingleChannelDataSource.add(channel.idHex)
} else {
NostrSingleChannelDataSource.add(channel.idHex)
}
} }
override fun onInactive() { override fun onInactive() {
super.onInactive() super.onInactive()
if (channel is PublicChatChannel) { NostrSingleChannelDataSource.remove(channel)
NostrSingleChannelDataSource.remove(channel.idHex)
} else {
NostrSingleChannelDataSource.remove(channel.idHex)
}
} }
} }
@@ -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<Note> = 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<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
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
}
}
@@ -17,7 +17,6 @@ import com.vitorpamplona.quartz.encoders.bechToBytes
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.* import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -372,7 +371,7 @@ object LocalCache {
if (event.createdAt > (note.createdAt() ?: 0)) { if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList()) note.loadEvent(event, author, emptyList())
author.liveSet?.statuses?.invalidateData() author.liveSet?.innerStatuses?.invalidateData()
refreshObservers(note) refreshObservers(note)
} }
@@ -662,7 +661,7 @@ object LocalCache {
mentions.forEach { mentions.forEach {
// doesn't add to reports, but triggers recounts // doesn't add to reports, but triggers recounts
it.liveSet?.reports?.invalidateData() it.liveSet?.innerReports?.invalidateData()
} }
} }
@@ -1318,8 +1317,6 @@ object LocalCache {
fun pruneExpiredEvents() { fun pruneExpiredEvents() {
checkNotInMainThread() checkNotInMainThread()
val now = TimeUtils.now()
val toBeRemoved = notes.filter { val toBeRemoved = notes.filter {
it.value.event?.isExpired() == true it.value.event?.isExpired() == true
}.values }.values
@@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji 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.encoders.toNote
import com.vitorpamplona.quartz.events.* import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
@@ -126,7 +127,7 @@ open class Note(val idHex: String) {
this.author = author this.author = author
this.replyTo = replyTo this.replyTo = replyTo
liveSet?.metadata?.invalidateData() liveSet?.innerMetadata?.invalidateData()
} }
} }
@@ -174,21 +175,21 @@ open class Note(val idHex: String) {
fun addReply(note: Note) { fun addReply(note: Note) {
if (note !in replies) { if (note !in replies) {
replies = replies + note replies = replies + note
liveSet?.replies?.invalidateData() liveSet?.innerReplies?.invalidateData()
} }
} }
fun removeReply(note: Note) { fun removeReply(note: Note) {
if (note in replies) { if (note in replies) {
replies = replies - note replies = replies - note
liveSet?.replies?.invalidateData() liveSet?.innerReplies?.invalidateData()
} }
} }
fun removeBoost(note: Note) { fun removeBoost(note: Note) {
if (note in boosts) { if (note in boosts) {
boosts = boosts - note boosts = boosts - note
liveSet?.boosts?.invalidateData() liveSet?.innerBoosts?.invalidateData()
} }
} }
@@ -211,11 +212,11 @@ open class Note(val idHex: String) {
relays = listOf<String>() relays = listOf<String>()
lastReactionsDownloadTime = emptyMap() lastReactionsDownloadTime = emptyMap()
liveSet?.replies?.invalidateData() liveSet?.innerReplies?.invalidateData()
liveSet?.reactions?.invalidateData() liveSet?.innerReactions?.invalidateData()
liveSet?.boosts?.invalidateData() liveSet?.innerBoosts?.invalidateData()
liveSet?.reports?.invalidateData() liveSet?.innerReports?.invalidateData()
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
return toBeRemoved return toBeRemoved
} }
@@ -234,7 +235,7 @@ open class Note(val idHex: String) {
reactions = reactions + Pair(reaction, newList) 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) { if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let { reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote)) 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) { fun removeZap(note: Note) {
if (zaps[note] != null) { if (zaps[note] != null) {
zaps = zaps.minus(note) zaps = zaps.minus(note)
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} else if (zaps.containsValue(note)) { } else if (zaps.containsValue(note)) {
zaps = zaps.filterValues { it != note } zaps = zaps.filterValues { it != note }
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} }
} }
fun removeZapPayment(note: Note) { fun removeZapPayment(note: Note) {
if (zapPayments[note] != null) { if (zapPayments[note] != null) {
zapPayments = zapPayments.minus(note) zapPayments = zapPayments.minus(note)
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} else if (zapPayments.containsValue(note)) { } else if (zapPayments.containsValue(note)) {
val toRemove = zapPayments.filterValues { it == note } val toRemove = zapPayments.filterValues { it == note }
zapPayments = zapPayments.minus(toRemove.keys) zapPayments = zapPayments.minus(toRemove.keys)
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} }
} }
fun addBoost(note: Note) { fun addBoost(note: Note) {
if (note !in boosts) { if (note !in boosts) {
boosts = boosts + note boosts = boosts + note
liveSet?.boosts?.invalidateData() liveSet?.innerBoosts?.invalidateData()
} }
} }
@@ -297,12 +298,12 @@ open class Note(val idHex: String) {
if (zapRequest !in zaps.keys) { if (zapRequest !in zaps.keys) {
val inserted = innerAddZap(zapRequest, zap) val inserted = innerAddZap(zapRequest, zap)
if (inserted) { if (inserted) {
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} }
} else if (zaps[zapRequest] == null) { } else if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap) val inserted = innerAddZap(zapRequest, zap)
if (inserted) { if (inserted) {
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} }
} }
} }
@@ -325,12 +326,12 @@ open class Note(val idHex: String) {
if (zapPaymentRequest !in zapPayments.keys) { if (zapPaymentRequest !in zapPayments.keys) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) { if (inserted) {
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} }
} else if (zapPayments[zapPaymentRequest] == null) { } else if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment) val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) { if (inserted) {
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} }
} }
} }
@@ -341,10 +342,10 @@ open class Note(val idHex: String) {
if (reaction !in reactions.keys) { if (reaction !in reactions.keys) {
reactions = reactions + Pair(reaction, listOf(note)) reactions = reactions + Pair(reaction, listOf(note))
liveSet?.reactions?.invalidateData() liveSet?.innerReactions?.invalidateData()
} else if (reactions[reaction]?.contains(note) == false) { } else if (reactions[reaction]?.contains(note) == false) {
reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note) 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) { if (author !in reports.keys) {
reports = reports + Pair(author, listOf(note)) reports = reports + Pair(author, listOf(note))
liveSet?.reports?.invalidateData() liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) { } else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note) reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
liveSet?.reports?.invalidateData() liveSet?.innerReports?.invalidateData()
} }
} }
fun addRelay(relay: Relay) { fun addRelay(relay: Relay) {
if (relay.url !in relays) { if (relay.url !in relays) {
relays = relays + relay.url relays = relays + relay.url
liveSet?.relays?.invalidateData() liveSet?.innerRelays?.invalidateData()
} }
} }
@@ -463,10 +464,6 @@ open class Note(val idHex: String) {
}.flatten() }.flatten()
} }
fun countReactions(): Int {
return reactions.values.sumOf { it.size }
}
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal { fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
// Regular Zap Receipts // Regular Zap Receipts
val completedZaps = zaps.asSequence() val completedZaps = zaps.asSequence()
@@ -657,36 +654,52 @@ open class Note(val idHex: String) {
@Stable @Stable
class NoteLiveSet(u: Note) { class NoteLiveSet(u: Note) {
// Observers line up here. // 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 it.note.author
} }
val hasEvent = metadata.map {
val hasEvent = innerMetadata.map {
it.note.event != null it.note.event != null
}.distinctUntilChanged() }.distinctUntilChanged()
val reactions: NoteLiveData = NoteLiveData(u) val hasReactions = innerZaps.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
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 ->
zapState?.note?.zaps?.isNotEmpty() ?: false || zapState?.note?.zaps?.isNotEmpty() ?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false || boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false reactionState?.note?.reactions?.isNotEmpty() ?: false
}.distinctUntilChanged() }.distinctUntilChanged()
val replyCount = replies.map { val replyCount = innerReplies.map {
it.note.replies.size it.note.replies.size
}.distinctUntilChanged() }.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 it.note.boosts.size
}.distinctUntilChanged() }.distinctUntilChanged()
val boostList = innerBoosts.map {
it.note.boosts.toImmutableList()
}.distinctUntilChanged()
fun isInUse(): Boolean { fun isInUse(): Boolean {
return metadata.hasObservers() || return metadata.hasObservers() ||
reactions.hasObservers() || reactions.hasObservers() ||
@@ -694,21 +707,28 @@ class NoteLiveSet(u: Note) {
replies.hasObservers() || replies.hasObservers() ||
reports.hasObservers() || reports.hasObservers() ||
relays.hasObservers() || relays.hasObservers() ||
zaps.hasObservers() zaps.hasObservers() ||
authorChanges.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers() ||
boostList.hasObservers()
} }
fun destroy() { fun destroy() {
metadata.destroy() innerMetadata.destroy()
reactions.destroy() innerReactions.destroy()
boosts.destroy() innerBoosts.destroy()
replies.destroy() innerReplies.destroy()
reports.destroy() innerReports.destroy()
relays.destroy() innerRelays.destroy()
zaps.destroy() innerZaps.destroy()
} }
} }
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) { class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO) private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -730,6 +750,17 @@ class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
} }
} }
fun <Y> map(
transform: (NoteState) -> Y
): NoteLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = NoteLoadingLiveData(note, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
if (note is AddressableNote) { if (note is AddressableNote) {
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map import androidx.lifecycle.map
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource 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.ReportEvent
import com.vitorpamplona.quartz.events.UserMetadata import com.vitorpamplona.quartz.events.UserMetadata
import com.vitorpamplona.quartz.events.toImmutableListOfLists import com.vitorpamplona.quartz.events.toImmutableListOfLists
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import java.math.BigDecimal import java.math.BigDecimal
@@ -101,7 +101,7 @@ class User(val pubkeyHex: String) {
if (event.id == latestBookmarkList?.id) return if (event.id == latestBookmarkList?.id) return
latestBookmarkList = event latestBookmarkList = event
liveSet?.bookmarks?.invalidateData() liveSet?.innerBookmarks?.invalidateData()
} }
fun updateContactList(event: ContactListEvent) { fun updateContactList(event: ContactListEvent) {
@@ -111,18 +111,18 @@ class User(val pubkeyHex: String) {
latestContactList = event latestContactList = event
// Update following of the current user // Update following of the current user
liveSet?.follows?.invalidateData() liveSet?.innerFollows?.invalidateData()
// Update Followers of the past user list // Update Followers of the past user list
// Update Followers of the new contact list // Update Followers of the new contact list
(oldContactListEvent)?.unverifiedFollowKeySet()?.forEach { (oldContactListEvent)?.unverifiedFollowKeySet()?.forEach {
LocalCache.users[it]?.liveSet?.followers?.invalidateData() LocalCache.users[it]?.liveSet?.innerFollowers?.invalidateData()
} }
(latestContactList)?.unverifiedFollowKeySet()?.forEach { (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) { fun addReport(note: Note) {
@@ -130,10 +130,10 @@ class User(val pubkeyHex: String) {
if (author !in reports.keys) { if (author !in reports.keys) {
reports = reports + Pair(author, setOf(note)) reports = reports + Pair(author, setOf(note))
liveSet?.reports?.invalidateData() liveSet?.innerReports?.invalidateData()
} else if (reports[author]?.contains(note) == false) { } else if (reports[author]?.contains(note) == false) {
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note) 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) { if (author in reports.keys && reports[author]?.contains(deleteNote) == true) {
reports[author]?.let { reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote)) 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?) { fun addZap(zapRequest: Note, zap: Note?) {
if (zapRequest !in zaps.keys) { if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap) zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} else if (zapRequest in zaps.keys && zaps[zapRequest] == null) { } else if (zapRequest in zaps.keys && zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap) zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} }
} }
fun removeZap(zapRequestOrZapEvent: Note) { fun removeZap(zapRequestOrZapEvent: Note) {
if (zapRequestOrZapEvent in zaps.keys) { if (zapRequestOrZapEvent in zaps.keys) {
zaps = zaps.minus(zapRequestOrZapEvent) zaps = zaps.minus(zapRequestOrZapEvent)
liveSet?.zaps?.invalidateData() liveSet?.innerZaps?.invalidateData()
} else if (zapRequestOrZapEvent in zaps.values) { } else if (zapRequestOrZapEvent in zaps.values) {
zaps = zaps.filter { it.value != zapRequestOrZapEvent } 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) val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg !in privateChatroom.roomMessages) { if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg) privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData() liveSet?.innerMessages?.invalidateData()
} }
} }
@@ -226,7 +226,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user) val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg !in privateChatroom.roomMessages) { if (msg !in privateChatroom.roomMessages) {
privateChatroom.addMessageSync(msg) privateChatroom.addMessageSync(msg)
liveSet?.messages?.invalidateData() liveSet?.innerMessages?.invalidateData()
} }
} }
@@ -240,7 +240,7 @@ class User(val pubkeyHex: String) {
val privateChatroom = getOrCreatePrivateChatroom(user) val privateChatroom = getOrCreatePrivateChatroom(user)
if (msg in privateChatroom.roomMessages) { if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg) privateChatroom.removeMessageSync(msg)
liveSet?.messages?.invalidateData() liveSet?.innerMessages?.invalidateData()
} }
} }
@@ -255,7 +255,7 @@ class User(val pubkeyHex: String) {
here.counter++ here.counter++
} }
liveSet?.relayInfo?.invalidateData() liveSet?.innerRelayInfo?.invalidateData()
} }
fun updateUserInfo(newUserInfo: UserMetadata, latestMetadata: MetadataEvent) { 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 { fun isFollowing(user: User): Boolean {
@@ -382,54 +382,70 @@ class User(val pubkeyHex: String) {
@Stable @Stable
class UserLiveSet(u: User) { class UserLiveSet(u: User) {
// UI Observers line up here. val innerMetadata = UserBundledRefresherLiveData(u)
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 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() it.user.profilePicture()
}.distinctUntilChanged() }.distinctUntilChanged()
val nip05Changes = metadata.map { val nip05Changes = innerMetadata.map {
it.user.nip05() it.user.nip05()
}.distinctUntilChanged() }.distinctUntilChanged()
val userMetadataInfo = metadata.map { val userMetadataInfo = innerMetadata.map {
it.user.info it.user.info
}.distinctUntilChanged() }.distinctUntilChanged()
fun isInUse(): Boolean { fun isInUse(): Boolean {
return follows.hasObservers() || return metadata.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() || followers.hasObservers() ||
reports.hasObservers() || reports.hasObservers() ||
messages.hasObservers() || messages.hasObservers() ||
relays.hasObservers() || relays.hasObservers() ||
relayInfo.hasObservers() || relayInfo.hasObservers() ||
metadata.hasObservers() ||
zaps.hasObservers() || zaps.hasObservers() ||
bookmarks.hasObservers() ||
statuses.hasObservers() || statuses.hasObservers() ||
bookmarks.hasObservers() profilePictureChanges.hasObservers() ||
nip05Changes.hasObservers() ||
userMetadataInfo.hasObservers()
} }
fun destroy() { fun destroy() {
follows.destroy() innerMetadata.destroy()
followers.destroy() innerFollows.destroy()
reports.destroy() innerFollowers.destroy()
messages.destroy() innerReports.destroy()
relays.destroy() innerMessages.destroy()
relayInfo.destroy() innerRelays.destroy()
metadata.destroy() innerRelayInfo.destroy()
zaps.destroy() innerZaps.destroy()
bookmarks.destroy() innerBookmarks.destroy()
statuses.destroy() innerStatuses.destroy()
} }
} }
@@ -440,64 +456,7 @@ data class RelayInfo(
var counter: Long var counter: Long
) )
@Stable class UserBundledRefresherLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
class Chatroom() {
var roomMessages: Set<Note> = 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<HexKey>): Boolean {
return roomMessages.any { it.author?.pubkeyHex in keySet }
}
fun pruneMessagesToTheLatestOnly(): Set<Note> {
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>(UserState(user)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO) private val bundler = BundledUpdate(500, Dispatchers.IO)
@@ -518,6 +477,17 @@ class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
} }
} }
fun <Y> map(
transform: (UserState) -> Y
): UserLoadingLiveData<Y> {
val initialValue = this.value?.let { transform(it) }
val result = UserLoadingLiveData(user, initialValue)
result.addSource(this) { x -> result.value = transform(x) }
return result
}
}
class UserLoadingLiveData<Y>(val user: User, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() { override fun onActive() {
super.onActive() super.onActive()
NostrSingleUserDataSource.add(user) NostrSingleUserDataSource.add(user)
@@ -111,6 +111,7 @@ abstract class NostrDataSource(val debugName: String) {
private val bundler = BundledUpdate(300, Dispatchers.IO) private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() { fun invalidateFilters() {
scope.launch(Dispatchers.IO) {
bundler.invalidate() { bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
@@ -119,6 +120,7 @@ abstract class NostrDataSource(val debugName: String) {
resetFiltersSuspend() resetFiltersSuspend()
} }
} }
}
fun resetFilters() { fun resetFilters() {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@@ -18,53 +18,66 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
fun createLiveStreamFilter(): TypedFilter { fun createLiveStreamFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followKeys = follows?.map { return listOfNotNull(
it.substring(0, 8) TypedFilter(
}
return TypedFilter(
types = setOf(FeedType.GLOBAL), types = setOf(FeedType.GLOBAL),
filter = JsonFilter( filter = JsonFilter(
authors = followKeys, authors = follows,
kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind), kinds = listOf(LiveActivitiesChatMessageEvent.kind, LiveActivitiesEvent.kind),
limit = 300, limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList 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 { fun createPublicChatFilter(): List<TypedFilter> {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followChats = account.selectedChatsFollowList().toList()
val followKeys = follows?.map { return listOf(
it.substring(0, 8) TypedFilter(
}
return TypedFilter(
types = setOf(FeedType.PUBLIC_CHATS), types = setOf(FeedType.PUBLIC_CHATS),
filter = JsonFilter( filter = JsonFilter(
authors = followKeys, authors = follows,
kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind), kinds = listOf(ChannelCreateEvent.kind, ChannelMetadataEvent.kind, ChannelMessageEvent.kind),
limit = 300, limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList 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 { fun createCommunitiesFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList) val follows = account.selectedUsersFollowList(account.defaultDiscoveryFollowList)?.toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
return TypedFilter( return TypedFilter(
types = setOf(FeedType.GLOBAL), types = setOf(FeedType.GLOBAL),
filter = JsonFilter( filter = JsonFilter(
authors = followKeys, authors = follows,
kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind), kinds = listOf(CommunityDefinitionEvent.kind, CommunityPostApprovalEvent.kind),
limit = 300, limit = 300,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultDiscoveryFollowList)?.relayList
@@ -197,16 +210,16 @@ object NostrDiscoveryDataSource : NostrDataSource("DiscoveryFeed") {
} }
override fun updateChannelFilters() { override fun updateChannelFilters() {
discoveryFeedChannel.typedFilters = listOfNotNull( discoveryFeedChannel.typedFilters = createLiveStreamFilter().plus(createPublicChatFilter()).plus(
createLiveStreamFilter(), listOfNotNull(
createPublicChatFilter(),
createCommunitiesFilter(),
createLiveStreamTagsFilter(), createLiveStreamTagsFilter(),
createLiveStreamGeohashesFilter(),
createCommunitiesFilter(),
createPublicChatsTagsFilter(), createPublicChatsTagsFilter(),
createCommunitiesTagsFilter(), createCommunitiesTagsFilter(),
createCommunitiesGeohashesFilter(), createCommunitiesGeohashesFilter(),
createPublicChatsGeohashesFilter(), createPublicChatsGeohashesFilter()
createLiveStreamGeohashesFilter() )
).ifEmpty { null } ).ifEmpty { null }
} }
} }
@@ -54,13 +54,8 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
} }
fun createFollowAccountsFilter(): TypedFilter { fun createFollowAccountsFilter(): TypedFilter {
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followSet = follows.plus(account.userProfile().pubkeyHex).toList()
val followKeys = follows?.map {
it.substring(0, 8)
}
val followSet = followKeys?.plus(account.userProfile().pubkeyHex.substring(0, 8))
return TypedFilter( return TypedFilter(
types = setOf(FeedType.FOLLOWS), types = setOf(FeedType.FOLLOWS),
@@ -1,7 +1,7 @@
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.PublicChatChannel import com.vitorpamplona.amethyst.model.PublicChatChannel
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
@@ -11,10 +11,10 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
private var channelsToWatch = setOf<String>() private var channelsToWatch = setOf<Channel>()
private fun createRepliesAndReactionsFilter(): TypedFilter? { private fun createMetadataChangeFilter(): TypedFilter? {
val reactionsToWatch = channelsToWatch.map { it } val reactionsToWatch = channelsToWatch.filter { it is PublicChatChannel }.map { it.idHex }
if (reactionsToWatch.isEmpty()) { if (reactionsToWatch.isEmpty()) {
return null return null
@@ -32,7 +32,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadEventsIfNotLoadedFilter(): TypedFilter? { fun createLoadEventsIfNotLoadedFilter(): TypedFilter? {
val directEventsToLoad = channelsToWatch val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filter { it.notes.isEmpty() && it is PublicChatChannel } .filter { it.notes.isEmpty() && it is PublicChatChannel }
val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet() val interestedEvents = (directEventsToLoad).map { it.idHex }.toSet()
@@ -53,7 +52,6 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? { fun createLoadStreamingIfNotLoadedFilter(): List<TypedFilter>? {
val directEventsToLoad = channelsToWatch val directEventsToLoad = channelsToWatch
.mapNotNull { LocalCache.checkGetOrCreateChannel(it) }
.filterIsInstance<LiveActivitiesChannel>() .filterIsInstance<LiveActivitiesChannel>()
.filter { it.info == null } .filter { it.info == null }
@@ -81,7 +79,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
val singleChannelChannel = requestNewChannel() val singleChannelChannel = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
val reactions = createRepliesAndReactionsFilter() val reactions = createMetadataChangeFilter()
val missing = createLoadEventsIfNotLoadedFilter() val missing = createLoadEventsIfNotLoadedFilter()
val missingStreaming = createLoadStreamingIfNotLoadedFilter() val missingStreaming = createLoadStreamingIfNotLoadedFilter()
@@ -90,13 +88,17 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") {
).ifEmpty { null } ).ifEmpty { null }
} }
fun add(eventId: String) { fun add(eventId: Channel) {
if (eventId !in channelsToWatch) {
channelsToWatch = channelsToWatch.plus(eventId) channelsToWatch = channelsToWatch.plus(eventId)
invalidateFilters() invalidateFilters()
} }
}
fun remove(eventId: String) { fun remove(eventId: Channel) {
if (eventId in channelsToWatch) {
channelsToWatch = channelsToWatch.minus(eventId) channelsToWatch = channelsToWatch.minus(eventId)
invalidateFilters() invalidateFilters()
} }
} }
}
@@ -12,50 +12,61 @@ import com.vitorpamplona.quartz.events.StatusEvent
object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
var usersToWatch = setOf<User>() var usersToWatch = setOf<User>()
fun createUserFilter(): List<TypedFilter>? { fun createUserMetadataFilter(): List<TypedFilter>? {
if (usersToWatch.isEmpty()) return null 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( TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(MetadataEvent.kind), kinds = listOf(MetadataEvent.kind, StatusEvent.kind),
authors = listOf(it.pubkeyHex), authors = firstTimers,
limit = 1 limit = 10 * firstTimers.size
)
) )
) )
}
} }
fun createUserStatusFilter(): List<TypedFilter>? { fun createUserMetadataFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null if (usersToWatch.isEmpty()) return null
return usersToWatch.map { return TypedFilter(
TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(StatusEvent.kind), kinds = listOf(StatusEvent.kind),
authors = listOf(it.pubkeyHex), authors = usersToWatch.map { it.pubkeyHex },
since = it.latestEOSEs since = minLatestEOSEs
) )
) )
} }
}
fun createUserReportFilter(): List<TypedFilter>? { fun createUserStatusFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null if (usersToWatch.isEmpty()) return null
return usersToWatch.map { return TypedFilter(
TypedFilter( types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(StatusEvent.kind),
authors = usersToWatch.map { it.pubkeyHex },
since = minLatestEOSEs
)
)
}
fun createUserReportFilter(minLatestEOSEs: Map<String, EOSETime>): TypedFilter? {
if (usersToWatch.isEmpty()) return null
return TypedFilter(
types = COMMON_FEED_TYPES, types = COMMON_FEED_TYPES,
filter = JsonFilter( filter = JsonFilter(
kinds = listOf(ReportEvent.kind), kinds = listOf(ReportEvent.kind),
tags = mapOf("p" to listOf(it.pubkeyHex)), tags = mapOf("p" to usersToWatch.map { it.pubkeyHex }),
since = it.latestEOSEs since = minLatestEOSEs
) )
) )
} }
}
val userChannel = requestNewChannel() { time, relayUrl -> val userChannel = requestNewChannel() { time, relayUrl ->
usersToWatch.forEach { usersToWatch.forEach {
@@ -66,17 +77,40 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
eose.time = time eose.time = time
} }
} }
}
val userChannelFirstTimers = requestNewChannel() { time, relayUrl ->
// Many relays operate with limits in the amount of filters. // Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data. // As information comes, the filters will be rotated to get more data.
invalidateFilters() invalidateFilters()
} }
val userChannelOnce = requestNewChannel()
override fun updateChannelFilters() { override fun updateChannelFilters() {
userChannel.typedFilters = listOfNotNull(createUserReportFilter(), createUserStatusFilter()).flatten().ifEmpty { null } val minLatestEOSEs = mutableMapOf<String, EOSETime>()
userChannelOnce.typedFilters = listOfNotNull(createUserFilter()).flatten().ifEmpty { null } val neverGottenAnEOSE = mutableSetOf<String>()
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) { fun add(user: User) {
@@ -36,8 +36,10 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
} }
fun loadThread(noteId: String?) { fun loadThread(noteId: String?) {
if (eventToWatch != noteId) {
eventToWatch = noteId eventToWatch = noteId
invalidateFilters() invalidateFilters()
} }
} }
}
@@ -14,16 +14,12 @@ object NostrVideoDataSource : NostrDataSource("VideoFeed") {
val latestEOSEs = EOSEAccount() val latestEOSEs = EOSEAccount()
fun createContextualFilter(): TypedFilter? { fun createContextualFilter(): TypedFilter? {
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList) val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)?.toList()
val followKeys = follows?.map {
it.substring(0, 6)
}
return TypedFilter( return TypedFilter(
types = setOf(FeedType.GLOBAL), types = setOf(FeedType.GLOBAL),
filter = JsonFilter( filter = JsonFilter(
authors = followKeys, authors = follows,
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind), kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200, limit = 200,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultStoriesFollowList)?.relayList
@@ -57,7 +57,7 @@ class LightningAddressResolver() {
if (it.isSuccessful) { if (it.isSuccessful) {
onSuccess(it.body.string()) onSuccess(it.body.string())
} else { } 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) { } catch (e: Exception) {
@@ -23,7 +23,7 @@ import kotlinx.collections.immutable.persistentSetOf
class EventNotificationConsumer(private val applicationContext: Context) { class EventNotificationConsumer(private val applicationContext: Context) {
fun consume(event: Event) { suspend fun consume(event: Event) {
if (LocalCache.notes[event.id] == null) { if (LocalCache.notes[event.id] == null) {
if (LocalCache.justVerify(event)) { if (LocalCache.justVerify(event)) {
LocalCache.justConsume(event, null) 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 if (!LocalCache.justVerify(event)) return null
return when (event) { 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 val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return
LocalPreferences.allSavedAccounts().forEach { LocalPreferences.allSavedAccounts().forEach {
@@ -60,7 +60,7 @@ class JsonFilter(
} else { } else {
val jsonObjectSince = factory.objectNode() val jsonObjectSince = factory.objectNode()
entries.forEach { sincePairs -> entries.forEach { sincePairs ->
put(sincePairs.key, "${sincePairs.value}") jsonObjectSince.put(sincePairs.key, "${sincePairs.value}")
} }
put("since", jsonObjectSince) put("since", jsonObjectSince)
} }
@@ -186,6 +186,7 @@ class MainActivity : AppCompatActivity() {
} }
override fun onPause() { override fun onPause() {
ServiceManager.cleanObservers()
// if (BuildConfig.DEBUG) { // if (BuildConfig.DEBUG) {
debugState(this) debugState(this)
// } // }
@@ -212,7 +213,7 @@ class MainActivity : AppCompatActivity() {
super.onTrimMemory(level) super.onTrimMemory(level)
println("Trim Memory $level") println("Trim Memory $level")
GlobalScope.launch(Dispatchers.Default) { GlobalScope.launch(Dispatchers.Default) {
ServiceManager.cleanUp() ServiceManager.trimMemory()
} }
} }
@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
@@ -11,8 +13,6 @@ import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable @Composable
fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) { 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( val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = MaterialTheme.colors.error, focusedBorderColor = MaterialTheme.colors.error,
unfocusedBorderColor = Color.Red unfocusedBorderColor = Color.Red
@@ -80,10 +40,10 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
OutlinedTextField( OutlinedTextField(
value = textMin, value = pollViewModel.valueMinimum?.toString() ?: "",
onValueChange = { textMin = it }, onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp), modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid,
label = { label = {
Text( Text(
@@ -98,11 +58,14 @@ fun NewPollVoteValueRange(pollViewModel: NewPostViewModel) {
) )
} }
) )
Spacer(modifier = DoubleHorzSpacer)
OutlinedTextField( OutlinedTextField(
value = textMax, value = pollViewModel.valueMaximum?.toString() ?: "",
onValueChange = { textMax = it }, onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.width(150.dp), modifier = Modifier.weight(1f),
colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid,
label = { label = {
Text( 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 @Preview
@Composable @Composable
fun NewPollVoteValueRangePreview() { fun NewPollVoteValueRangePreview() {
Column(
modifier = Modifier.fillMaxWidth()
) {
NewPollVoteValueRange(NewPostViewModel()) NewPollVoteValueRange(NewPostViewModel())
} }
}
@@ -542,6 +542,8 @@ private fun PollField(postViewModel: NewPostViewModel) {
NewPollOption(postViewModel, index) NewPollOption(postViewModel, index)
} }
NewPollVoteValueRange(postViewModel)
Button( Button(
onClick = { onClick = {
postViewModel.pollOptions[postViewModel.pollOptions.size] = postViewModel.pollOptions[postViewModel.pollOptions.size] =
@@ -74,8 +74,8 @@ open class NewPostViewModel() : ViewModel() {
var wantsPoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false)
var zapRecipients = mutableStateListOf<HexKey>() var zapRecipients = mutableStateListOf<HexKey>()
var pollOptions = newStateMapPollOptions() var pollOptions = newStateMapPollOptions()
var valueMaximum: Int? = null var valueMaximum by mutableStateOf<Int?>(null)
var valueMinimum: Int? = null var valueMinimum by mutableStateOf<Int?>(null)
var consensusThreshold: Int? = null var consensusThreshold: Int? = null
var closedAt: Int? = null var closedAt: Int? = null
@@ -503,7 +503,7 @@ open class NewPostViewModel() : ViewModel() {
return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) && (!wantsZapraiser || zapRaiserAmount != null) &&
(!wantsDirectMessage || !toUsers.text.isNullOrBlank()) && (!wantsDirectMessage || !toUsers.text.isNullOrBlank()) &&
(!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && (!wantsPoll || (pollOptions.values.all { it.isNotEmpty() } && isValidvalueMinimum.value && isValidvalueMaximum.value)) &&
contentToAddUrl == null contentToAddUrl == null
} }
@@ -621,6 +621,50 @@ open class NewPostViewModel() : ViewModel() {
nip24 = !nip24 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) { enum class GeohashPrecision(val digits: Int) {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle 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.AnnotatedString
import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.encoders.LnWithdrawalUtil import com.vitorpamplona.quartz.encoders.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -41,10 +43,7 @@ fun MayBeWithdrawal(lnurlWord: String) {
@Composable @Composable
fun ClickableWithdrawal(withdrawalString: String) { fun ClickableWithdrawal(withdrawalString: String) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val uri = remember(withdrawalString) {
Uri.parse("lightning:$withdrawalString")
}
val withdraw = remember(withdrawalString) { val withdraw = remember(withdrawalString) {
AnnotatedString("$withdrawalString ") AnnotatedString("$withdrawalString ")
@@ -53,9 +52,18 @@ fun ClickableWithdrawal(withdrawalString: String) {
ClickableText( ClickableText(
text = withdraw, text = withdraw,
onClick = { onClick = {
runCatching { try {
val intent = Intent(Intent.ACTION_VIEW, uri) 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) 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) style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.widget.Toast
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -66,6 +67,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
@Composable @Composable
fun InvoicePreview(lnInvoice: String, amount: String?) { fun InvoicePreview(lnInvoice: String, amount: String?) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -118,9 +120,18 @@ fun InvoicePreview(lnInvoice: String, amount: String?) {
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 10.dp), .padding(vertical = 10.dp),
onClick = { onClick = {
runCatching { try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$lnInvoice")) 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) 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, shape = QuoteBorder,
@@ -12,8 +12,6 @@ import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable @Composable
fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) { fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel) {
@@ -37,13 +35,11 @@ fun UrlPreview(url: String, urlText: String, accountViewModel: AccountViewModel)
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created). // Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
if (urlPreviewState == UrlPreviewState.Loading) { if (urlPreviewState == UrlPreviewState.Loading) {
LaunchedEffect(url) { LaunchedEffect(url) {
launch(Dispatchers.IO) { accountViewModel.urlPreview(url) {
UrlCachedPreviewer.previewInfo(url) {
urlPreviewState = it urlPreviewState = it
} }
} }
} }
}
Crossfade( Crossfade(
targetState = urlPreviewState, targetState = urlPreviewState,
@@ -7,7 +7,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.ChatroomKeyable
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() { class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -50,7 +49,6 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
.reversed() .reversed()
} }
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> { override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue { val (feed, elapsed) = measureTimedValue {
val me = account.userProfile() val me = account.userProfile()
@@ -6,7 +6,6 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.quartz.events.ChatroomKey import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() { class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
@@ -38,7 +37,6 @@ class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>
.reversed() .reversed()
} }
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> { override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue { val (feed, elapsed) = measureTimedValue {
val me = account.userProfile() val me = account.userProfile()
@@ -2,11 +2,9 @@ package com.vitorpamplona.amethyst.ui.dal
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
abstract class FeedFilter<T> { abstract class FeedFilter<T> {
@OptIn(ExperimentalTime::class)
fun loadTop(): List<T> { fun loadTop(): List<T> {
checkNotInMainThread() checkNotInMainThread()
@@ -33,7 +31,6 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(collection: Set<T>): Set<T> abstract fun applyFilter(collection: Set<T>): Set<T>
abstract fun sort(collection: Set<T>): List<T> abstract fun sort(collection: Set<T>): List<T>
@OptIn(ExperimentalTime::class)
open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> { open fun updateListWith(oldList: List<T>, newItems: Set<T>): List<T> {
checkNotInMainThread() checkNotInMainThread()
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent 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 LongTextNoteEvent ||
it.event is ChannelMessageEvent || it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent || it.event is PrivateDmEvent ||
it.event is PollNoteEvent ||
it.event is AudioHeaderEvent it.event is AudioHeaderEvent
) && ) &&
it.event?.isTaggedGeoHash(myTag) == true it.event?.isTaggedGeoHash(myTag) == true
@@ -6,6 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.AudioHeaderEvent import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent 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 LongTextNoteEvent ||
it.event is ChannelMessageEvent || it.event is ChannelMessageEvent ||
it.event is PrivateDmEvent || it.event is PrivateDmEvent ||
it.event is PollNoteEvent ||
it.event is AudioHeaderEvent it.event is AudioHeaderEvent
) && ) &&
it.event?.isTaggedHash(myTag) == true it.event?.isTaggedHash(myTag) == true
@@ -3,6 +3,9 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.Intent 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect 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 -> Route.Home.let { route ->
composable(route.route, route.arguments, content = { it -> composable(route.route, route.arguments, content = { it ->
val nip47 = it.arguments?.getString("nip47") val nip47 = it.arguments?.getString("nip47")
@@ -215,6 +223,7 @@ fun AppNavigation(
composable(route.route, route.arguments, content = { composable(route.route, route.arguments, content = {
ChatroomScreen( ChatroomScreen(
roomId = it.arguments?.getString("id"), roomId = it.arguments?.getString("id"),
draftMessage = it.arguments?.getString("message"),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav nav = nav
) )
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -278,19 +280,24 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
) )
}, },
keyboardOptions = KeyboardOptions.Default.copy( keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences capitalization = KeyboardCapitalization.Sentences
), ),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true, singleLine = true,
trailingIcon = { trailingIcon = {
if (hasChanged) { if (hasChanged) {
UserStatusSendButton() { UserStatusSendButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.createStatus(currentStatus.value) accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true) focusManager.clearFocus(true)
} }
} }
} }
}
) )
} else { } else {
statuses.forEach { statuses.forEach {
@@ -319,27 +326,29 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
) )
}, },
keyboardOptions = KeyboardOptions.Default.copy( keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences capitalization = KeyboardCapitalization.Sentences
), ),
keyboardActions = KeyboardActions(
onSend = {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
),
singleLine = true, singleLine = true,
trailingIcon = { trailingIcon = {
if (hasChanged) { if (hasChanged) {
UserStatusSendButton() { UserStatusSendButton() {
scope.launch(Dispatchers.IO) {
accountViewModel.updateStatus(it, thisStatus.value) accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true) focusManager.clearFocus(true)
} }
}
} else { } else {
UserStatusDeleteButton() { UserStatusDeleteButton() {
scope.launch(Dispatchers.IO) { accountViewModel.deleteStatus(it)
accountViewModel.updateStatus(it, "")
accountViewModel.delete(it, true)
focusManager.clearFocus(true) focusManager.clearFocus(true)
} }
} }
} }
}
) )
} }
} }
@@ -120,9 +120,12 @@ sealed class Route(
) )
object Room : Route( object Room : Route(
route = "Room/{id}", route = "Room/{id}?message={message}",
icon = R.drawable.ic_moments, 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( object RoomByAuthor : Route(
@@ -625,7 +625,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
Spacer(modifier = StdHorzSpacer) Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer) 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 { description?.let {
@@ -739,7 +739,7 @@ fun RenderChannelThumb(baseNote: Note, channel: Channel, accountViewModel: Accou
Spacer(modifier = StdHorzSpacer) Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) LikeReaction(baseNote = baseNote, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer) 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 { description?.let {
@@ -569,7 +569,7 @@ private fun StatusRow(
Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) { Row(verticalAlignment = Alignment.CenterVertically, modifier = ReactionRowHeightChat) {
LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav) LikeReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav)
Spacer(modifier = StdHorzSpacer) Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel) ZapReaction(baseNote, MaterialTheme.colors.placeholderText, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer) Spacer(modifier = DoubleHorzSpacer)
ReplyReaction( ReplyReaction(
baseNote = baseNote, baseNote = baseNote,
@@ -42,7 +42,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ImageUrlType 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.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller 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.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import com.vitorpamplona.quartz.events.ImmutableListOfLists 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.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -352,8 +348,7 @@ private fun ParseAuthorCommentAndAmount(
} }
LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) {
launch(Dispatchers.IO) { accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState ->
val newState = loadAmountState(zapRequest, zapEvent, accountViewModel)
if (newState != null) { if (newState != null) {
content.value = newState content.value = newState
} }
@@ -363,34 +358,6 @@ private fun ParseAuthorCommentAndAmount(
onReady(content) 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 @Composable
private fun RenderState( private fun RenderState(
content: MutableState<ZapAmountCommentNotification>, content: MutableState<ZapAmountCommentNotification>,
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Icon 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.AddressableNote
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User 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.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadStatuses import com.vitorpamplona.amethyst.ui.note.LoadStatuses
import com.vitorpamplona.amethyst.ui.note.NIP05CheckingIcon 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.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier 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.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.amethyst.ui.theme.placeholderText 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.events.UserMetadata
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): MutableState<Boolean?> { fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String, accountViewModel: AccountViewModel): MutableState<Boolean?> {
val nip05Verified = remember(userMetadata.nip05) { val nip05Verified = remember(userMetadata.nip05) {
// starts with null if must verify or already filled in if verified in the last hour // starts with null if must verify or already filled in if verified in the last hour
val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) { val default = if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) {
@@ -73,37 +72,9 @@ fun nip05VerificationAsAState(userMetadata: UserMetadata, pubkeyHex: String): Mu
if (nip05Verified.value == null) { if (nip05Verified.value == null) {
LaunchedEffect(key1 = userMetadata.nip05) { LaunchedEffect(key1 = userMetadata.nip05) {
launch(Dispatchers.IO) { accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus ->
userMetadata.nip05?.ifBlank { null }?.let { nip05 -> if (nip05Verified.value != newVerificationStatus) {
Nip05Verifier().verifyNip05( nip05Verified.value = newVerificationStatus
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
}
}
)
} }
} }
} }
@@ -154,7 +125,7 @@ private fun VerifyAndDisplayNIP05OrStatusLine(
Column(modifier = columnModifier) { Column(modifier = columnModifier) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05 != null) { if (nip05 != null) {
val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex) val nip05Verified = nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
if (nip05Verified.value != true) { if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified) DisplayNIP05(nip05, nip05Verified)
@@ -180,7 +151,7 @@ fun RotateStatuses(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
var indexToDisplay by remember { var indexToDisplay by remember(statuses) {
mutableIntStateOf(0) mutableIntStateOf(0)
} }
@@ -190,7 +161,7 @@ fun RotateStatuses(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
delay(10.seconds) delay(10.seconds)
indexToDisplay = ((indexToDisplay + 1) % (statuses.size + 1)) indexToDisplay = (indexToDisplay + 1) % statuses.size
} }
} }
} }
@@ -233,7 +204,7 @@ fun DisplayStatus(
"music" -> Icon( "music" -> Icon(
painter = painterResource(id = R.drawable.tunestr), painter = painterResource(id = R.drawable.tunestr),
null, null,
modifier = Size18Modifier, modifier = Size15Modifier.padding(end = Size5dp),
tint = MaterialTheme.colors.placeholderText tint = MaterialTheme.colors.placeholderText
) )
else -> {} else -> {}
@@ -249,6 +220,7 @@ fun DisplayStatus(
if (url != null) { if (url != null) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
Spacer(modifier = StdHorzSpacer)
IconButton( IconButton(
modifier = Size15Modifier, modifier = Size15Modifier,
onClick = { runCatching { uri.openUri(url.trim()) } } onClick = { runCatching { uri.openUri(url.trim()) } }
@@ -263,6 +235,7 @@ fun DisplayStatus(
} else if (nostrATag != null) { } else if (nostrATag != null) {
LoadAddressableNote(nostrATag) { note -> LoadAddressableNote(nostrATag) { note ->
if (note != null) { if (note != null) {
Spacer(modifier = StdHorzSpacer)
IconButton( IconButton(
modifier = Size15Modifier, modifier = Size15Modifier,
onClick = { onClick = {
@@ -284,6 +257,7 @@ fun DisplayStatus(
} else if (nostrHexID != null) { } else if (nostrHexID != null) {
LoadNote(baseNoteHex = nostrHexID) { LoadNote(baseNoteHex = nostrHexID) {
if (it != null) { if (it != null) {
Spacer(modifier = StdHorzSpacer)
IconButton( IconButton(
modifier = Size15Modifier, modifier = Size15Modifier,
onClick = { onClick = {
@@ -353,12 +327,12 @@ private fun NIP05VerifiedSymbol(nip05Verified: MutableState<Boolean?>, modifier:
} }
@Composable @Composable
fun DisplayNip05ProfileStatus(user: User) { fun DisplayNip05ProfileStatus(user: User, accountViewModel: AccountViewModel) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
user.nip05()?.let { nip05 -> user.nip05()?.let { nip05 ->
if (nip05.split("@").size <= 2) { if (nip05.split("@").size <= 2) {
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex) val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
NIP05VerifiedSymbol(nip05Verified, Size16Modifier) NIP05VerifiedSymbol(nip05Verified, Size16Modifier)
var domainPadStart = 5.dp var domainPadStart = 5.dp
@@ -165,6 +165,7 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.ClassifiedsEvent import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
@@ -441,11 +442,9 @@ fun WatchForReports(
val noteReportsState by note.live().reports.observeAsState() val noteReportsState by note.live().reports.observeAsState()
LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) { LaunchedEffect(key1 = noteReportsState, key2 = userFollowsState) {
launch(Dispatchers.Default) {
accountViewModel.isNoteAcceptable(note, onChange) accountViewModel.isNoteAcceptable(note, onChange)
} }
} }
}
@Composable @Composable
fun NormalNote( fun NormalNote(
@@ -784,9 +783,9 @@ private fun ShortCommunityActionOptions(
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
Spacer(modifier = StdHorzSpacer) 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) 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 -> WatchAddressableNoteFollows(note, accountViewModel) { isFollowing ->
if (!isFollowing) { if (!isFollowing) {
@@ -1238,6 +1237,16 @@ fun routeFor(note: Note, loggedIn: User): String? {
return null 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 { fun routeFor(note: Channel): String {
return "Channel/${note.idHex}" return "Channel/${note.idHex}"
} }
@@ -1315,7 +1324,7 @@ fun RenderPoll(
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
val noteEvent = note.event as? PollNoteEvent ?: return val noteEvent = note.event as? PollNoteEvent ?: return
val eventContent = remember { noteEvent.content() } val eventContent = remember(note) { noteEvent.content() }
if (makeItShort && accountViewModel.isLoggedUser(note.author)) { if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
Text( Text(
@@ -2399,9 +2408,9 @@ private fun ReplyRow(
) { ) {
val noteEvent = note.event val noteEvent = note.event
val showReply by remember { val showReply by remember(note) {
derivedStateOf { derivedStateOf {
noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
} }
} }
@@ -98,11 +98,9 @@ private fun WatchZapsAndUpdateTallies(
val zapsState by baseNote.live().zaps.observeAsState() val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) { LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
pollViewModel.refreshTallies() pollViewModel.refreshTallies()
} }
} }
}
@Composable @Composable
private fun OptionNote( private fun OptionNote(
@@ -132,8 +130,7 @@ private fun OptionNote(
ZapVote( ZapVote(
baseNote, baseNote,
poolOption, poolOption,
accountViewModel, pollViewModel = pollViewModel,
pollViewModel,
nonClickablePrepend = { nonClickablePrepend = {
RenderOptionAfterVote( RenderOptionAfterVote(
poolOption.descriptor, poolOption.descriptor,
@@ -147,18 +144,21 @@ private fun OptionNote(
) )
}, },
clickablePrepend = { clickablePrepend = {
} },
accountViewModel = accountViewModel,
nav = nav
) )
} else { } else {
ZapVote( ZapVote(
baseNote, baseNote,
poolOption, poolOption,
accountViewModel, pollViewModel = pollViewModel,
pollViewModel,
nonClickablePrepend = {}, nonClickablePrepend = {},
clickablePrepend = { clickablePrepend = {
RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav) RenderOptionBeforeVote(poolOption.descriptor, canPreview, tags, backgroundColor, accountViewModel, nav)
} },
accountViewModel = accountViewModel,
nav = nav
) )
} }
} }
@@ -269,11 +269,12 @@ private fun RenderOptionBeforeVote(
fun ZapVote( fun ZapVote(
baseNote: Note, baseNote: Note,
poolOption: PollOption, poolOption: PollOption,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
pollViewModel: PollNoteViewModel,
nonClickablePrepend: @Composable () -> Unit, nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit clickablePrepend: @Composable () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) { ) {
val isLoggedUser by remember { val isLoggedUser by remember {
derivedStateOf { derivedStateOf {
@@ -283,6 +284,7 @@ fun ZapVote(
var wantsToZap by remember { mutableStateOf(false) } var wantsToZap by remember { mutableStateOf(false) }
var zappingProgress by remember { mutableStateOf(0f) } var zappingProgress by remember { mutableStateOf(0f) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -384,7 +386,7 @@ fun ZapVote(
onError = { onError = {
scope.launch { scope.launch {
zappingProgress = 0f zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show() showErrorMessageDialog = it
} }
}, },
onProgress = { 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() clickablePrepend()
if (poolOption.zappedByLoggedIn) { if (poolOption.zappedByLoggedIn) {
@@ -3,13 +3,16 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.events.* import com.vitorpamplona.quartz.events.*
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
@@ -52,12 +55,12 @@ class PollNoteViewModel : ViewModel() {
closedAt = pollEvent?.getTagInt(CLOSED_AT) closedAt = pollEvent?.getTagInt(CLOSED_AT)
} }
suspend fun refreshTallies() { fun refreshTallies() {
viewModelScope.launch(Dispatchers.Default) {
totalZapped = totalZapped() totalZapped = totalZapped()
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
_tallies.emit( val newOptions = pollOptions?.keys?.map {
pollOptions?.keys?.map {
val zappedInOption = zappedPollOptionAmount(it) val zappedInOption = zappedPollOptionAmount(it)
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) { val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
@@ -71,9 +74,13 @@ class PollNoteViewModel : ViewModel() {
val consensus = consensusThreshold != null && myTally >= consensusThreshold!! val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn) PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
} ?: emptyList() }
_tallies.emit(
newOptions ?: emptyList()
) )
} }
}
fun canZap(): Boolean { fun canZap(): Boolean {
val account = account ?: return false val account = account ?: return false
@@ -40,6 +40,7 @@ import androidx.compose.material.ProgressIndicatorDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -214,7 +215,7 @@ private fun InnerReactionRow(
) { ) {
val (value, elapsed) = measureTimedValue { val (value, elapsed) = measureTimedValue {
Row(verticalAlignment = CenterVertically) { 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") 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<Boolean>, wantsToSeeReactions: MutableState<Boolean>,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
val zapraiserAmount by remember { val zapraiserAmount by remember(baseNote) {
derivedStateOf { derivedStateOf {
baseNote.event?.zapraiserAmount() ?: 0 baseNote.event?.zapraiserAmount() ?: 0
} }
@@ -265,42 +266,26 @@ private fun LoadAndDisplayZapraiser(
} }
} }
@Immutable
data class ZapraiserStatus(val progress: Float, val left: String)
@Composable @Composable
fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) { fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, accountViewModel: AccountViewModel) {
val zapsState by baseNote.live().zaps.observeAsState() val zapsState by baseNote.live().zaps.observeAsState()
var zapraiserProgress by remember { mutableStateOf(0F) } var zapraiserStatus by remember { mutableStateOf(ZapraiserStatus(0F, "$zapraiserAmount")) }
var zapraiserLeft by remember { mutableStateOf("$zapraiserAmount") }
LaunchedEffect(key1 = zapsState) { LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) {
zapsState?.note?.let { zapsState?.note?.let {
val newZapAmount = accountViewModel.calculateZapAmount(it) accountViewModel.calculateZapraiser(baseNote) { newStatus ->
var percentage = newZapAmount.div(zapraiserAmount.toBigDecimal()).toFloat() if (zapraiserStatus != newStatus) {
zapraiserStatus = newStatus
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
}
} }
} }
} }
} }
val color = if (zapraiserProgress > 0.99) { val color = if (zapraiserStatus.progress > 0.99) {
DarkerGreen DarkerGreen
} else { } else {
MaterialTheme.colors.mediumImportanceLink MaterialTheme.colors.mediumImportanceLink
@@ -311,7 +296,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
.fillMaxWidth() .fillMaxWidth()
.height(if (details) 24.dp else 4.dp), .height(if (details) 24.dp else 4.dp),
color = color, color = color,
progress = zapraiserProgress progress = zapraiserStatus.progress
) )
if (details) { if (details) {
@@ -319,14 +304,14 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
contentAlignment = Center, contentAlignment = Center,
modifier = TinyBorders modifier = TinyBorders
) { ) {
val totalPercentage by remember(zapraiserProgress) { val totalPercentage by remember(zapraiserStatus) {
derivedStateOf { derivedStateOf {
"${(zapraiserProgress * 100).roundToInt()}%" "${(zapraiserStatus.progress * 100).roundToInt()}%"
} }
} }
Text( Text(
text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserLeft), text = stringResource(id = R.string.sats_to_complete, totalPercentage, zapraiserStatus.left),
modifier = NoSoTinyBorders, modifier = NoSoTinyBorders,
color = MaterialTheme.colors.placeholderText, color = MaterialTheme.colors.placeholderText,
fontSize = Font14SP, fontSize = Font14SP,
@@ -338,15 +323,7 @@ fun RenderZapRaiser(baseNote: Note, zapraiserAmount: Long, details: Boolean, acc
@Composable @Composable
private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) { private fun WatchReactionsZapsBoostsAndDisplayIfExists(baseNote: Note, content: @Composable () -> Unit) {
val hasReactions by baseNote.live().zaps.combineWith( val hasReactions by baseNote.live().hasReactions.observeAsState(
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(
baseNote.zaps.isNotEmpty() || baseNote.zaps.isNotEmpty() ||
baseNote.boosts.isNotEmpty() || baseNote.boosts.isNotEmpty() ||
baseNote.reactions.isNotEmpty() baseNote.reactions.isNotEmpty()
@@ -439,19 +416,18 @@ private fun WatchBoostsAndRenderGallery(
nav: (String) -> Unit, nav: (String) -> Unit,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
val boostsState by baseNote.live().boosts.observeAsState() val boostsEvents by baseNote.live().boostList.observeAsState()
val boostsEvents by remember(boostsState) {
derivedStateOf { baseNote.boosts.toImmutableList() }
}
if (boostsEvents.isNotEmpty()) { boostsEvents?.let {
if (it.isNotEmpty()) {
RenderBoostGallery( RenderBoostGallery(
boostsEvents, it,
nav, nav,
accountViewModel accountViewModel
) )
} }
} }
}
@Composable @Composable
private fun WatchReactionsAndRenderGallery( private fun WatchReactionsAndRenderGallery(
@@ -909,10 +885,7 @@ private fun WatchReactionTypeForNote(baseNote: Note, accountViewModel: AccountVi
val reactionsState by baseNote.live().reactions.observeAsState() val reactionsState by baseNote.live().reactions.observeAsState()
LaunchedEffect(key1 = reactionsState) { LaunchedEffect(key1 = reactionsState) {
launch(Dispatchers.Default) { accountViewModel.loadReactionTo(reactionsState?.note, onNewReactionType)
val reactionNote = reactionsState?.note?.getReactionBy(accountViewModel.userProfile())
onNewReactionType(reactionNote)
}
} }
} }
@@ -947,32 +920,9 @@ private fun RenderReactionType(
@Composable @Composable
fun LikeText(baseNote: Note, grayTint: Color) { fun LikeText(baseNote: Note, grayTint: Color) {
val reactionsCount = remember(baseNote) { val reactionCount by baseNote.live().reactionCount.observeAsState(0)
mutableStateOf(baseNote.reactions.size)
}
val scope = rememberCoroutineScope() SlidingAnimationCount(reactionCount, grayTint)
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)
}
}
} }
private fun likeClick( private fun likeClick(
@@ -1028,11 +978,13 @@ fun ZapReaction(
grayTint: Color, grayTint: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp, iconSize: Dp = 20.dp,
animationSize: Dp = 14.dp animationSize: Dp = 14.dp,
nav: (String) -> Unit
) { ) {
var wantsToZap by remember { mutableStateOf(false) } var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -1084,7 +1036,7 @@ fun ZapReaction(
onError = { onError = {
scope.launch { scope.launch {
zappingProgress = 0f zappingProgress = 0f
Toast.makeText(context, it, Toast.LENGTH_SHORT).show() showErrorMessageDialog = it
} }
}, },
onProgress = { 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) { if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel) UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
} }
@@ -1217,9 +1182,7 @@ private fun WatchZapsForNote(baseNote: Note, accountViewModel: AccountViewModel,
val zapsState by baseNote.live().zaps.observeAsState() val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) { LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) { accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, onWasZapped)
onWasZapped(accountViewModel.calculateIfNoteWasZappedByAccount(baseNote))
}
} }
} }
@@ -1249,9 +1212,7 @@ fun WatchZapAmountsForNote(baseNote: Note, accountViewModel: AccountViewModel, o
val zapsState by baseNote.live().zaps.observeAsState() val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) { LaunchedEffect(key1 = zapsState) {
launch(Dispatchers.Default) { accountViewModel.calculateZapAmount(baseNote, onZapAmount)
onZapAmount(showAmount(accountViewModel.calculateZapAmount(baseNote)))
}
} }
} }
@@ -4,11 +4,14 @@ import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@@ -111,8 +114,7 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
onError = { onError = {
zappingProgress = 0f zappingProgress = 0f
scope.launch { scope.launch {
Toast Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
.makeText(context, it, Toast.LENGTH_SHORT).show()
} }
}, },
onProgress = { onProgress = {
@@ -225,3 +227,51 @@ fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
Text(text = "⚡Zap ", color = Color.White) 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))
}
}
}
}
)
}
@@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
@@ -102,14 +101,12 @@ private fun WatchScrollToTop(
val scrollToTop by viewModel.scrollToTop.collectAsState() val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) { LaunchedEffect(scrollToTop) {
launch {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) { if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0) listState.scrollToItem(index = 0)
viewModel.sentToTop() viewModel.sentToTop()
} }
} }
} }
}
@Composable @Composable
fun RenderCardFeed( fun RenderCardFeed(
@@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@Stable @Stable
@@ -270,7 +269,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val bundler = BundledUpdate(1000, Dispatchers.IO) private val bundler = BundledUpdate(1000, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO) private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
@OptIn(ExperimentalTime::class)
fun invalidateData(ignoreIfDoing: Boolean = false) { fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) { bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
@@ -282,7 +280,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
@OptIn(ExperimentalTime::class)
fun invalidateDataAndSendToTop() { fun invalidateDataAndSendToTop() {
clear() clear()
bundler.invalidate(false) { bundler.invalidate(false) {
@@ -296,7 +293,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
@OptIn(ExperimentalTime::class)
fun checkKeysInvalidateDataAndSendToTop() { fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) { if (lastFeedKey != localFilter.feedKey()) {
clear() clear()
@@ -312,7 +308,6 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
@OptIn(ExperimentalTime::class)
fun invalidateInsertData(newItems: Set<Note>) { fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { bundlerInsert.invalidateList(newItems) {
val newObjects = it.flatten().toSet() val newObjects = it.flatten().toSet()
@@ -236,7 +236,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
checkNotInMainThread() checkNotInMainThread()
lastFeedKey = localFilter.feedKey() lastFeedKey = localFilter.feedKey()
val notes = localFilter.loadTop().toImmutableList() val notes = localFilter.loadTop().distinctBy { it.idHex }.toImmutableList()
val oldNotesState = _feedContent.value val oldNotesState = _feedContent.value
if (oldNotesState is FeedState.Loaded) { if (oldNotesState is FeedState.Loaded) {
@@ -301,6 +301,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
fun checkKeysInvalidateDataAndSendToTop() { fun checkKeysInvalidateDataAndSendToTop() {
if (lastFeedKey != localFilter.feedKey()) { if (lastFeedKey != localFilter.feedKey()) {
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(false) { bundler.invalidate(false) {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
@@ -309,6 +310,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
} }
} }
} }
}
fun invalidateInsertData(newItems: Set<Note>) { fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { bundlerInsert.invalidateList(newItems) {
@@ -16,20 +16,31 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState 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.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.encoders.HexKey
import com.vitorpamplona.quartz.events.BookmarkListEvent import com.vitorpamplona.quartz.events.BookmarkListEvent
import com.vitorpamplona.quartz.events.DeletionEvent import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReactionEvent
import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent 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.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
@@ -128,21 +139,86 @@ class AccountViewModel(val account: Account) : ViewModel() {
return account.delete(account.boostsTo(note), signEvent) return account.delete(account.boostsTo(note), signEvent)
} }
fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { fun calculateIfNoteWasZappedByAccount(zappedNote: Note, onWasZapped: (Boolean) -> Unit) {
return account.calculateIfNoteWasZappedByAccount(zappedNote) viewModelScope.launch(Dispatchers.Default) {
onWasZapped(account.calculateIfNoteWasZappedByAccount(zappedNote))
}
} }
fun calculateZapAmount(zappedNote: Note): BigDecimal { suspend fun calculateZapAmount(zappedNote: Note): BigDecimal {
return account.calculateZappedAmount(zappedNote) 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) { 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) { viewModelScope.launch(Dispatchers.IO) {
innerZap(note, amount, pollOption, message, context, onError, onProgress, zapType) 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() val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) { if (lud16.isNullOrBlank()) {
@@ -193,9 +269,12 @@ class AccountViewModel(val account: Account) : ViewModel() {
onProgress(0f) onProgress(0f)
} }
} else { } else {
runCatching { try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) 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) ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
onError(context.getString(R.string.lightning_wallets_not_found))
} }
onProgress(0f) onProgress(0f)
} }
@@ -347,6 +426,7 @@ class AccountViewModel(val account: Account) : ViewModel() {
} }
fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) { fun isNoteAcceptable(note: Note, onReady: (Boolean, Boolean, ImmutableSet<Note>) -> Unit) {
viewModelScope.launch {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
@@ -368,6 +448,7 @@ class AccountViewModel(val account: Account) : ViewModel() {
} }
} }
} }
}
fun unwrap(event: GiftWrapEvent): Event? { fun unwrap(event: GiftWrapEvent): Event? {
return account.unwrap(event) return account.unwrap(event)
@@ -395,12 +476,73 @@ class AccountViewModel(val account: Account) : ViewModel() {
} }
fun createStatus(newStatus: String) { fun createStatus(newStatus: String) {
viewModelScope.launch(Dispatchers.IO) {
account.createStatus(newStatus) account.createStatus(newStatus)
} }
}
fun updateStatus(it: AddressableNote, newStatus: String) { fun updateStatus(it: AddressableNote, newStatus: String) {
viewModelScope.launch(Dispatchers.IO) {
account.updateStatus(it, newStatus) 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 { class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel { override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
@@ -626,7 +626,7 @@ fun ShowVideoStreaming(
} }
url?.let { url?.let {
CheckIfUrlIsOnline(url) { CheckIfUrlIsOnline(url, accountViewModel) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = remember { Modifier.heightIn(max = 300.dp) } modifier = remember { Modifier.heightIn(max = 300.dp) }
@@ -896,7 +896,7 @@ private fun ShortChannelActionOptions(
Spacer(modifier = StdHorzSpacer) Spacer(modifier = StdHorzSpacer)
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer) 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) Spacer(modifier = StdHorzSpacer)
} }
} }
@@ -971,7 +971,7 @@ private fun LiveChannelActionOptions(
LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav) LikeReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav)
Spacer(modifier = StdHorzSpacer) Spacer(modifier = StdHorzSpacer)
ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel) ZapReaction(baseNote = it, grayTint = MaterialTheme.colors.onSurface, accountViewModel = accountViewModel, nav = nav)
} }
} }
@@ -106,6 +106,7 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun ChatroomScreen( fun ChatroomScreen(
roomId: String?, roomId: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
@@ -115,6 +116,7 @@ fun ChatroomScreen(
it?.let { it?.let {
PrepareChatroomViewModels( PrepareChatroomViewModels(
room = it, room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav nav = nav
) )
@@ -125,6 +127,7 @@ fun ChatroomScreen(
@Composable @Composable
fun ChatroomScreenByAuthor( fun ChatroomScreenByAuthor(
authorPubKeyHex: String?, authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
@@ -134,6 +137,7 @@ fun ChatroomScreenByAuthor(
it?.let { it?.let {
PrepareChatroomViewModels( PrepareChatroomViewModels(
room = it, room = it,
draftMessage = draftMessage,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav nav = nav
) )
@@ -171,7 +175,12 @@ fun LoadRoomByAuthor(authorPubKeyHex: String, accountViewModel: AccountViewModel
} }
@Composable @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( val feedViewModel: NostrChatroomFeedViewModel = viewModel(
key = room.hashCode().toString() + "ChatroomViewModels", key = room.hashCode().toString() + "ChatroomViewModels",
factory = NostrChatroomFeedViewModel.Factory( factory = NostrChatroomFeedViewModel.Factory(
@@ -198,6 +207,12 @@ fun PrepareChatroomViewModels(room: ChatroomKey, accountViewModel: AccountViewMo
} }
} }
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.message = TextFieldValue(draftMessage)
}
}
ChatroomScreen( ChatroomScreen(
room = room, room = room,
feedViewModel = feedViewModel, feedViewModel = feedViewModel,
@@ -29,7 +29,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel 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 com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -136,12 +134,14 @@ private fun HomePages(
} }
@Composable @Composable
fun CheckIfUrlIsOnline(url: String, whenOnline: @Composable () -> Unit) { fun CheckIfUrlIsOnline(url: String, accountViewModel: AccountViewModel, whenOnline: @Composable () -> Unit) {
var online by remember { mutableStateOf(false) } var online by remember { mutableStateOf(false) }
LaunchedEffect(key1 = url) { LaunchedEffect(key1 = url) {
launch(Dispatchers.IO) { accountViewModel.checkIfOnline(url) { isOnline ->
online = OnlineChecker.isOnline(url) if (online != isOnline) {
online = isOnline
}
} }
} }
@@ -162,13 +162,11 @@ fun WatchAccountForHomeScreen(
val followState by accountViewModel.account.userProfile().live().follows.observeAsState() val followState by accountViewModel.account.userProfile().live().follows.observeAsState()
LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList, followState) { LaunchedEffect(accountViewModel, accountState?.account?.defaultHomeFollowList, followState) {
launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters() NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.checkKeysInvalidateDataAndSendToTop() homeFeedViewModel.checkKeysInvalidateDataAndSendToTop()
repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop() repliesFeedViewModel.checkKeysInvalidateDataAndSendToTop()
} }
} }
}
@Immutable @Immutable
class TabItem( class TabItem(
@@ -73,11 +73,8 @@ fun LoadRedirectScreen(eventId: String?, accountViewModel: AccountViewModel, nav
fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) { fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState() val noteState by baseNote.live().metadata.observeAsState()
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = noteState) { LaunchedEffect(key1 = noteState) {
scope.launch { val note = noteState?.note ?: return@LaunchedEffect
val note = noteState?.note ?: return@launch
var event = note.event var event = note.event
val channelHex = note.channelHex() val channelHex = note.channelHex()
@@ -110,7 +107,6 @@ fun LoadRedirectScreen(baseNote: Note, accountViewModel: AccountViewModel, nav:
nav("Note/${note.idHex}") nav("Note/${note.idHex}")
} }
} }
}
Column( Column(
Modifier Modifier
@@ -955,7 +955,7 @@ private fun DrawAdditionalInfo(
DisplayBadges(baseUser, nav) DisplayBadges(baseUser, nav)
DisplayNip05ProfileStatus(user) DisplayNip05ProfileStatus(user, accountViewModel)
val website = user.info?.website val website = user.info?.website
if (!website.isNullOrEmpty()) { if (!website.isNullOrEmpty()) {
@@ -1085,9 +1085,18 @@ fun DisplayLNAddress(
} }
} }
} else { } else {
runCatching { try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) 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) 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()
}
} }
} }
}, },
@@ -26,9 +26,10 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
factory = NostrThreadFeedViewModel.Factory(noteId) factory = NostrThreadFeedViewModel.Factory(noteId)
) )
LaunchedEffect(noteId) {
NostrThreadDataSource.loadThread(noteId) NostrThreadDataSource.loadThread(noteId)
feedViewModel.invalidateData()
LaunchedEffect(noteId) {
feedViewModel.invalidateData(true)
} }
DisposableEffect(accountViewModel) { DisposableEffect(accountViewModel) {
@@ -37,7 +38,7 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
println("Thread Start") println("Thread Start")
NostrThreadDataSource.loadThread(noteId) NostrThreadDataSource.loadThread(noteId)
NostrThreadDataSource.start() NostrThreadDataSource.start()
feedViewModel.invalidateData() feedViewModel.invalidateData(true)
} }
if (event == Lifecycle.Event.ON_PAUSE) { if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop") println("Thread Stop")
@@ -467,7 +467,7 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
wantsToQuote = baseNote wantsToQuote = baseNote
} }
LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, nav, iconSize = 40.dp, heartSize = Size35dp, 28.sp) 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) ViewCountReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, barChartSize = 39.dp, viewCountColorFilter = MaterialTheme.colors.onBackgroundColorFilter)
} }
} }
+41 -4
View File
@@ -95,7 +95,7 @@
<string name="uploading">Chargement…</string> <string name="uploading">Chargement…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string> <string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">L\'utilisateur n\'a pas configuré d\'adresse Lightning pour recevoir des sats</string>
<string name="reply_here">"Répondre ici.. "</string> <string name="reply_here">"Répondre ici.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage</string> <string name="copies_the_note_id_to_the_clipboard_for_sharing">Copie l\'ID de note dans le presse-papiers pour le partage sur Nostr</string>
<string name="copy_channel_id_note_to_the_clipboard">Copier l\'ID de la chaîne (Note) dans le presse-papiers</string> <string name="copy_channel_id_note_to_the_clipboard">Copier l\'ID de la chaîne (Note) dans le presse-papiers</string>
<string name="edits_the_channel_metadata">Modifie les métadonnées du canal</string> <string name="edits_the_channel_metadata">Modifie les métadonnées du canal</string>
<string name="join">Joindre</string> <string name="join">Joindre</string>
@@ -474,9 +474,9 @@
<string name="application_preferences">Préférences de l\'application</string> <string name="application_preferences">Préférences de l\'application</string>
<string name="language">Langage</string> <string name="language">Langage</string>
<string name="theme">Thème</string> <string name="theme">Thème</string>
<string name="automatically_load_images_gifs">Chargement automatique des images/gifs</string> <string name="automatically_load_images_gifs">Prévisualisation des images</string>
<string name="automatically_play_videos">Démarrage automatique des vidéos</string> <string name="automatically_play_videos">Lecture vidéo</string>
<string name="automatically_show_url_preview">Prévisualisation automatique des liens</string> <string name="automatically_show_url_preview">Prévisualisation des URLs</string>
<string name="load_image">Charger l\'image</string> <string name="load_image">Charger l\'image</string>
<string name="spamming_users">Spammeurs</string> <string name="spamming_users">Spammeurs</string>
@@ -499,4 +499,41 @@
<string name="geohash_explainer">Ajoute un Geohash de votre emplacement au message. Le public saura que vous êtes à moins de 5km de l\'emplacement actuel</string> <string name="geohash_explainer">Ajoute un Geohash de votre emplacement au message. Le public saura que vous êtes à moins de 5km de l\'emplacement actuel</string>
<string name="add_sensitive_content_explainer">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</string> <string name="add_sensitive_content_explainer">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</string>
<string name="new_feature_nip24_might_not_be_available_title">Nouvelle Fonctionnalité</string>
<string name="new_feature_nip24_might_not_be_available_description">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.</string>
<string name="new_feature_nip24_activate">Activer</string>
<string name="messages_create_public_chat">Public</string>
<string name="messages_new_message">Privé</string>
<string name="messages_new_message_to">À</string>
<string name="messages_new_message_subject">Sujet</string>
<string name="messages_new_message_subject_caption">Sujet de la conversation</string>
<string name="messages_new_message_to_caption">"@Utilisateur1, @Utilisateur2, @Utilisateur3"</string>
<string name="messages_group_descriptor">Membres de ce groupe</string>
<string name="messages_new_subject_message">Explication aux membres</string>
<string name="messages_new_subject_message_placeholder">Changement de nom pour les nouveaux objectifs.</string>
<string name="language_description">Pour l\'interface de l\'App</string>
<string name="theme_description">Sombre, Clair ou thème Système</string>
<string name="automatically_load_images_gifs_description">Charger automatiquement les images et les GIFs</string>
<string name="automatically_play_videos_description">Lire automatiquement les vidéos et les GIFs</string>
<string name="automatically_show_url_preview_description">Afficher la prévisualisation d\'URL</string>
<string name="load_image_description">Quand charger les images</string>
<string name="copy_url_to_clipboard">Copier l\'URL dans le presse-papiers</string>
<string name="copy_the_note_id_to_the_clipboard">Copier l\'ID de la note dans le presse-papiers</string>
<string name="created_at">Créé le</string>
<string name="rules">Règles</string>
<string name="status_update">Mettez à jour votre statut</string>
<string name="lightning_wallets_not_found">Erreur d\'analyse du message d\'erreur</string>
<string name="poll_zap_value_min_max_explainer">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.</string>
<string name="error_dialog_zap_error">Impossible d\'envoyer un zap</string>
<string name="error_dialog_talk_to_user">Contacter l\'Utilisateur</string>
<string name="error_dialog_button_ok">Ok</string>
</resources> </resources>
+45 -1
View File
@@ -356,7 +356,7 @@
\n4. Ha szükséges az Orbot-ban változtasd meg a portot \n4. Ha szükséges az Orbot-ban változtasd meg a portot
\n5. Ezen a felületen állítsd be a Socks 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 \n6. Kattíts az Aktiválás gombra, hogy az Orbot-ot átjátszóként használd
</string> </string>ít
<string name="orbot_socks_port">Orbot Socks portja</string> <string name="orbot_socks_port">Orbot Socks portja</string>
<string name="invalid_port_number">Érvénytelen Port szám</string> <string name="invalid_port_number">Érvénytelen Port szám</string>
<string name="use_orbot">Használd az Orbot-ot</string> <string name="use_orbot">Használd az Orbot-ot</string>
@@ -476,4 +476,48 @@
<string name="nip05_verified">A Nostr cím ellenőrzésre került</string> <string name="nip05_verified">A Nostr cím ellenőrzésre került</string>
<string name="nip05_failed">A Nostr cím ellenőrzése sikeretelen</string> <string name="nip05_failed">A Nostr cím ellenőrzése sikeretelen</string>
<string name="nip05_checking">A Nostr cím ellenőrzése</string> <string name="nip05_checking">A Nostr cím ellenőrzése</string>
<string name="select_deselect_all">Mind kijelölése/kijelölés visszavonása</string>
<string name="default_relays">Alapértelmezett</string>
<string name="select_a_relay_to_continue">A folytatáshoz válassz egy csomópontot</string>
<string name="zap_forward_title">Zap-ek továbbítása:</string>
<string name="zap_forward_explainer">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</string>
<string name="geohash_title">A Hely megjelenítése mint </string>
<string name="geohash_explainer">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</string>
<string name="add_sensitive_content_explainer">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</string>
<string name="new_feature_nip24_might_not_be_available_title">Új funkció</string>
<string name="new_feature_nip24_might_not_be_available_description">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.</string>
<string name="new_feature_nip24_activate">Aktiválás</string>
<string name="messages_create_public_chat">Publikus</string>
<string name="messages_new_message">Privát</string>
<string name="messages_new_message_to">Címzett</string>
<string name="messages_new_message_subject">Téma</string>
<string name="messages_new_message_subject_caption">A beszélgetés témája</string>
<string name="messages_new_message_to_caption">"@Felhasználó1, @Felhasználó2, @UFelhasználó3"</string>
<string name="messages_group_descriptor">A csoport tagjai</string>
<string name="messages_new_subject_message">Magyarázat a csoport tagjainak</string>
<string name="messages_new_subject_message_placeholder">Az új célok érdekében, a név megváltoztatása.</string>
<string name="language_description">Az applikáció felülete</string>
<string name="theme_description">Sötét, Világos vagy Rendszer által használt téma</string>
<string name="automatically_load_images_gifs_description">Képek és GIF-ek automatikus betöltése</string>
<string name="automatically_play_videos_description">A videók és a GIF-ek automatikus lejátszása</string>
<string name="automatically_show_url_preview_description">URL előnézetek megjelenítése</string>
<string name="load_image_description">Mikor kell a képeket betölteni</string>
<string name="copy_url_to_clipboard">Az URL vágólapra másolása</string>
<string name="copy_the_note_id_to_the_clipboard">A bejegyzésazonosító vágólapra másolása</string>
<string name="created_at">Létrehozva</string>
<string name="rules">Szabályok</string>
<string name="status_update">Állapotod változtatása</string>
<string name="lightning_wallets_not_found">Hiba a hibaüzenet elemzésekor</string>
<string name="poll_zap_value_min_max_explainer">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.</string>
</resources> </resources>
+7
View File
@@ -555,4 +555,11 @@
<string name="login_with_amber">Login with Amber</string> <string name="login_with_amber">Login with Amber</string>
<string name="status_update">Update your status</string> <string name="status_update">Update your status</string>
<string name="lightning_wallets_not_found">Error parsing error message</string>
<string name="poll_zap_value_min_max_explainer">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.</string>
<string name="error_dialog_zap_error">Unable to send zap</string>
<string name="error_dialog_talk_to_user">Message the User</string>
<string name="error_dialog_button_ok">Ok</string>
</resources> </resources>
+3 -3
View File
@@ -5,13 +5,13 @@ buildscript {
fragment_version = "1.6.1" fragment_version = "1.6.1"
lifecycle_version = '2.6.1' lifecycle_version = '2.6.1'
compose_ui_version = '1.5.0' compose_ui_version = '1.5.0'
nav_version = "2.7.0" nav_version = "2.7.1"
room_version = "2.4.3" room_version = "2.4.3"
accompanist_version = '0.30.1' accompanist_version = '0.30.1'
coil_version = '2.4.0' coil_version = '2.4.0'
vico_version = '1.9.2' vico_version = '1.11.0'
exoplayer_version = '1.1.1' exoplayer_version = '1.1.1'
media3_version = '1.1.0' media3_version = '1.1.1'
core_ktx_version = '1.10.1' core_ktx_version = '1.10.1'
} }
dependencies { dependencies {
@@ -72,7 +72,7 @@ open class Event(
override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } 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 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 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 aTagValue = it[1]
val relay = it.getOrNull(2) val relay = it.getOrNull(2)
@@ -218,7 +218,7 @@ open class Event(
return try { return try {
hasCorrectIDHash() && hasVerifedSignature() hasCorrectIDHash() && hasVerifedSignature()
} catch (e: Exception) { } 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 false
} }
} }
@@ -50,5 +50,18 @@ class StatusEvent(
val sig = CryptoUtils.sign(id, privateKey) val sig = CryptoUtils.sign(id, privateKey)
return StatusEvent(id.toHexKey(), pubKey, createdAt, tags, newStatus, sig.toHexKey()) 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())
}
} }
} }