amethyst/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt

1087 wiersze
34 KiB
Kotlin

/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.distinctUntilChanged
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.actions.updated
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.combineWith
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.encoders.toNote
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import java.math.BigDecimal
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Stable
class AddressableNote(val address: ATag) : Note(address.toTag()) {
override fun idNote() = address.toNAddr()
override fun toNEvent() = address.toNAddr()
override fun idDisplayNote() = idNote().toShortenHex()
override fun address() = address
override fun createdAt(): Long? {
if (event == null) return null
val publishedAt = (event as? LongTextNoteEvent)?.publishedAt() ?: Long.MAX_VALUE
val lastCreatedAt = event?.createdAt() ?: Long.MAX_VALUE
return minOf(publishedAt, lastCreatedAt)
}
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
}
@Stable
open class Note(val idHex: String) {
// These fields are only available after the Text Note event is received.
// They are immutable after that.
var event: EventInterface? = null
var author: User? = null
var replyTo: List<Note>? = null
// These fields are updated every time an event related to this note is received.
var replies = listOf<Note>()
private set
var reactions = mapOf<String, List<Note>>()
private set
var boosts = listOf<Note>()
private set
var reports = mapOf<User, List<Note>>()
private set
var zaps = mapOf<Note, Note?>()
private set
var zapsAmount: BigDecimal = BigDecimal.ZERO
var zapPayments = mapOf<Note, Note?>()
private set
var relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
private set
var lastReactionsDownloadTime: Map<String, EOSETime> = emptyMap()
fun id() = Hex.decode(idHex)
open fun idNote() = id().toNote()
open fun toNEvent(): String {
val myEvent = event
return if (myEvent is WrappedEvent) {
val host = myEvent.host
if (host != null) {
Nip19.createNEvent(
host.id,
host.pubKey,
host.kind(),
relays.firstOrNull()?.url,
)
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
} else {
Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()?.url)
}
}
fun toNostrUri(): String {
return "nostr:${toNEvent()}"
}
open fun idDisplayNote() = idNote().toShortenHex()
fun channelHex(): HexKey? {
return if (
event is ChannelMessageEvent ||
event is ChannelMetadataEvent ||
event is ChannelCreateEvent ||
event is LiveActivitiesChatMessageEvent ||
event is LiveActivitiesEvent
) {
(event as? ChannelMessageEvent)?.channel()
?: (event as? ChannelMetadataEvent)?.channel() ?: (event as? ChannelCreateEvent)?.id
?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag()
?: (event as? LiveActivitiesEvent)?.address()?.toTag()
} else {
null
}
}
open fun address(): ATag? = null
open fun createdAt() = event?.createdAt()
fun loadEvent(
event: Event,
author: User,
replyTo: List<Note>,
) {
if (this.event?.id() != event.id()) {
this.event = event
this.author = author
this.replyTo = replyTo
liveSet?.innerMetadata?.invalidateData()
flowSet?.metadata?.invalidateData()
}
}
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu-MM-dd-HH:mm:ss"))
}
data class LevelSignature(val signature: String, val createdAt: Long?, val author: User?)
/**
* This method caches signatures during each execution to avoid recalculation in longer threads
*/
fun replyLevelSignature(
eventsToConsider: Set<HexKey>,
cachedSignatures: MutableMap<Note, LevelSignature>,
account: User,
accountFollowingSet: Set<String>,
now: Long,
): LevelSignature {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return LevelSignature(
signature = "/" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) + ";",
createdAt = createdAt(),
author = author,
)
}
val parent =
(
replyTo
.filter {
it.idHex in eventsToConsider
} // This forces the signature to be based on a branch, avoiding two roots
.map {
cachedSignatures[it]
?: it
.replyLevelSignature(
eventsToConsider,
cachedSignatures,
account,
accountFollowingSet,
now,
)
.apply { cachedSignatures.put(it, this) }
}
.maxByOrNull { it.signature.length }
)
val parentSignature = parent?.signature?.removeSuffix(";") ?: ""
val threadOrder =
if (parent?.author == author && createdAt() != null) {
// author of the thread first, in **ascending** order
"9" +
formattedDateTime((parent?.createdAt ?: 0) + (now - (createdAt() ?: 0))) +
idHex.substring(0, 8)
} else if (author?.pubkeyHex == account.pubkeyHex) {
"8" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my replies
} else if (author?.pubkeyHex in accountFollowingSet) {
"7" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // my follows replies.
} else {
"0" + formattedDateTime(createdAt() ?: 0) + idHex.substring(0, 8) // everyone else.
}
val mySignature =
LevelSignature(
signature = parentSignature + "/" + threadOrder + ";",
createdAt = createdAt(),
author = author,
)
cachedSignatures[this] = mySignature
return mySignature
}
fun replyLevel(cachedLevels: MutableMap<Note, Int> = mutableMapOf()): Int {
val replyTo = replyTo
if (
event is RepostEvent || event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
) {
return 0
}
return replyTo.maxOf {
cachedLevels[it] ?: it.replyLevel(cachedLevels).apply { cachedLevels.put(it, this) }
} + 1
}
fun addReply(note: Note) {
if (note !in replies) {
replies = replies + note
liveSet?.innerReplies?.invalidateData()
}
}
fun removeReply(note: Note) {
if (note in replies) {
replies = replies - note
liveSet?.innerReplies?.invalidateData()
}
}
fun removeBoost(note: Note) {
if (note in boosts) {
boosts = boosts - note
liveSet?.innerBoosts?.invalidateData()
}
}
fun removeAllChildNotes(): List<Note> {
val toBeRemoved =
replies +
reactions.values.flatten() +
boosts +
reports.values.flatten() +
zaps.keys +
zaps.values.filterNotNull() +
zapPayments.keys +
zapPayments.values.filterNotNull()
replies = listOf<Note>()
reactions = mapOf<String, List<Note>>()
boosts = listOf<Note>()
reports = mapOf<User, List<Note>>()
zaps = mapOf<Note, Note?>()
zapPayments = mapOf<Note, Note?>()
zapsAmount = BigDecimal.ZERO
relays = listOf<RelayBriefInfoCache.RelayBriefInfo>()
lastReactionsDownloadTime = emptyMap()
liveSet?.innerReplies?.invalidateData()
liveSet?.innerReactions?.invalidateData()
liveSet?.innerBoosts?.invalidateData()
liveSet?.innerReports?.invalidateData()
liveSet?.innerZaps?.invalidateData()
return toBeRemoved
}
fun removeReaction(note: Note) {
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
if (reactions[reaction]?.contains(note) == true) {
reactions[reaction]?.let {
if (note in it) {
val newList = it.minus(note)
if (newList.isEmpty()) {
reactions = reactions.minus(reaction)
} else {
reactions = reactions + Pair(reaction, newList)
}
liveSet?.innerReactions?.invalidateData()
}
}
}
}
fun removeReport(deleteNote: Note) {
val author = deleteNote.author ?: return
if (reports[author]?.contains(deleteNote) == true) {
reports[author]?.let {
reports = reports + Pair(author, it.minus(deleteNote))
liveSet?.innerReports?.invalidateData()
}
}
}
fun removeZap(note: Note) {
if (zaps[note] != null) {
zaps = zaps.minus(note)
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
} else if (zaps.containsValue(note)) {
zaps = zaps.filterValues { it != note }
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
}
}
fun removeZapPayment(note: Note) {
if (zapPayments.containsKey(note)) {
zapPayments = zapPayments.minus(note)
liveSet?.innerZaps?.invalidateData()
} else if (zapPayments.containsValue(note)) {
zapPayments = zapPayments.filterValues { it != note }
liveSet?.innerZaps?.invalidateData()
}
}
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
liveSet?.innerBoosts?.invalidateData()
}
}
@Synchronized
private fun innerAddZap(
zapRequest: Note,
zap: Note?,
): Boolean {
if (zaps[zapRequest] == null) {
zaps = zaps + Pair(zapRequest, zap)
return true
}
return false
}
fun addZap(
zapRequest: Note,
zap: Note?,
) {
checkNotInMainThread()
if (zaps[zapRequest] == null) {
val inserted = innerAddZap(zapRequest, zap)
if (inserted) {
updateZapTotal()
liveSet?.innerZaps?.invalidateData()
}
}
}
@Synchronized
private fun innerAddZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
): Boolean {
if (zapPayments[zapPaymentRequest] == null) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
return true
}
return false
}
fun addZapPayment(
zapPaymentRequest: Note,
zapPayment: Note?,
) {
checkNotInMainThread()
if (zapPayments[zapPaymentRequest] == null) {
val inserted = innerAddZapPayment(zapPaymentRequest, zapPayment)
if (inserted) {
liveSet?.innerZaps?.invalidateData()
}
}
}
fun addReaction(note: Note) {
val tags = note.event?.tags() ?: emptyArray()
val reaction = note.event?.content()?.firstFullCharOrEmoji(ImmutableListOfLists(tags)) ?: "+"
val listOfAuthors = reactions[reaction]
if (listOfAuthors == null) {
reactions = reactions + Pair(reaction, listOf(note))
liveSet?.innerReactions?.invalidateData()
} else if (!listOfAuthors.contains(note)) {
reactions = reactions + Pair(reaction, listOfAuthors + note)
liveSet?.innerReactions?.invalidateData()
}
}
fun addReport(note: Note) {
val author = note.author ?: return
val reportsByAuthor = reports[author]
if (reportsByAuthor == null) {
reports = reports + Pair(author, listOf(note))
liveSet?.innerReports?.invalidateData()
} else if (!reportsByAuthor.contains(note)) {
reports = reports + Pair(author, reportsByAuthor + note)
liveSet?.innerReports?.invalidateData()
}
}
@Synchronized
fun addRelaySync(briefInfo: RelayBriefInfoCache.RelayBriefInfo) {
if (briefInfo !in relays) {
relays = relays + briefInfo
}
}
fun addRelay(relay: Relay) {
if (relay.brief !in relays) {
addRelaySync(relay.brief)
liveSet?.innerRelays?.invalidateData()
}
}
private fun recursiveIsPaidByCalculation(
account: Account,
remainingZapPayments: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
return
}
val next = remainingZapPayments.first()
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
account.decryptZapPaymentResponseEvent(zapResponseEvent) { response ->
if (
response is PayInvoiceSuccessResponse &&
account.isNIP47Author(zapResponseEvent.requestAuthor())
) {
onWasZappedByAuthor()
} else {
recursiveIsPaidByCalculation(
account,
remainingZapPayments.minus(next),
onWasZappedByAuthor,
)
}
}
}
}
private fun recursiveIsZappedByCalculation(
option: Int?,
user: User,
account: Account,
remainingZapEvents: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit,
) {
if (remainingZapEvents.isEmpty()) {
return
}
val next = remainingZapEvents.first()
if (next.first.author?.pubkeyHex == user.pubkeyHex) {
onWasZappedByAuthor()
} else {
account.decryptZapContentAuthor(next.first) {
if (
it.pubKey == user.pubkeyHex &&
(option == null || option == (it as? LnZapEvent)?.zappedPollOption())
) {
onWasZappedByAuthor()
} else {
recursiveIsZappedByCalculation(
option,
user,
account,
remainingZapEvents.minus(next),
onWasZappedByAuthor,
)
}
}
}
}
fun isZappedBy(
user: User,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
recursiveIsZappedByCalculation(null, user, account, zaps.toList(), onWasZappedByAuthor)
if (account.userProfile() == user) {
recursiveIsPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
}
}
fun isZappedBy(
option: Int?,
user: User,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
recursiveIsZappedByCalculation(option, user, account, zaps.toList(), onWasZappedByAuthor)
}
fun getReactionBy(user: User): String? {
return reactions.firstNotNullOfOrNull {
if (it.value.any { it.author?.pubkeyHex == user.pubkeyHex }) {
it.key
} else {
null
}
}
}
fun isBoostedBy(user: User): Boolean {
return boosts.any { it.author?.pubkeyHex == user.pubkeyHex }
}
fun hasReportsBy(user: User): Boolean {
return reports[user]?.isNotEmpty() ?: false
}
fun countReportAuthorsBy(users: Set<HexKey>): Int {
return reports.count { it.key.pubkeyHex in users }
}
fun reportsBy(users: Set<HexKey>): List<Note> {
return reports
.mapNotNull {
if (it.key.pubkeyHex in users) {
it.value
} else {
null
}
}
.flatten()
}
private fun updateZapTotal() {
var sumOfAmounts = BigDecimal.ZERO
// Regular Zap Receipts
zaps.forEach {
val noteEvent = it.value?.event
if (noteEvent is LnZapEvent) {
sumOfAmounts += noteEvent.amount ?: BigDecimal.ZERO
}
}
zapsAmount = sumOfAmounts
}
private fun recursiveZappedAmountCalculation(
invoiceSet: LinkedHashSet<String>,
remainingZapPayments: List<Pair<Note, Note?>>,
signer: NostrSigner,
output: BigDecimal,
onReady: (BigDecimal) -> Unit,
) {
if (remainingZapPayments.isEmpty()) {
onReady(output)
return
}
val next = remainingZapPayments.first()
(next.second?.event as? LnZapPaymentResponseEvent)?.response(signer) { noteEvent ->
if (noteEvent is PayInvoiceSuccessResponse) {
(next.first.event as? LnZapPaymentRequestEvent)?.lnInvoice(signer) { invoice ->
val amount =
try {
LnInvoiceUtil.getAmountInSats(invoice)
} catch (e: java.lang.Exception) {
if (e is CancellationException) throw e
null
}
var newAmount = output
if (amount != null && !invoiceSet.contains(invoice)) {
invoiceSet.add(invoice)
newAmount += amount
}
recursiveZappedAmountCalculation(
invoiceSet,
remainingZapPayments.minus(next),
signer,
newAmount,
onReady,
)
}
}
}
}
fun zappedAmountWithNWCPayments(
signer: NostrSigner,
onReady: (BigDecimal) -> Unit,
) {
if (zapPayments.isEmpty()) {
onReady(zapsAmount)
}
val invoiceSet = LinkedHashSet<String>(zaps.size + zapPayments.size)
zaps.forEach { (it.value?.event as? LnZapEvent)?.lnInvoice()?.let { invoiceSet.add(it) } }
recursiveZappedAmountCalculation(
invoiceSet,
zapPayments.toList(),
signer,
zapsAmount,
onReady,
)
}
fun hasPledgeBy(user: User): Boolean {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.any {
val pledgeValue =
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
if (e is CancellationException) throw e
null
// do nothing if it can't convert to bigdecimal
}
pledgeValue != null && it.author == user
}
}
fun pledgedAmountByOthers(): BigDecimal {
return replies
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
.mapNotNull {
try {
BigDecimal(it.event?.content())
} catch (e: Exception) {
if (e is CancellationException) throw e
null
// do nothing if it can't convert to bigdecimal
}
}
.sumOf { it }
}
fun hasAnyReports(): Boolean {
val dayAgo = TimeUtils.oneDayAgo()
return reports.isNotEmpty() ||
(
author?.reports?.any { it.value.firstOrNull { (it.createdAt() ?: 0) > dayAgo } != null }
?: false
)
}
fun isNewThread(): Boolean {
return (
event is RepostEvent ||
event is GenericRepostEvent ||
replyTo == null ||
replyTo?.size == 0
) &&
event !is ChannelMessageEvent &&
event !is LiveActivitiesChatMessageEvent
}
fun hasZapped(loggedIn: User): Boolean {
return zaps.any { it.key.author == loggedIn }
}
fun hasReacted(
loggedIn: User,
content: String,
): Boolean {
return reactedBy(loggedIn, content).isNotEmpty()
}
fun reactedBy(
loggedIn: User,
content: String,
): List<Note> {
return reactions[content]?.filter { it.author == loggedIn } ?: emptyList()
}
fun reactedBy(loggedIn: User): List<String> {
return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key }
}
fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean {
return boosts.firstOrNull {
it.author == loggedIn && (it.createdAt() ?: 0) > TimeUtils.fiveMinutesAgo()
} != null // 5 minute protection
}
fun boostedBy(loggedIn: User): List<Note> {
return boosts.filter { it.author == loggedIn }
}
fun moveAllReferencesTo(note: AddressableNote) {
// migrates these comments to a new version
replies.forEach {
note.addReply(it)
it.replyTo = it.replyTo?.updated(this, note)
}
reactions.forEach {
it.value.forEach {
note.addReaction(it)
it.replyTo = it.replyTo?.updated(this, note)
}
}
boosts.forEach {
note.addBoost(it)
it.replyTo = it.replyTo?.updated(this, note)
}
reports.forEach {
it.value.forEach {
note.addReport(it)
it.replyTo = it.replyTo?.updated(this, note)
}
}
zaps.forEach {
note.addZap(it.key, it.value)
it.key.replyTo = it.key.replyTo?.updated(this, note)
it.value?.replyTo = it.value?.replyTo?.updated(this, note)
}
replyTo = null
replies = emptyList()
reactions = emptyMap()
boosts = emptyList()
reports = emptyMap()
zaps = emptyMap()
zapsAmount = BigDecimal.ZERO
}
fun clearEOSE() {
lastReactionsDownloadTime = emptyMap()
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
val thisEvent = event ?: return false
val isBoostedNoteHidden =
if (
thisEvent is GenericRepostEvent ||
thisEvent is RepostEvent ||
thisEvent is CommunityPostApprovalEvent
) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
}
val isHiddenByWord =
if (thisEvent is BaseTextNoteEvent) {
accountChoices.hiddenWords.any {
thisEvent.content.containsAny(accountChoices.hiddenWordsCase)
}
} else {
false
}
val isSensitive = thisEvent.isSensitive()
return isBoostedNoteHidden ||
isHiddenByWord ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)
}
var liveSet: NoteLiveSet? = null
var flowSet: NoteFlowSet? = null
@Synchronized
fun createOrDestroyLiveSync(create: Boolean) {
if (create) {
if (liveSet == null) {
liveSet = NoteLiveSet(this)
}
} else {
if (liveSet != null && liveSet?.isInUse() == false) {
liveSet?.destroy()
liveSet = null
}
}
}
fun live(): NoteLiveSet {
if (liveSet == null) {
createOrDestroyLiveSync(true)
}
return liveSet!!
}
fun clearLive() {
if (liveSet != null && liveSet?.isInUse() == false) {
createOrDestroyLiveSync(false)
}
}
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
if (create) {
if (flowSet == null) {
flowSet = NoteFlowSet(this)
}
} else {
if (flowSet != null && flowSet?.isInUse() == false) {
flowSet?.destroy()
flowSet = null
}
}
}
fun flow(): NoteFlowSet {
if (flowSet == null) {
createOrDestroyFlowSync(true)
}
return flowSet!!
}
fun clearFlow() {
if (flowSet != null && flowSet?.isInUse() == false) {
createOrDestroyFlowSync(false)
}
}
}
@Stable
class NoteFlowSet(u: Note) {
// Observers line up here.
val metadata = NoteBundledRefresherFlow(u)
fun isInUse(): Boolean {
return metadata.stateFlow.subscriptionCount.value > 0
}
fun destroy() {
metadata.destroy()
}
}
@Stable
class NoteLiveSet(u: Note) {
// Observers line up here.
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 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 }.distinctUntilChanged()
val hasEvent = innerMetadata.map { it.note.event != null }.distinctUntilChanged()
val hasReactions =
innerZaps
.combineWith(innerBoosts, innerReactions) { zapState, boostState, reactionState ->
zapState?.note?.zaps?.isNotEmpty()
?: false ||
boostState?.note?.boosts?.isNotEmpty() ?: false ||
reactionState?.note?.reactions?.isNotEmpty() ?: false
}
.distinctUntilChanged()
val replyCount = innerReplies.map { it.note.replies.size }.distinctUntilChanged()
val reactionCount =
innerReactions
.map {
var total = 0
it.note.reactions.forEach { total += it.value.size }
total
}
.distinctUntilChanged()
val boostCount = innerBoosts.map { it.note.boosts.size }.distinctUntilChanged()
val relayInfo = innerRelays.map { it.note.relays }
val content = innerMetadata.map { it.note.event?.content() ?: "" }
fun isInUse(): Boolean {
return metadata.hasObservers() ||
reactions.hasObservers() ||
boosts.hasObservers() ||
replies.hasObservers() ||
reports.hasObservers() ||
relays.hasObservers() ||
zaps.hasObservers() ||
authorChanges.hasObservers() ||
hasEvent.hasObservers() ||
hasReactions.hasObservers() ||
replyCount.hasObservers() ||
reactionCount.hasObservers() ||
boostCount.hasObservers()
}
fun destroy() {
innerMetadata.destroy()
innerReactions.destroy()
innerBoosts.destroy()
innerReplies.destroy()
innerReports.destroy()
innerRelays.destroy()
innerZaps.destroy()
}
}
@Stable
class NoteBundledRefresherFlow(val note: Note) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
val stateFlow = MutableStateFlow(NoteState(note))
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
stateFlow.emit(NoteState(note))
}
}
}
@Stable
class NoteBundledRefresherLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(500, Dispatchers.IO)
fun destroy() {
bundler.cancel()
}
fun invalidateData() {
checkNotInMainThread()
bundler.invalidate {
checkNotInMainThread()
postValue(NoteState(note))
}
}
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
}
}
@Stable
class NoteLoadingLiveData<Y>(val note: Note, initialValue: Y?) : MediatorLiveData<Y>(initialValue) {
override fun onActive() {
super.onActive()
if (note is AddressableNote) {
NostrSingleEventDataSource.addAddress(note)
} else {
NostrSingleEventDataSource.add(note)
}
}
override fun onInactive() {
super.onInactive()
if (note is AddressableNote) {
NostrSingleEventDataSource.removeAddress(note)
} else {
NostrSingleEventDataSource.remove(note)
}
}
}
@Immutable class NoteState(val note: Note)
object RelayBriefInfoCache {
val cache = LruCache<String, RelayBriefInfo?>(50)
@Immutable
data class RelayBriefInfo(
val url: String,
val displayUrl: String =
url.trim().removePrefix("wss://").removePrefix("ws://").removeSuffix("/").intern(),
val favIcon: String = "https://$displayUrl/favicon.ico".intern(),
)
fun get(url: String): RelayBriefInfo {
val info = cache[url]
if (info != null) return info
val newInfo = RelayBriefInfo(url)
cache.put(url, newInfo)
return newInfo
}
}