Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-08-14 04:22:54 +00:00
committed by GitHub
78 changed files with 941 additions and 820 deletions
@@ -46,7 +46,6 @@ import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -120,7 +119,7 @@ class Amethyst : Application() {
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
val logger = if (isDebug) RelaySpeedLogger(client) else null
// val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState
@@ -131,16 +132,13 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.addressables.taggedAddresses
import com.vitorpamplona.quartz.nip01Core.tags.events.taggedEventIds
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip04Dm.messages.reply
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
@@ -161,12 +159,10 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip37Drafts.DraftBuilder
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Response
@@ -219,6 +215,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.Locale
import kotlin.collections.forEach
@OptIn(DelicateCoroutinesApi::class)
@Stable
@@ -299,6 +296,7 @@ class Account(
val serverLists = MergedServerListState(fileStorageServers, blossomServers, scope)
// Relay settings
val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope)
val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope)
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
@@ -518,10 +516,8 @@ class Account(
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?,
onWasZapped: () -> Unit,
) {
zappedNote?.isZappedBy(userProfile(), this, onWasZapped)
}
afterTimeInSeconds: Long,
): Boolean = zappedNote?.isZappedBy(userProfile(), afterTimeInSeconds, this) == true
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
@@ -1161,103 +1157,42 @@ class Account(
return event
}
suspend fun <T : Event> signAndSend(
draftTag: String?,
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
broadcastNotes: List<Entity>,
) = signAndSend(draftTag, template, relayList, mapEntitiesToNotes(broadcastNotes).toSet())
suspend fun <T : Event> signAndSend(
draftTag: String?,
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
broadcastNotes: Set<Note>,
) {
if (draftTag != null) {
val rumor = RumorAssembler.assembleRumor(signer.pubKey, template)
val draftEvent =
DraftEvent.create(
dTag = draftTag,
innerEvent = rumor,
anchorTagArray = emptyList(),
signer = signer,
)
draftsDecryptionCache.preload(draftEvent, rumor)
sendDraftEvent(draftEvent)
} else {
val it = signer.sign(template)
cache.justConsumeMyOwnEvent(it)
client.send(it, relayList = relayList)
broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } }
}
}
suspend fun <T : Event> signAndSendNIP04Message(
draftTag: String?,
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
) {
if (draftTag != null) {
if (template.content.isEmpty()) {
deleteDraft(draftTag)
} else {
val rumor = RumorAssembler.assembleRumor(signer.pubKey, template)
val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer)
draftsDecryptionCache.preload(draftEvent, rumor)
sendDraftEvent(draftEvent)
}
} else {
val newEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(newEvent)
client.send(newEvent, relayList)
}
}
suspend fun <T : Event> signAndSendWithList(
draftTag: String?,
template: EventTemplate<T>,
relayList: Collection<NormalizedRelayUrl>,
broadcastNotes: Set<Note>,
) {
if (draftTag != null) {
val rumor = RumorAssembler.assembleRumor(signer.pubKey, template)
val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer)
draftsDecryptionCache.preload(draftEvent, rumor)
sendDraftEvent(draftEvent)
} else {
val event = signer.sign(template)
cache.justConsumeMyOwnEvent(event)
val relaySet = relayList.toSet()
client.send(event, relayList = relaySet)
broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relaySet) } }
}
}
suspend fun createAndSendDraft(
draftTag: String,
template: EventTemplate<out Event>,
broadcast: Set<Event> = emptySet(),
) {
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
val rumor = RumorAssembler.assembleRumor(signer.pubKey, template)
val draftEvent = DraftBuilder.encryptAndSign(draftTag, rumor, signer)
draftsDecryptionCache.preload(draftEvent, rumor)
sendDraftEvent(draftEvent)
cache.justConsumeMyOwnEvent(draftEvent)
val relayList = (privateStorageRelayList.flow.value + localRelayList.flow.value + extraRelays).toSet()
if (relayList.isNotEmpty()) {
client.send(draftEvent, relayList)
broadcast.forEach {
client.send(it, relayList.toSet())
}
}
}
suspend fun deleteDraft(draftTag: String) {
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
cache.getAddressableNoteIfExists(key)?.let { note ->
val noteEvent = note.event
if (noteEvent is DraftEvent) {
val deletedDraftEvent = noteEvent.createDeletedEvent(signer)
client.send(deletedDraftEvent, outboxRelays.flow.value + note.relays)
cache.justConsumeMyOwnEvent(deletedDraftEvent)
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
delete(deletedDraftEvent, note.relays.toSet())
}
val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer)
val deletionEvent = signer.sign(DeletionEvent.build(listOf(deletedDraft)))
val relayList = (privateStorageRelayList.flow.value + localRelayList.flow.value + extraRelays).toSet()
cache.justConsumeMyOwnEvent(deletedDraft)
cache.justConsumeMyOwnEvent(deletionEvent)
if (relayList.isNotEmpty()) {
client.send(deletedDraft, relayList)
client.send(deletionEvent, relayList)
}
}
@@ -1277,7 +1212,18 @@ class Account(
currentSceneRelay = readingSceneRelay,
)
sendToPrivateOutboxAndLocal(signer.sign(template))
val event = signer.sign(template)
// updates relays that already have this replaceable.
val noteRelays = cache.getAddressableNoteIfExists(event.address())?.relays ?: emptyList()
val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value
if (relayList.isNotEmpty()) {
client.send(event, relayList + noteRelays)
} else {
client.send(event, outboxRelays.flow.value + noteRelays)
}
cache.justConsumeMyOwnEvent(event)
}
suspend fun updateInteractiveStoryReadingState(
@@ -1294,7 +1240,18 @@ class Account(
currentSceneRelay = readingSceneRelay,
)
sendToPrivateOutboxAndLocal(signer.sign(template))
val event = signer.sign(template)
// updates relays that already have this replaceable.
val noteRelays = cache.getAddressableNoteIfExists(event.address())?.relays ?: emptyList()
val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value
if (relayList.isNotEmpty()) {
client.send(event, relayList + noteRelays)
} else {
client.send(event, outboxRelays.flow.value + noteRelays)
}
cache.justConsumeMyOwnEvent(event)
}
fun mapEntitiesToNotes(entities: List<Entity>): List<Note> =
@@ -1348,7 +1305,15 @@ class Account(
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
signAndSend(draftTag, template, relayList, quotes)
if (draftTag != null) {
createAndSendDraft(draftTag, template)
} else {
val it = signer.sign(template)
cache.justConsumeMyOwnEvent(it)
client.send(it, relayList = relayList)
mapEntitiesToNotes(quotes).forEach { it.event?.let { client.send(it, relayList = relayList) } }
}
}
suspend fun sendInteractiveStoryScene(
@@ -1383,29 +1348,42 @@ class Account(
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
signAndSend(draftTag, template, relayList, mapEntitiesToNotes(quotes).toSet())
val broadcastNotes = mapEntitiesToNotes(quotes).toSet()
if (draftTag != null) {
createAndSendDraft(draftTag, template)
} else {
val it = signer.sign(template)
cache.justConsumeMyOwnEvent(it)
client.send(it, relayList = relayList)
broadcastNotes.forEach { it.event?.let { client.send(it, relayList = relayList) } }
}
}
suspend fun sendAddBounty(
value: BigDecimal,
bounty: Note,
draftTag: String?,
) {
if (!isWriteable()) return
val event = bounty.event as? TextNoteEvent ?: return
val eventAuthor = bounty.author ?: return
val bountyEvent = bounty.event as? TextNoteEvent ?: return
val bountyAuthor = bounty.author ?: return
val template =
BountyAddValueEvent.build(
value,
EventHintBundle(event, bounty.relayHintUrl()),
eventAuthor.toPTag(),
amount = value,
bountyRoot = EventHintBundle(bountyEvent, bounty.relayHintUrl()),
bountyRootAuthor = bountyAuthor.toPTag(),
)
val relays = bounty.relays + outboxRelays.flow.value
val relays = (bounty.relays + outboxRelays.flow.value).toSet()
signAndSendWithList(draftTag, template, relays, setOf(bounty))
val newEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(newEvent)
client.send(newEvent, relayList = relays)
client.send(bountyEvent, relayList = relays)
}
suspend fun sendEdit(
@@ -1437,82 +1415,27 @@ class Account(
broadcast.forEach { client.send(it, relayList) }
}
suspend fun sendPrivateMessage(
message: String,
toUser: PTag,
replyingTo: Note? = null,
zapReceiver: List<ZapSplitSetup>? = null,
contentWarningReason: String? = null,
zapRaiserAmount: Long? = null,
geohash: String? = null,
imetas: List<IMetaTag>? = null,
emojis: List<EmojiUrlTag>? = null,
draftTag: String?,
) {
suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate<PrivateDmEvent>) {
if (!isWriteable()) return
val encryptedContent =
signer.nip04Encrypt(
PrivateDmEvent.prepareMessageToEncrypt(message, imetas),
toUser.pubKey,
)
val newEvent = signer.sign(eventTemplate)
val recipient = newEvent.verifiedRecipientPubKey()
val destinationRelays = recipient?.let { cache.getOrCreateUser(it).dmInboxRelays() } ?: emptyList()
val template =
PrivateDmEvent.build(toUser, encryptedContent) {
replyingTo?.let { reply(it.toEId()) }
geohash?.let { geohash(it) }
zapRaiserAmount?.let { zapraiser(it) }
zapReceiver?.let { zapSplits(it) }
emojis?.let { emojis(it) }
contentWarningReason?.let { contentWarning(contentWarningReason) }
}
val destinationRelays = cache.getOrCreateUser(toUser.pubKey).dmInboxRelays()
signAndSendNIP04Message(draftTag, template, outboxRelays.flow.value + destinationRelays)
cache.justConsumeMyOwnEvent(newEvent)
client.send(newEvent, outboxRelays.flow.value + destinationRelays)
}
suspend fun sendNIP17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val wraps = NIP17Factory().createEncryptedFileNIP17(template, signer)
broadcastPrivately(wraps)
}
suspend fun sendNIP17PrivateMessage(
template: EventTemplate<ChatMessageEvent>,
draftTag: String? = null,
) {
if (draftTag != null) {
if (template.content.isEmpty()) {
deleteDraft(draftTag)
} else {
val rumor = RumorAssembler.assembleRumor(signer.pubKey, template)
val draftEvent = DraftEvent.create(draftTag, rumor, emptyList(), signer)
draftsDecryptionCache.preload(draftEvent, rumor)
sendDraftEvent(draftEvent)
}
} else {
val it = NIP17Factory().createMessageNIP17(template, signer)
broadcastPrivately(it)
}
}
fun sendDraftEvent(draftEvent: DraftEvent) {
sendToPrivateOutboxAndLocal(draftEvent)
}
fun sendToPrivateOutboxAndLocal(event: Event?) {
if (event == null) return
val relayList = privateStorageRelayList.flow.value + localRelayList.flow.value
if (relayList.isNotEmpty()) {
client.send(event, relayList.toSet())
} else {
client.send(event, outboxRelays.flow.value)
}
cache.justConsumeMyOwnEvent(event)
suspend fun sendNip17PrivateMessage(template: EventTemplate<ChatMessageEvent>) {
val events = NIP17Factory().createMessageNIP17(template, signer)
broadcastPrivately(events)
}
suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
@@ -1665,7 +1588,7 @@ class Account(
privateDMDecryptionCache.cachedDM(event)
} else if (event is LnZapRequestEvent && event.isPrivateZap()) {
privateZapsDecryptionCache.cachedPrivateZap(event)?.content
} else if (event is DraftEvent) {
} else if (event is DraftWrapEvent) {
draftsDecryptionCache.preCachedDraft(event)?.content
} else {
event.content
@@ -1689,7 +1612,7 @@ class Account(
} else {
event.content
}
} else if (event is DraftEvent && isWriteable()) {
} else if (event is DraftWrapEvent && isWriteable()) {
draftsDecryptionCache.cachedDraft(event)?.content
} else {
event?.content
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip28PublicChat.list.tags.ChannelTag
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
@@ -89,7 +89,7 @@ val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Co
val DefaultSignerPermissions =
listOf(
Permission(CommandType.SIGN_EVENT, RelayAuthEvent.KIND),
Permission(CommandType.SIGN_EVENT, DraftEvent.KIND),
Permission(CommandType.SIGN_EVENT, DraftWrapEvent.KIND),
Permission(CommandType.NIP04_ENCRYPT),
Permission(CommandType.NIP04_DECRYPT),
Permission(CommandType.NIP44_DECRYPT),
@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.ListChange
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.LargeCache
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
abstract class Channel : NotesGatherer {
@@ -34,6 +38,16 @@ abstract class Channel : NotesGatherer {
private var relays = mapOf<NormalizedRelayUrl, Counter>()
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
return new
}
abstract fun toBestDisplayName(): String
open fun relays(): Set<NormalizedRelayUrl> =
@@ -80,6 +94,8 @@ abstract class Channel : NotesGatherer {
addRelay(relay)
}
changesFlow.get()?.tryEmit(ListChange.Addition(note))
flowSet?.notes?.invalidateData()
}
}
@@ -93,6 +109,8 @@ abstract class Channel : NotesGatherer {
lastNote = notes.values().sortedWith(DefaultFeedOrder).firstOrNull()
}
changesFlow.get()?.tryEmit(ListChange.Deletion(note))
flowSet?.notes?.invalidateData()
}
}
@@ -109,6 +127,8 @@ abstract class Channel : NotesGatherer {
toBeRemoved.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(toBeRemoved.toSet()))
flowSet?.notes?.invalidateData()
return toBeRemoved.toSet()
@@ -123,6 +143,8 @@ abstract class Channel : NotesGatherer {
hidden.forEach { notes.remove(it.idHex) }
changesFlow.get()?.tryEmit(ListChange.SetDeletion(hidden))
flowSet?.notes?.invalidateData()
return hidden.toSet()
@@ -120,7 +120,7 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
@@ -2549,7 +2549,7 @@ object LocalCache : ILocalCache {
}
fun consume(
event: DraftEvent,
event: DraftWrapEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean = !event.isDeleted() && consumeBaseReplaceable(event, relay, wasVerified)
@@ -2757,7 +2757,7 @@ object LocalCache : ILocalCache {
is CommunityPostApprovalEvent -> consume(event, relay, wasVerified)
is ContactListEvent -> consume(event, relay, wasVerified)
is DeletionEvent -> consume(event, relay, wasVerified)
is DraftEvent -> consume(event, relay, wasVerified)
is DraftWrapEvent -> consume(event, relay, wasVerified)
is EmojiPackEvent -> consume(event, relay, wasVerified)
is EmojiPackSelectionEvent -> consume(event, relay, wasVerified)
is EphemeralChatEvent -> consume(event, relay, wasVerified)
@@ -52,7 +52,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
@@ -235,7 +235,7 @@ open class Note(
open fun createdAt() = event?.createdAt
fun isDraft() = event is DraftEvent
fun isDraft() = event is DraftWrapEvent
fun loadEvent(
event: Event,
@@ -472,28 +472,28 @@ open class Note(
}
private suspend fun isPaidByCalculation(
zapPayments: List<Pair<Note, Note?>>,
afterTimeInSeconds: Long,
account: Account,
zapEvents: List<Pair<Note, Note?>>,
onWasZappedByAuthor: () -> Unit,
) {
if (zapEvents.isEmpty()) {
return
): Boolean {
if (zapPayments.isEmpty()) {
return false
}
var hasSentOne = false
launchAndWaitAll(zapEvents) { next ->
return anyAsync(zapPayments) { next ->
val zapResponseEvent = next.second?.event as? LnZapPaymentResponseEvent
if (zapResponseEvent != null) {
account.nip47SignerState.decryptResponse(zapResponseEvent)?.let { response ->
val result = response is PayInvoiceSuccessResponse && account.nip47SignerState.isNIP47Author(zapResponseEvent.requestAuthor())
if (!hasSentOne && result == true) {
hasSentOne = true
onWasZappedByAuthor()
}
val response = account.nip47SignerState.decryptResponse(zapResponseEvent)
if (response != null) {
response is PayInvoiceSuccessResponse &&
account.nip47SignerState.isNIP47Author(zapResponseEvent.requestAuthor()) &&
zapResponseEvent.createdAt > afterTimeInSeconds
} else {
false
}
} else {
false
}
}
}
@@ -501,75 +501,81 @@ open class Note(
private suspend fun isZappedByCalculation(
option: Int?,
user: User,
afterTimeInSeconds: Long,
account: Account,
zapEvents: Map<Note, Note?>,
onWasZappedByAuthor: () -> Unit,
) {
): Boolean {
if (zapEvents.isEmpty()) {
return
return false
}
val parallelDecrypt = mutableListOf<Pair<LnZapRequestEvent, LnZapEvent?>>()
val parallelDecrypt = mutableListOf<Pair<LnZapRequestEvent, LnZapEvent>>()
zapEvents.forEach { next ->
val zapRequest = next.key.event as LnZapRequestEvent
val zapEvent = next.value?.event as? LnZapEvent
if (!zapRequest.isPrivateZap()) {
// public events
if (zapRequest.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
return
}
} else {
// private events
// if has already decrypted
val privateZap = account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest)
if (privateZap != null) {
if (privateZap.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) {
onWasZappedByAuthor()
return
if (zapEvent != null) {
if (!zapRequest.isPrivateZap()) {
// public events
if (zapRequest.pubKey == user.pubkeyHex &&
zapEvent.createdAt > afterTimeInSeconds &&
(option == null || option == zapEvent.zappedPollOption())
) {
return true
}
} else {
if (account.isWriteable()) {
parallelDecrypt.add(Pair(zapRequest, zapEvent))
// private events
// if has already decrypted
val privateZap = account.privateZapsDecryptionCache.cachedPrivateZap(zapRequest)
if (privateZap != null) {
if (privateZap.pubKey == user.pubkeyHex &&
zapEvent.createdAt > afterTimeInSeconds &&
(option == null || option == zapEvent.zappedPollOption())
) {
return true
}
} else {
if (account.isWriteable()) {
parallelDecrypt.add(Pair(zapRequest, zapEvent))
}
}
}
}
}
val result =
anyAsync(parallelDecrypt) { pair ->
val result = account.privateZapsDecryptionCache.decryptPrivateZap(pair.first)
if (parallelDecrypt.isEmpty()) {
return false
}
result?.pubKey == user.pubkeyHex && (option == null || option == pair.second?.zappedPollOption())
}
if (result) {
onWasZappedByAuthor()
return anyAsync(parallelDecrypt) { pair ->
val result = account.privateZapsDecryptionCache.decryptPrivateZap(pair.first)
result?.pubKey == user.pubkeyHex &&
pair.second.createdAt > afterTimeInSeconds &&
(option == null || option == pair.second.zappedPollOption())
}
}
suspend fun isZappedBy(
user: User,
afterTimeInSeconds: Long,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
isZappedByCalculation(null, user, account, zaps, onWasZappedByAuthor)
): Boolean {
val first = isZappedByCalculation(null, user, afterTimeInSeconds, account, zaps)
if (first) return true
if (account.userProfile() == user) {
isPaidByCalculation(account, zapPayments.toList(), onWasZappedByAuthor)
return isPaidByCalculation(zapPayments.toList(), afterTimeInSeconds, account)
}
return false
}
suspend fun isZappedBy(
option: Int?,
user: User,
afterTimeInSeconds: Long,
account: Account,
onWasZappedByAuthor: () -> Unit,
) {
isZappedByCalculation(option, user, account, zaps, onWasZappedByAuthor)
}
): Boolean = isZappedByCalculation(option, user, afterTimeInSeconds, account, zaps)
fun getReactionBy(user: User): String? =
reactions.firstNotNullOfOrNull {
@@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 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.nip01UserMetadata
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
class AccountHomeRelayState(
nip65: Nip65RelayListState,
privateStorage: PrivateStorageRelayListState,
local: LocalRelayListState,
scope: CoroutineScope,
) {
val flow =
combine(
nip65.outboxFlow,
privateStorage.flow,
local.flow,
) { nip65Outbox, privateOutBox, localRelays ->
nip65Outbox + privateOutBox + localRelays
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
SharingStarted.Eagerly,
nip65.outboxFlow.value +
privateStorage.flow.value +
local.flow.value,
)
}
@@ -44,8 +44,8 @@ class AccountOutboxRelayState(
privateStorage.flow,
local.flow,
broadcast.flow,
) { nip65Inbox, privateOutBox, localRelays, broadcastRelays ->
nip65Inbox + privateOutBox + localRelays + broadcastRelays
) { nip65Outbox, privateOutBox, localRelays, broadcastRelays ->
nip65Outbox + privateOutBox + localRelays + broadcastRelays
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
@@ -131,9 +131,9 @@ class FollowListOutboxOrProxyRelays(
val flowSet: StateFlow<Set<String>> =
flow
.map { relayList ->
relayList.map { it.url }.toSet()
relayList.mapTo(mutableSetOf()) { it.url }
}.onStart {
emit(flow.value.map { it.url }.toSet())
emit(flow.value.mapTo(mutableSetOf()) { it.url })
}.flowOn(Dispatchers.Default)
.stateIn(
scope,
@@ -153,7 +153,7 @@ class NwcSignerState(
val walletService = settings.zapPaymentRequest.value
if (walletService == null) throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, signer)
val event = LnZapPaymentRequestEvent.create(bolt11, walletService.pubKeyHex, nip47Signer.value)
val filter =
NWCPaymentQueryState(
@@ -25,11 +25,15 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NotesGatherer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.ListChange
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import java.lang.ref.WeakReference
@Stable
class Chatroom : NotesGatherer {
@@ -38,7 +42,17 @@ class Chatroom : NotesGatherer {
var subject = MutableStateFlow<String?>(null)
var subjectCreatedAt: Long? = null
var ownerSentMessage: Boolean = false
var lastMessage: Note? = null
var newestMessage: Note? = null
private var changesFlow: WeakReference<MutableSharedFlow<ListChange<Note>>> = WeakReference(null)
fun changesFlow(): MutableSharedFlow<ListChange<Note>> {
val current = changesFlow.get()
if (current != null) return current
val new = MutableSharedFlow<ListChange<Note>>(0, 100, BufferOverflow.DROP_OLDEST)
changesFlow = WeakReference(new)
return new
}
override fun removeNote(note: Note) {
removeMessageSync(note)
@@ -57,8 +71,8 @@ class Chatroom : NotesGatherer {
}
val createdAt = msg.createdAt() ?: 0
if (createdAt > (lastMessage?.createdAt() ?: 0)) {
lastMessage = msg
if (createdAt > (newestMessage?.createdAt() ?: 0)) {
newestMessage = msg
}
val newSubject = msg.event?.subject()
@@ -67,6 +81,9 @@ class Chatroom : NotesGatherer {
subject.tryEmit(newSubject)
subjectCreatedAt = msg.createdAt()
}
changesFlow.get()?.tryEmit(ListChange.Addition(msg))
return true
}
return false
@@ -78,14 +95,27 @@ class Chatroom : NotesGatherer {
messages = messages - msg
msg.removeGatherer(this)
messages
.filter { it.event?.subject() != null }
.sortedBy { it.createdAt() }
.lastOrNull()
?.let {
subject.tryEmit(it.event?.subject())
subjectCreatedAt = it.createdAt()
}
if (msg == newestMessage) {
newestMessage = messages.maxByOrNull { it.createdAt() ?: 0 }
}
if (msg.event?.subject() == subject.value) {
messages
.maxByOrNull {
val noteEvent = it.event
if (noteEvent?.subject() != null) {
noteEvent.createdAt
} else {
0
}
}?.let {
subject.tryEmit(it.event?.subject())
subjectCreatedAt = it.createdAt()
}
}
changesFlow.get()?.tryEmit(ListChange.Deletion(msg))
return true
}
return false
@@ -107,6 +137,9 @@ class Chatroom : NotesGatherer {
val toRemove = messages.minus(toKeep)
messages = toKeep
changesFlow.get()?.tryEmit(ListChange.SetDeletion<Note>(toRemove))
return toRemove
}
}
@@ -44,7 +44,7 @@ class DualHttpClientManager(
factory.buildHttpClient(proxy, mobile, userAgent)
}.stateIn(
scope,
SharingStarted.Lazily,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value, userAgent),
)
@@ -54,7 +54,7 @@ class DualHttpClientManager(
factory.buildHttpClient(mobile, userAgent)
}.stateIn(
scope,
SharingStarted.Lazily,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(isMobileDataProvider.value, userAgent),
)
@@ -39,7 +39,7 @@ class AccountMetadataEoseManager(
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
fun relayFlow(query: AccountQueryState) = query.account.outboxRelays.flow
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
override fun updateFilter(
key: AccountQueryState,
@@ -24,13 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
val ReportsAndBookmarksFromKeyKinds =
listOf(
DraftEvent.KIND,
DraftWrapEvent.KIND,
ReportEvent.KIND,
BookmarkListEvent.KIND,
)
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.fadeIn
@@ -809,9 +808,6 @@ private fun verifyHash(content: MediaUrlContent): Boolean? {
Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot ->
val hash = sha256(snapshot.data.toFile().readBytes()).toHexKey()
Log.d("Image Hash Verification", "$hash == ${content.hash}")
return hash == content.hash
}
@@ -22,11 +22,9 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.logTime
abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
abstract fun applyFilter(newItems: Set<T>): Set<T>
abstract fun sort(items: Set<T>): List<T>
abstract class AdditiveFeedFilter<T> :
FeedFilter<T>(),
IAdditiveFeedFilter<T> {
open fun updateListWith(
oldList: List<T>,
newItems: Set<T>,
@@ -42,4 +40,15 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
oldList
}
}
fun removeNotes(
oldList: List<T>,
deletedNotes: Set<T>,
): List<T> = oldList - deletedNotes
}
interface IAdditiveFeedFilter<T> : IFeedFilter<T> {
fun applyFilter(newItems: Set<T>): Set<T>
fun sort(items: Set<T>): List<T>
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.dal
import kotlinx.coroutines.flow.MutableSharedFlow
interface ChangesFlowFilter<T> : IAdditiveFeedFilter<T> {
fun changesFlow(): MutableSharedFlow<ListChange<T>>
}
sealed class ListChange<out T> {
data class Addition<T>(
val item: T,
) : ListChange<T>()
data class Deletion<T>(
val item: T,
) : ListChange<T>()
data class SetAddition<T>(
val item: Set<T>,
) : ListChange<T>()
data class SetDeletion<T>(
val item: Set<T>,
) : ListChange<T>()
}
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.logTime
abstract class FeedFilter<T> {
fun loadTop(): List<T> {
abstract class FeedFilter<T> : IFeedFilter<T> {
override fun loadTop(): List<T> {
val feed =
logTime(
debugMessage = { "${this.javaClass.simpleName} FeedFilter returning ${it.size} objects" },
@@ -31,13 +31,17 @@ abstract class FeedFilter<T> {
)
return feed.take(limit())
}
}
open fun limit() = 500
interface IFeedFilter<T> {
fun loadTop(): List<T>
fun limit(): Int = 500
/** Returns a string that serves as the key to invalidate the list if it changes. */
abstract fun feedKey(): Any
fun feedKey(): Any
open fun showHiddenKey(): Boolean = false
fun showHiddenKey(): Boolean = false
abstract fun feed(): List<T>
fun feed(): List<T>
}
@@ -20,17 +20,16 @@
*/
package com.vitorpamplona.amethyst.ui.feeds
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.IFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.ammolite.relays.BundledInsert
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.ammolite.relays.BasicBundledInsert
import com.vitorpamplona.ammolite.relays.BasicBundledUpdate
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -42,7 +41,7 @@ import kotlinx.coroutines.launch
@Stable
class FeedContentState(
val localFilter: FeedFilter<Note>,
val localFilter: IFeedFilter<Note>,
val viewModelScope: CoroutineScope,
) : InvalidatableContent {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
@@ -177,8 +176,8 @@ class FeedContentState(
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
private val bundler = BasicBundledUpdate(250, Dispatchers.IO, viewModelScope)
private val bundlerInsert = BasicBundledInsert<Set<Note>>(250, Dispatchers.IO, viewModelScope)
override fun invalidateData(ignoreIfDoing: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
@@ -202,7 +201,9 @@ class FeedContentState(
}
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { refreshFromOldState(it.flatten().toSet()) }
bundlerInsert.invalidateList(newItems) {
refreshFromOldState(it.flatten().toSet())
}
}
fun updateFeedWith(newNotes: Set<Note>) {
@@ -216,10 +217,4 @@ class FeedContentState(
invalidateData()
}
}
fun destroy() {
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
bundlerInsert.cancel()
bundler.cancel()
}
}
@@ -40,7 +40,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
@@ -63,7 +63,7 @@ fun routeFor(
noteEvent: Event,
loggedIn: Account,
): Route? {
if (noteEvent is DraftEvent) {
if (noteEvent is DraftWrapEvent) {
val innerEvent = loggedIn.draftsDecryptionCache.preCachedDraft(noteEvent)
if (innerEvent is IsInPublicChatChannel) {
@@ -187,7 +187,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -552,7 +552,7 @@ fun InnerNoteWithReactions(
}
}
val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && baseNote.event !is DraftEvent
val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && baseNote.event !is DraftWrapEvent
if (isNotRepost) {
if (makeItShort) {
@@ -568,7 +568,7 @@ fun InnerNoteWithReactions(
)
}
} else {
if (baseNote.event is DraftEvent) {
if (baseNote.event is DraftWrapEvent) {
Spacer(modifier = DoubleVertSpacer)
}
}
@@ -647,7 +647,7 @@ private fun RenderNoteRow(
is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav)
is AudioTrackEvent -> RenderAudioTrack(baseNote, ContentScale.FillWidth, accountViewModel, nav)
is AudioHeaderEvent -> RenderAudioHeader(baseNote, ContentScale.FillWidth, accountViewModel, nav)
is DraftEvent -> RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
is DraftWrapEvent -> RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav)
is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
is GenericRepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
@@ -901,7 +901,7 @@ fun ObserveDraftEvent(
accountViewModel: AccountViewModel,
render: @Composable (Note) -> Unit,
) {
val noteEvent by observeNoteEvent<DraftEvent>(note, accountViewModel)
val noteEvent by observeNoteEvent<DraftWrapEvent>(note, accountViewModel)
noteEvent?.let {
val innerNote by produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = it)
@@ -52,6 +52,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -90,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -100,6 +102,7 @@ import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -517,6 +520,8 @@ fun ZapVote(
}
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
var showErrorMessageDialog by remember { mutableStateOf<StringToastMsg?>(null) }
val context = LocalContext.current
@@ -558,6 +563,7 @@ fun ZapVote(
accountViewModel.zapAmountChoices().size == 1 &&
pollViewModel.isValidInputVoteAmount(accountViewModel.zapAmountChoices().first())
) {
zapStartingTime = TimeUtils.now()
accountViewModel.zap(
baseNote,
accountViewModel.zapAmountChoices().first() * 1000,
@@ -583,6 +589,7 @@ fun ZapVote(
accountViewModel,
pollViewModel,
poolOption.option,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = false
zappingProgress = 0f
@@ -660,9 +667,10 @@ fun ZapVote(
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = { zappingProgress },
modifier = Modifier.size(14.dp),
modifier = Size14Modifier,
strokeWidth = 2.dp,
)
}
@@ -688,6 +696,7 @@ fun FilteredZapAmountChoicePopup(
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
pollOption: Int,
onZapStarts: () -> Unit,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (title: String, text: String, toUser: User?) -> Unit,
@@ -717,6 +726,7 @@ fun FilteredZapAmountChoicePopup(
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
@@ -743,6 +753,7 @@ fun FilteredZapAmountChoicePopup(
modifier =
Modifier.combinedClickable(
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
@@ -103,10 +103,8 @@ class PollNoteViewModel : ViewModel() {
viewModelScope.launch(Dispatchers.Default) {
totalZapped = totalZapped()
wasZappedByLoggedInAccount = false
account?.calculateIfNoteWasZappedByAccount(pollNote) {
wasZappedByLoggedInAccount = true
canZap.value = checkIfCanZap()
}
wasZappedByLoggedInAccount = account.calculateIfNoteWasZappedByAccount(pollNote, 0)
canZap.value = checkIfCanZap()
tallies.forEach {
val zappedValue = zappedPollOptionAmount(it.option)
@@ -210,10 +208,8 @@ class PollNoteViewModel : ViewModel() {
suspend fun isPollOptionZappedBy(
option: Int,
user: User,
onWasZappedByAuthor: () -> Unit,
) {
pollNote?.isZappedBy(option, user, account!!, onWasZappedByAuthor)
}
afterTimeInSeconds: Long,
): Boolean = pollNote?.isZappedBy(option, user, afterTimeInSeconds, account) == true
fun cachedIsPollOptionZappedBy(
option: Int,
@@ -66,6 +66,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
@@ -135,7 +136,7 @@ import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightWithPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiserWithPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size14dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size18dp
import com.vitorpamplona.amethyst.ui.theme.Size19Modifier
@@ -154,11 +155,13 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.EmptyClientListener.onError
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
@@ -977,7 +980,7 @@ fun ZapReaction(
accountViewModel: AccountViewModel,
iconSize: Dp = Size20dp,
iconSizeModifier: Modifier = Size20Modifier,
animationSize: Dp = Size14dp,
animationModifier: Modifier = Size14Modifier,
nav: INav,
) {
var wantsToZap by remember { mutableStateOf(false) }
@@ -994,6 +997,7 @@ fun ZapReaction(
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
Row(
verticalAlignment = CenterVertically,
@@ -1008,6 +1012,7 @@ fun ZapReaction(
baseNote,
accountViewModel,
context,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZappingProgress = { progress: Float -> scope.launch { zappingProgress = progress } },
onMultipleChoices = {
scope.launch {
@@ -1033,6 +1038,7 @@ fun ZapReaction(
baseNote = baseNote,
popupYOffset = iconSize,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = false
zappingProgress = 0f
@@ -1083,6 +1089,7 @@ fun ZapReaction(
if (wantsToSetCustomZap) {
ZapCustomDialog(
onZapStarts = { zapStartingTime = TimeUtils.now() },
onClose = { wantsToSetCustomZap = false },
onError = { _, message, user ->
scope.launch {
@@ -1106,11 +1113,23 @@ fun ZapReaction(
label = "ZapIconIndicator",
)
CircularProgressIndicator(
progress = { animatedProgress },
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp,
)
ObserveZapIcon(
baseNote,
accountViewModel,
zapStartingTime,
) { wasZappedByLoggedInUser ->
CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = animationModifier,
strokeWidth = 2.dp,
)
}
}
}
} else {
ObserveZapIcon(
baseNote,
@@ -1136,6 +1155,7 @@ fun zapClick(
baseNote: Note,
accountViewModel: AccountViewModel,
context: Context,
onZapStarts: () -> Unit,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: () -> Unit,
onError: (String, String, User?) -> Unit,
@@ -1162,6 +1182,7 @@ fun zapClick(
R.string.login_with_a_private_key_to_be_able_to_send_zaps,
)
} else if (choices.size == 1) {
onZapStarts()
accountViewModel.zap(
baseNote,
choices.first() * 1000,
@@ -1181,6 +1202,7 @@ fun zapClick(
fun ObserveZapIcon(
baseNote: Note,
accountViewModel: AccountViewModel,
afterTimeInSeconds: Long = 0,
inner: @Composable (MutableState<Boolean>) -> Unit,
) {
val wasZappedByLoggedInUser = remember { mutableStateOf(false) }
@@ -1196,10 +1218,9 @@ fun ObserveZapIcon(
LaunchedEffect(key1 = zapsState) {
if (zapsState?.note?.zapPayments?.isNotEmpty() == true || zapsState?.note?.zaps?.isNotEmpty() == true) {
accountViewModel.calculateIfNoteWasZappedByAccount(baseNote) { newWasZapped ->
if (wasZappedByLoggedInUser.value != newWasZapped) {
wasZappedByLoggedInUser.value = newWasZapped
}
val newWasZapped = accountViewModel.calculateIfNoteWasZappedByAccount(baseNote, afterTimeInSeconds)
if (wasZappedByLoggedInUser.value != newWasZapped) {
wasZappedByLoggedInUser.value = newWasZapped
}
}
}
@@ -1552,6 +1573,7 @@ fun ZapAmountChoicePopup(
baseNote: Note,
accountViewModel: AccountViewModel,
popupYOffset: Dp,
onZapStarts: () -> Unit,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (title: String, text: String, user: User?) -> Unit,
@@ -1562,7 +1584,7 @@ fun ZapAmountChoicePopup(
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent)
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onZapStarts, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent)
}
@Composable
@@ -1571,6 +1593,7 @@ fun ZapAmountChoicePopup(
zapAmountChoices: ImmutableList<Long>,
accountViewModel: AccountViewModel,
popupYOffset: Dp,
onZapStarts: () -> Unit,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
onError: (title: String, text: String, user: User?) -> Unit,
@@ -1578,7 +1601,7 @@ fun ZapAmountChoicePopup(
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
val visibilityState = rememberVisibilityState(onDismiss)
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, visibilityState, onChangeAmount, onError, onProgress, onPayViaIntent)
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, visibilityState, onZapStarts, onChangeAmount, onError, onProgress, onPayViaIntent)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@@ -1589,6 +1612,7 @@ fun ZapAmountChoicePopup(
accountViewModel: AccountViewModel,
popupYOffset: Dp,
visibilityState: MutableTransitionState<Boolean>,
onZapStarts: () -> Unit,
onChangeAmount: () -> Unit,
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
@@ -1615,6 +1639,7 @@ fun ZapAmountChoicePopup(
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
@@ -1641,6 +1666,7 @@ fun ZapAmountChoicePopup(
modifier =
Modifier.combinedClickable(
onClick = {
onZapStarts()
accountViewModel.zap(
baseNote,
amountInSats * 1000,
@@ -103,6 +103,7 @@ class ZapOptionViewModel : ViewModel() {
@Composable
fun ZapCustomDialog(
onZapStarts: () -> Unit,
onClose: () -> Unit,
onError: (title: String, text: String, user: User?) -> Unit,
onProgress: (percent: Float) -> Unit,
@@ -172,6 +173,7 @@ fun ZapCustomDialog(
ZapButton(
isActive = postViewModel.canSend() && !baseNote.isDraft(),
) {
onZapStarts()
accountViewModel.zap(
baseNote,
postViewModel.value()!! * 1000L,
@@ -179,7 +179,6 @@ class AddBountyAmountViewModel : ViewModel() {
myAccount.sendAddBounty(
newValue,
bountyInner,
draftTag = null,
)
}
}
@@ -40,6 +40,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -83,12 +84,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -285,7 +288,7 @@ fun ZapDonationButton(
accountViewModel: AccountViewModel,
iconSize: Dp = Size35dp,
iconSizeModifier: Modifier = Size20Modifier,
animationSize: Dp = 14.dp,
animationModifier: Modifier = Size14Modifier,
nav: INav,
) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) }
@@ -300,6 +303,8 @@ fun ZapDonationButton(
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
var hasZapped by remember { mutableStateOf(false) }
Button(
@@ -308,6 +313,7 @@ fun ZapDonationButton(
baseNote,
accountViewModel,
context,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZappingProgress = { progress: Float ->
scope.launch { zappingProgress = progress }
},
@@ -329,6 +335,7 @@ fun ZapDonationButton(
zapAmountChoices = it,
popupYOffset = iconSize,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = null
zappingProgress = 0f
@@ -376,17 +383,30 @@ fun ZapDonationButton(
if (zappingProgress > 0.00001 && zappingProgress < 0.99999) {
Spacer(ModifierWidth3dp)
CircularProgressIndicator(
progress =
animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "ZapIconIndicator",
).value,
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp,
color = grayTint,
val animatedProgress by animateFloatAsState(
targetValue = zappingProgress,
animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,
label = "ZapIconIndicator",
)
ObserveZapIcon(
baseNote,
accountViewModel,
zapStartingTime,
) { wasZappedByLoggedInUser ->
CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = animationModifier,
strokeWidth = 2.dp,
color = grayTint,
)
}
}
}
} else {
ObserveZapIcon(
baseNote,
@@ -423,6 +443,7 @@ fun customZapClick(
baseNote: Note,
accountViewModel: AccountViewModel,
context: Context,
onZapStarts: () -> Unit,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: (List<Long>) -> Unit,
onError: (String, String, User?) -> Unit,
@@ -452,6 +473,7 @@ fun customZapClick(
val amount = choices.first()
if (amount > 1100) {
onZapStarts()
accountViewModel.zap(
baseNote,
amount * 1000,
@@ -82,7 +82,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -202,7 +202,7 @@ open class CommentPostViewModel :
val noteEvent = draft.event
val noteAuthor = draft.author
if (noteEvent is DraftEvent && noteAuthor != null) {
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -324,13 +324,14 @@ open class CommentPostViewModel :
if (message.text.isBlank()) {
accountViewModel.account.deleteDraft(draftTag.current)
} else {
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template)
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
account.sendToPrivateOutboxAndLocal(it.first)
account.sendToPrivateOutboxAndLocal(it.second)
attachments.add(it.first)
attachments.add(it.second)
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template, attachments)
}
}
@@ -28,7 +28,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedState
@@ -50,7 +52,7 @@ fun RefresheableFeedView(
nav: INav,
) {
RefresheableBox(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
SaveableFeedState(viewModel.feedState, scrollStateKey) { listState ->
RenderFeedState(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
}
@@ -58,7 +60,7 @@ fun RefresheableFeedView(
@Composable
fun SaveableFeedState(
viewModel: FeedViewModel,
feedContentState: FeedContentState,
scrollStateKey: String? = null,
content: @Composable (LazyListState) -> Unit,
) {
@@ -69,7 +71,7 @@ fun SaveableFeedState(
rememberLazyListState()
}
WatchScrollToTop(viewModel.feedState, listState)
WatchScrollToTop(feedContentState, listState)
content(listState)
}
@@ -100,22 +100,6 @@ class AccountFeedContentStates(
}
fun destroy() {
homeNewThreads.destroy()
homeReplies.destroy()
dmKnown.destroy()
dmNew.destroy()
videoFeed.destroy()
discoverMarketplace.destroy()
discoverFollowSets.destroy()
discoverReads.destroy()
discoverDVMs.destroy()
discoverLive.destroy()
discoverCommunities.destroy()
discoverPublicChats.destroy()
notifications.destroy()
notificationSummary.destroy()
@@ -115,7 +115,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay
import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -412,12 +412,11 @@ class AccountViewModel(
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note,
onWasZapped: (Boolean) -> Unit,
) {
afterTimeInSeconds: Long,
): Boolean =
withContext(Dispatchers.IO) {
account.calculateIfNoteWasZappedByAccount(zappedNote) { onWasZapped(true) }
account.calculateIfNoteWasZappedByAccount(zappedNote, afterTimeInSeconds)
}
}
suspend fun calculateZapAmount(zappedNote: Note): String =
if (zappedNote.zapPayments.isNotEmpty()) {
@@ -675,7 +674,7 @@ class AccountViewModel(
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
okHttpClient = ::okHttpClientForMoney,
onError = onError,
onProgress = { onProgress(it) },
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
zapType = zapType ?: defaultZapType(),
)
@@ -1528,7 +1527,7 @@ class AccountViewModel(
account.deleteDraft(draftTag)
}
suspend fun createTempDraftNote(noteEvent: DraftEvent): Note? = draftNoteCache.update(noteEvent)
suspend fun createTempDraftNote(noteEvent: DraftWrapEvent): Note? = draftNoteCache.update(noteEvent)
fun createTempDraftNote(
innerEvent: Event,
@@ -1709,8 +1708,8 @@ class AccountViewModel(
class CachedDraftNotes(
val accountViewModel: AccountViewModel,
) : GenericBaseCacheAsync<DraftEvent, Note>(20) {
override suspend fun compute(key: DraftEvent): Note? =
) : GenericBaseCacheAsync<DraftWrapEvent, Note>(20) {
override suspend fun compute(key: DraftWrapEvent): Note? =
withContext(Dispatchers.IO) {
val decrypted = accountViewModel.account.draftsDecryptionCache.cachedDraft(key)
if (decrypted != null) {
@@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.IEvent
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -74,7 +74,7 @@ class EventProcessor(
when (event) {
is ChatroomKeyable -> chatHandler.add(event, eventNote, publicNote)
is OtsEvent -> otsHandler.add(event, eventNote, publicNote)
is DraftEvent -> draftHandler.add(event, eventNote, publicNote)
is DraftWrapEvent -> draftHandler.add(event, eventNote, publicNote)
is GiftWrapEvent -> giftWrapHandler.add(event, eventNote, publicNote)
is SealedRumorEvent -> sealHandler.add(event, eventNote, publicNote)
is LnZapRequestEvent -> zapRequest.add(event, eventNote, publicNote)
@@ -98,7 +98,7 @@ class EventProcessor(
when (event) {
is ChatroomKeyable -> chatHandler.delete(event, note)
is OtsEvent -> otsHandler.delete(event, note)
is DraftEvent -> draftHandler.delete(event, note)
is DraftWrapEvent -> draftHandler.delete(event, note)
is GiftWrapEvent -> giftWrapHandler.delete(event, note)
is SealedRumorEvent -> sealHandler.delete(event, note)
is LnZapRequestEvent -> zapRequest.delete(event, note)
@@ -129,7 +129,7 @@ class EventProcessor(
val deletedDrafts =
newNotes.mapNotNull { note ->
val event = note.event
if (event is DraftEvent &&
if (event is DraftWrapEvent &&
event.isDeleted() &&
!cache.deletionIndex.hasBeenDeleted(event)
) {
@@ -193,9 +193,9 @@ class OtsEventHandler(
class DraftEventHandler(
private val account: Account,
private val cache: LocalCache,
) : EventHandler<DraftEvent> {
) : EventHandler<DraftWrapEvent> {
override suspend fun add(
event: DraftEvent,
event: DraftWrapEvent,
eventNote: Note,
publicNote: Note,
) {
@@ -32,22 +32,21 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@Composable
fun RefreshingChatroomFeedView(
viewModel: FeedViewModel,
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
routeForLastRead: String,
@@ -55,27 +54,24 @@ fun RefreshingChatroomFeedView(
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: DraftTagState? = null,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true,
) {
RefresheableBox(viewModel, enablePullRefresh) {
SaveableFeedState(viewModel, scrollStateKey) { listState ->
RenderChatFeedView(
viewModel,
accountViewModel,
listState,
nav,
routeForLastRead,
onWantsToReply,
onWantsToEditDraft,
avoidDraft,
)
}
SaveableFeedState(feedContentState, scrollStateKey) { listState ->
RenderChatFeedView(
feedContentState,
accountViewModel,
listState,
nav,
routeForLastRead,
onWantsToReply,
onWantsToEditDraft,
avoidDraft,
)
}
}
@Composable
fun RenderChatFeedView(
viewModel: FeedViewModel,
feed: FeedContentState,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: INav,
@@ -84,13 +80,13 @@ fun RenderChatFeedView(
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: DraftTagState? = null,
) {
val feedState by viewModel.feedState.feedContent.collectAsStateWithLifecycle()
val feedState by feed.feedContent.collectAsStateWithLifecycle()
CrossfadeIfEnabled(targetState = feedState, animationSpec = tween(durationMillis = 100), accountViewModel = accountViewModel) { state ->
when (state) {
is FeedState.Loading -> LoadingFeed()
is FeedState.Empty -> FeedEmpty { viewModel.invalidateData() }
is FeedState.FeedError -> FeedError(state.errorMessage) { viewModel.invalidateData() }
is FeedState.Empty -> FeedEmpty { feed.invalidateData() }
is FeedState.FeedError -> FeedError(state.errorMessage) { feed.invalidateData() }
is FeedState.Loaded ->
ChatFeedLoaded(
state,
@@ -133,7 +129,7 @@ fun ChatFeedLoaded(
) {
itemsIndexed(items.list, key = { _, item -> item.idHex }) { index, item ->
val noteEvent = item.event
if (avoidDraft == null || noteEvent !is DraftEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) {
if (avoidDraft == null || noteEvent !is DraftWrapEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) {
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
@@ -82,7 +82,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -162,7 +162,7 @@ fun NormalChatNote(
ChatBubbleLayout(
isLoggedInUser = isLoggedInUser,
isDraft = note.event is DraftEvent,
isDraft = note.event is DraftWrapEvent,
innerQuote = innerQuote,
isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE,
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
@@ -289,7 +289,7 @@ private fun MessageBubbleLines(
accountViewModel: AccountViewModel,
nav: INav,
) {
if (baseNote.event !is DraftEvent) {
if (baseNote.event !is DraftWrapEvent) {
RenderReplyRow(
note = baseNote,
innerQuote = innerQuote,
@@ -388,7 +388,7 @@ fun NoteRow(
when (note.event) {
is ChannelCreateEvent -> RenderCreateChannelNote(note, bgColor, accountViewModel, nav)
is ChannelMetadataEvent -> RenderChangeChannelMetadataNote(note, bgColor, accountViewModel, nav)
is DraftEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
else -> RenderRegularTextNote(note, canPreview, innerQuote, bgColor, accountViewModel, nav)
}
@@ -132,7 +132,7 @@ fun ChatroomViewUI(
.weight(1f, true),
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
@@ -152,7 +152,7 @@ fun ChatroomViewUI(
accountViewModel,
onSendNewMessage = {
scope.launch {
feedViewModel.sendToTop()
feedViewModel.feedState.sendToTop()
}
},
nav,
@@ -23,28 +23,26 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
class ChatroomFeedFilter(
val withUser: ChatroomKey,
val account: Account,
) : AdditiveFeedFilter<Note>() {
) : AdditiveFeedFilter<Note>(),
ChangesFlowFilter<Note> {
fun chatroom() = account.chatroomList.getOrCreatePrivateChatroom(withUser)
override fun changesFlow() = chatroom().changesFlow()
// returns the last Note of each user.
override fun feedKey(): String = withUser.hashCode().toString()
override fun feed(): List<Note> {
val chatroom = account.chatroomList.getOrCreatePrivateChatroom(withUser)
return chatroom.messages
.filter { account.isAcceptable(it) }
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
override fun feed(): List<Note> = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
val chatroom = account.chatroomList.getOrCreatePrivateChatroom(withUser)
val chatroom = chatroom()
return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet()
}
@@ -20,16 +20,25 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
import com.vitorpamplona.amethyst.ui.dal.ListChange
import com.vitorpamplona.amethyst.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ChatroomFeedViewModel(
val user: ChatroomKey,
val account: Account,
) : FeedViewModel(ChatroomFeedFilter(user, account)) {
) : ListChangeFeedViewModel(ChatroomFeedFilter(user, account)) {
class Factory(
val user: ChatroomKey,
val account: Account,
@@ -38,3 +47,30 @@ class ChatroomFeedViewModel(
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatroomFeedViewModel(user, account) as T
}
}
@Stable
abstract class ListChangeFeedViewModel(
localFilter: ChangesFlowFilter<Note>,
) : ViewModel(),
InvalidatableContent {
val feedState = FeedContentState(localFilter, viewModelScope)
override val isRefreshing = feedState.isRefreshing
override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing)
init {
Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}")
viewModelScope.launch(Dispatchers.Default) {
localFilter.changesFlow().collect {
Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel.javaClass.simpleName}")
when (it) {
is ListChange.Addition -> feedState.updateFeedWith(setOf(it.item))
is ListChange.Deletion -> feedState.deleteFromFeed(setOf(it.item))
is ListChange.SetAddition -> feedState.updateFeedWith(it.item)
is ListChange.SetDeletion -> feedState.deleteFromFeed(it.item)
}
}
}
}
}
@@ -37,7 +37,7 @@ fun filterNip04DMs(
): List<RelayBasedFilter>? {
if (group == null || group.isEmpty() || account == null) return null
val userOutboxRelays = account.outboxRelays.flow.value
val userOutboxRelays = account.homeRelays.flow.value
val userInboxRelays = account.dmRelays.flow.value
val groupOutboxRelays = mutableSetOf<NormalizedRelayUrl>()
@@ -107,7 +107,7 @@ fun NewChatroomSubjectDialog(
groupName.value.ifBlank { null }?.let { changeSubject(it) }
}
accountViewModel.account.sendNIP17PrivateMessage(template)
accountViewModel.account.sendNip17PrivateMessage(template)
}
onClose()
@@ -79,7 +79,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
@@ -252,7 +252,7 @@ class ChatNewMessageViewModel :
val noteEvent = draft.event
val noteAuthor = draft.author
if (noteEvent is DraftEvent && noteAuthor != null) {
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -421,7 +421,7 @@ class ChatNewMessageViewModel :
}
}
private suspend fun innerSendPost(dTag: String?) {
private suspend fun innerSendPost(draftTag: String?) {
val room = room ?: return
val urls = findURLs(message.text)
@@ -468,19 +468,31 @@ class ChatNewMessageViewModel :
}
}
accountViewModel.account.sendNIP17PrivateMessage(template, dTag)
if (draftTag != null) {
accountViewModel.account.createAndSendDraft(draftTag, template)
} else {
accountViewModel.account.sendNip17PrivateMessage(template)
}
} else {
accountViewModel.account.sendPrivateMessage(
message = message,
toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() },
replyingTo = replyTo.value,
contentWarningReason = null,
imetas = usedAttachments,
draftTag = dTag,
)
val toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }
val template =
PrivateDmEvent.build(
toUser = toUser,
message = message,
imetas = usedAttachments,
replyingTo = replyTo.value?.toEId(),
signer = accountViewModel.account.signer,
)
if (draftTag != null) {
accountViewModel.account.createAndSendDraft(draftTag, template)
} else {
accountViewModel.account.sendNip04PrivateMessage(template)
}
}
if (dTag == null) {
if (draftTag == null) {
ChatFileSender(room, accountViewModel.account).sendAll(uploadsWaitingToBeSent)
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
@@ -48,7 +49,7 @@ class ChatFileSender(
contentWarningReason: String?,
cipher: AESGCM,
) {
account.sendNIP17EncryptedFile(
account.sendNip17EncryptedFile(
ChatMessageEncryptedFileHeaderEvent.build(
url = result.url,
to = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() },
@@ -89,18 +90,18 @@ class ChatFileSender(
val iMetaAttachments = IMetaAttachments()
iMetaAttachments.add(result, caption, contentWarningReason)
account.sendPrivateMessage(
message = result.url,
toUser = chatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() },
replyingTo = null,
zapReceiver = null,
contentWarningReason = null,
zapRaiserAmount = null,
geohash = null,
imetas = iMetaAttachments.iMetaAttachments,
emojis = null,
draftTag = null,
)
val toUser = chatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }
val template =
PrivateDmEvent.build(
toUser = toUser,
message = result.url,
imetas = iMetaAttachments.iMetaAttachments,
replyingTo = null,
signer = account.signer,
)
account.sendNip04PrivateMessage(template)
}
suspend fun sendAll(uploads: List<SuccessfulUploads>) {
@@ -24,21 +24,20 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
class ChannelFeedFilter(
val channel: Channel,
val account: Account,
) : AdditiveFeedFilter<Note>() {
) : AdditiveFeedFilter<Note>(),
ChangesFlowFilter<Note> {
override fun feedKey() = channel
override fun changesFlow() = channel.changesFlow()
// returns the last Note of each user.
override fun feed(): List<Note> =
sort(
channel.notes.filterIntoSet { key, it ->
account.isAcceptable(it)
},
)
override fun feed(): List<Note> = sort(channel.notes.filterIntoSet { key, it -> account.isAcceptable(it) })
override fun applyFilter(newItems: Set<Note>): Set<Note> =
newItems
@@ -24,12 +24,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ListChangeFeedViewModel
class ChannelFeedViewModel(
val channel: Channel,
val account: Account,
) : FeedViewModel(ChannelFeedFilter(channel, account)) {
) : ListChangeFeedViewModel(ChannelFeedFilter(channel, account)) {
class Factory(
val channel: Channel,
val account: Account,
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -41,7 +40,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.C
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import kotlinx.coroutines.launch
@Composable
fun EphemeralChatChannelView(
@@ -111,7 +109,7 @@ private fun ChannelView(
},
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.roomId.toKey()}",
@@ -123,17 +121,11 @@ private fun ChannelView(
Spacer(modifier = DoubleVertSpacer)
val scope = rememberCoroutineScope()
// LAST ROW
EditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = {
scope.launch {
feedViewModel.sendToTop()
}
},
onSendNewMessage = feedViewModel.feedState::sendToTop,
nav,
)
}
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -41,7 +40,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import kotlinx.coroutines.launch
@Composable
fun PublicChatChannelView(
@@ -111,7 +109,7 @@ fun ChannelView(
},
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.idHex}",
@@ -123,17 +121,11 @@ fun ChannelView(
Spacer(modifier = DoubleVertSpacer)
val scope = rememberCoroutineScope()
// LAST ROW
EditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = {
scope.launch {
feedViewModel.sendToTop()
}
},
onSendNewMessage = feedViewModel.feedState::sendToTop,
nav,
)
}
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -42,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.C
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import kotlinx.coroutines.launch
@Composable
fun LiveActivityChannelView(
@@ -113,7 +111,7 @@ fun LiveActivityChannelView(
) {
ShowVideoStreaming(channel, accountViewModel)
RefreshingChatroomFeedView(
viewModel = feedViewModel,
feedContentState = feedViewModel.feedState,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.address.toValue()}",
@@ -125,17 +123,11 @@ fun LiveActivityChannelView(
Spacer(modifier = DoubleVertSpacer)
val scope = rememberCoroutineScope()
// LAST ROW
EditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = {
scope.launch {
feedViewModel.sendToTop()
}
},
onSendNewMessage = feedViewModel.feedState::sendToTop,
nav,
)
}
@@ -52,7 +52,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
fun ChannelFileUploadDialog(
channelScreenModel: ChannelNewMessageViewModel,
state: ChatFileUploadState,
onUpload: () -> Unit,
onUpload: suspend () -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
@@ -82,7 +82,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.notify
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
@@ -191,7 +191,7 @@ open class ChannelNewMessageViewModel :
val noteEvent = draft.event
val noteAuthor = draft.author
if (noteEvent is DraftEvent && noteAuthor != null) {
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -259,7 +259,7 @@ open class ChannelNewMessageViewModel :
urlPreview = findUrlInMessage()
}
fun sendPost(onDone: () -> Unit) {
fun sendPost(onDone: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
sendPostSync()
onDone()
@@ -281,8 +281,14 @@ open class ChannelNewMessageViewModel :
if (message.text.isBlank()) {
account.deleteDraft(draftTag.current)
} else {
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
attachments.add(it.first)
attachments.add(it.second)
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template)
accountViewModel.account.createAndSendDraft(draftTag.current, template, attachments)
}
}
@@ -293,7 +299,7 @@ open class ChannelNewMessageViewModel :
fun upload(
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
onceUploaded: suspend () -> Unit,
) = try {
uploadUnsafe(onError, context, onceUploaded)
} catch (_: SignerExceptions.ReadOnlyException) {
@@ -306,7 +312,7 @@ open class ChannelNewMessageViewModel :
fun uploadUnsafe(
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
onceUploaded: suspend () -> Unit,
) {
viewModelScope.launch(Dispatchers.Default) {
val uploadState = uploadState ?: return@launch
@@ -57,7 +57,7 @@ import kotlinx.coroutines.FlowPreview
fun EditFieldRow(
channelScreenModel: ChannelNewMessageViewModel,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
onSendNewMessage: suspend () -> Unit,
nav: INav,
) {
channelScreenModel.replyTo.value?.let {
@@ -86,7 +86,7 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@Composable
fun ChatroomHeaderCompose(
@@ -113,7 +113,7 @@ fun ChatroomComposeChannelOrUser(
nav: INav,
) {
val baseNoteEvent = baseNote.event
if (baseNoteEvent is DraftEvent) {
if (baseNoteEvent is DraftWrapEvent) {
ObserveDraftEvent(baseNote, accountViewModel) { innerNote ->
ChatroomEntry(innerNote, accountViewModel, nav)
}
@@ -47,7 +47,7 @@ class ChatroomListKnownFeedFilter(
if ((chatroom.senderIntersects(followingKeySet) || chatList.hasSentMessagesTo(key)) &&
!account.isAllHidden(key.users)
) {
chatroom.lastMessage
chatroom.newestMessage
} else {
null
}
@@ -41,7 +41,7 @@ class ChatroomListNewFeedFilter(
val privateMessages =
chatList.rooms.mapNotNull { key, chatroom ->
if (!chatroom.senderIntersects(followingKeySet) && !chatList.hasSentMessagesTo(key) && !account.isAllHidden(key.users)) {
chatroom.lastMessage
chatroom.newestMessage
} else {
null
}
@@ -40,7 +40,7 @@ class DMsFromUserFilterSubAssembler(
key: ChatroomListState,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? =
key.account.outboxRelays.flow.value.map {
key.account.homeRelays.flow.value.map {
filterNip04DMsFromMe(key.account.userProfile(), it, since?.get(it)?.time)
} +
key.account.dmRelays.flow.value.map {
@@ -58,7 +58,7 @@ class DMsFromUserFilterSubAssembler(
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.Default) {
key.account.outboxRelays.flow.collectLatest {
key.account.homeRelays.flow.collectLatest {
invalidateFilters()
}
},
@@ -78,7 +78,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
@@ -192,7 +192,7 @@ open class NewProductViewModel :
val noteEvent = draft.event
val noteAuthor = draft.author
if (noteEvent is DraftEvent && noteAuthor != null) {
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -120,7 +120,7 @@ private fun RenderDraftListScreen(
) {
Column(Modifier.padding(it).fillMaxHeight()) {
RefresheableBox(feedViewModel) {
SaveableFeedState(feedViewModel, DRAFTS) { listState ->
SaveableFeedState(feedViewModel.feedState, DRAFTS) { listState ->
RenderFeedState(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
@@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
class DraftEventsFeedFilter(
val account: Account,
@@ -48,7 +48,7 @@ class DraftEventsFeedFilter(
fun acceptableEvent(it: Note): Boolean {
val noteEvent = it.event
return noteEvent is DraftEvent && noteEvent.pubKey == account.userProfile().pubkeyHex
return noteEvent is DraftWrapEvent && noteEvent.pubKey == account.userProfile().pubkeyHex
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
@@ -40,6 +40,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -97,6 +98,7 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -283,7 +285,7 @@ fun RenderNostrNIP90ContentDiscoveryScreen(
nav: INav,
) {
Column(Modifier.fillMaxHeight()) {
SaveableFeedState(resultFeedViewModel, null) { listState ->
SaveableFeedState(resultFeedViewModel.feedState, null) { listState ->
// TODO (Optional) Instead of a like reaction, do a Kind 31989 NIP89 App recommendation
RenderFeedState(
resultFeedViewModel,
@@ -472,6 +474,7 @@ fun ZapDVMButton(
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
var hasZapped by remember { mutableStateOf(false) }
Button(
@@ -480,6 +483,7 @@ fun ZapDVMButton(
baseNote,
accountViewModel,
context,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZappingProgress = { progress: Float ->
scope.launch { zappingProgress = progress }
},
@@ -501,6 +505,7 @@ fun ZapDVMButton(
zapAmountChoices = persistentListOf(amount / 1000),
popupYOffset = iconSize,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onDismiss = {
wantsToZap = null
zappingProgress = 0f
@@ -554,12 +559,24 @@ fun ZapDVMButton(
label = "ZapIconIndicator",
)
CircularProgressIndicator(
progress = { animatedProgress },
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp,
color = grayTint,
)
ObserveZapIcon(
baseNote,
accountViewModel,
zapStartingTime,
) { wasZappedByLoggedInUser ->
CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) {
if (it) {
ZappedIcon(iconSizeModifier)
} else {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = remember { Modifier.size(animationSize) },
strokeWidth = 2.dp,
color = grayTint,
)
}
}
}
} else {
ObserveZapIcon(
baseNote,
@@ -96,7 +96,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
@@ -241,7 +241,7 @@ open class ShortNotePostViewModel :
val noteEvent = draft?.event
val noteAuthor = draft?.author
if (draft != null && noteEvent is DraftEvent && noteAuthor != null) {
if (draft != null && noteEvent is DraftWrapEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -483,18 +483,17 @@ open class ShortNotePostViewModel :
}
suspend fun sendDraftSync() {
val accountViewModel = accountViewModel
if (message.text.isBlank()) {
accountViewModel.account.deleteDraft(draftTag.current)
} else {
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template)
val attachments = mutableSetOf<Event>()
nip95attachments.forEach {
account.sendToPrivateOutboxAndLocal(it.first)
account.sendToPrivateOutboxAndLocal(it.second)
attachments.add(it.first)
attachments.add(it.second)
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template, attachments)
}
}
@@ -40,6 +40,8 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
@@ -129,6 +131,8 @@ class NotificationFeedFilter(
it.event !is NIP90ContentDiscoveryRequestEvent &&
it.event !is GiftWrapEvent &&
it.event !is PrivateTagArrayEvent &&
it.event !is LnZapPaymentRequestEvent &&
it.event !is LnZapPaymentResponseEvent &&
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
@@ -85,7 +85,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
@@ -235,7 +235,7 @@ class NewPublicMessageViewModel :
val noteEvent = draft.event
val noteAuthor = draft.author
if (noteEvent is DraftEvent && noteAuthor != null) {
if (noteEvent is DraftWrapEvent && noteAuthor != null) {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel.createTempDraftNote(noteEvent)?.let { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
@@ -323,18 +323,17 @@ class NewPublicMessageViewModel :
}
suspend fun sendDraftSync() {
val accountViewModel = accountViewModel
if (message.text.isBlank()) {
accountViewModel.account.deleteDraft(draftTag.current)
} else {
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template)
val broadcast = mutableSetOf<Event>()
nip95attachments.forEach {
account.sendToPrivateOutboxAndLocal(it.first)
account.sendToPrivateOutboxAndLocal(it.second)
broadcast.add(it.first)
broadcast.add(it.second)
}
val template = createTemplate() ?: return
accountViewModel.account.createAndSendDraft(draftTag.current, template, broadcast)
}
}
@@ -56,7 +56,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
@@ -97,7 +96,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.ZapTabHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.zaps.dal.UserProfileZapsFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import kotlinx.coroutines.Dispatchers
import com.vitorpamplona.amethyst.ui.theme.Size8dp
import kotlinx.coroutines.launch
@Composable
@@ -112,12 +111,9 @@ fun ProfileScreen(
if (userBase == null) {
LaunchedEffect(userId) {
// waits to resolve.
launch(Dispatchers.IO) {
val newUserBase = LocalCache.checkGetOrCreateUser(userId)
if (newUserBase != userBase) {
userBase = newUserBase
}
val newUserBase = LocalCache.checkGetOrCreateUser(userId)
if (newUserBase != userBase) {
userBase = newUserBase
}
}
}
@@ -395,7 +391,7 @@ private fun RenderScreen(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
selectedTabIndex = pagerState.currentPage,
edgePadding = 8.dp,
edgePadding = Size8dp,
modifier = tabRowModifier,
divider = { HorizontalDivider(thickness = DividerThickness) },
) {
@@ -208,7 +208,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -641,7 +641,7 @@ private fun FullBleedNoteCompose(
RenderGitIssueEvent(baseNote, makeItShort = false, canPreview = true, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is DraftEvent) {
} else if (noteEvent is DraftWrapEvent) {
RenderDraft(baseNote, 3, true, backgroundColor, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav)
@@ -93,7 +93,6 @@ import com.vitorpamplona.amethyst.ui.theme.HalfFeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size22Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -455,7 +454,7 @@ fun ReactionsColumn(
accountViewModel = accountViewModel,
iconSize = Size40dp,
iconSizeModifier = Size40Modifier,
animationSize = Size35dp,
animationModifier = Size35Modifier,
nav = nav,
)
}
@@ -92,6 +92,7 @@ val HalfDoubleVertSpacer = Modifier.height(7.dp)
val Size0dp = 0.dp
val Size5dp = 5.dp
val Size6dp = 6.dp
val Size8dp = 8.dp
val Size10dp = 10.dp
val Size12dp = 12.dp
val Size13dp = 13.dp
@@ -148,6 +149,7 @@ val MaxWidthWithHorzPadding = Modifier.fillMaxWidth().padding(horizontal = 10.dp
val Size5Modifier = Modifier.size(5.dp)
val Size10Modifier = Modifier.size(10.dp)
val Size14Modifier = Modifier.size(14.dp)
val Size15Modifier = Modifier.size(15.dp)
val Size16Modifier = Modifier.size(16.dp)
val Size17Modifier = Modifier.size(17.dp)
@@ -27,7 +27,6 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
@@ -55,7 +54,7 @@ class TorService(
name: ComponentName,
service: IBinder,
) {
launch {
launch(Dispatchers.IO) {
// moved torService to a local variable, since we only need it once
val torService = (service as LocalBinder).service
@@ -77,7 +76,6 @@ class TorService(
)
awaitClose {
checkNotInMainThread()
Log.d("TorService", "Stopping Tor Service")
launch {
context.stopService(currentIntent)
@@ -50,6 +50,25 @@ class BundledUpdate(
val scope = CoroutineScope(dispatcher + SupervisorJob() + exceptionHandler)
val bundledUpdate = BasicBundledUpdate(delay, dispatcher, scope)
fun invalidate(
ignoreIfDoing: Boolean = false,
onUpdate: suspend () -> Unit,
) = bundledUpdate.invalidate(ignoreIfDoing, onUpdate)
fun cancel() {
scope.cancel()
}
}
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BasicBundledUpdate(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default,
val scope: CoroutineScope,
) {
private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false
@@ -79,10 +98,6 @@ class BundledUpdate(
}
}
}
fun cancel() {
scope.cancel()
}
}
/** This class is designed to have a waiting time between two calls of invalidate */
@@ -99,6 +114,25 @@ class BundledInsert<T>(
val scope = CoroutineScope(dispatcher + SupervisorJob() + exceptionHandler)
val bundledInsert = BasicBundledInsert<T>(delay, dispatcher, scope)
fun invalidateList(
newObject: T,
onUpdate: suspend (Set<T>) -> Unit,
) = bundledInsert.invalidateList(newObject, onUpdate)
fun cancel() {
scope.cancel()
}
}
/** This class is designed to have a waiting time between two calls of invalidate */
@Stable
class BasicBundledInsert<T>(
val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default,
val scope: CoroutineScope,
) {
private var onlyOneInBlock = AtomicBoolean()
private var queue = LinkedBlockingQueue<T>()
@@ -109,32 +143,30 @@ class BundledInsert<T>(
checkNotInMainThread()
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
// if it was true already, returns.
return
}
scope.launch(dispatcher) {
try {
val mySet = mutableSetOf<T>()
queue.drainTo(mySet)
if (mySet.isNotEmpty()) {
onUpdate(mySet)
}
while (true) {
val batch = mutableSetOf<T>()
queue.drainTo(batch)
if (batch.isNotEmpty()) {
onUpdate(batch)
} else {
break
}
delay(delay)
val mySet2 = mutableSetOf<T>()
queue.drainTo(mySet2)
if (mySet2.isNotEmpty()) {
onUpdate(mySet2)
delay(delay)
}
} finally {
withContext(NonCancellable) { onlyOneInBlock.set(false) }
withContext(NonCancellable) {
onlyOneInBlock.set(false)
}
}
}
}
fun cancel() {
scope.cancel()
}
}
+2 -3
View File
@@ -17,7 +17,7 @@ composeBom = "2025.07.00"
coreKtx = "1.16.0"
datastore = "1.1.7"
espressoCore = "3.7.0"
firebaseBom = "34.0.0"
firebaseBom = "34.1.0"
fragmentKtx = "1.8.8"
gms = "4.4.3"
jacksonModuleKotlin = "2.19.2"
@@ -43,7 +43,7 @@ rfc3986 = "0.1.2"
secp256k1KmpJniAndroid = "0.18.0"
securityCryptoKtx = "1.1.0"
spotless = "7.2.1"
torAndroid = "0.4.8.17"
torAndroid = "0.4.8.17.1"
translate = "17.0.3"
unifiedpush = "3.0.10"
urlDetector = "0.1.23"
@@ -111,7 +111,6 @@ jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" }
lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" }
markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
@@ -67,7 +67,7 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
@@ -216,7 +216,7 @@ class EventFactory {
CommunityPostApprovalEvent.KIND -> CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
DraftEvent.KIND -> DraftEvent(id, pubKey, createdAt, tags, content, sig)
DraftWrapEvent.KIND -> DraftWrapEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig)
@@ -206,7 +206,7 @@ class NostrClient(
return false
}
fun sendRequest(
fun openReqSubscription(
subId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
@@ -239,7 +239,7 @@ class NostrClient(
}
}
fun sendCount(
fun openCountSubscription(
subId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
@@ -49,7 +49,6 @@ fun NostrClient.downloadFirstEvent(
if (subId == subscriptionId) {
unsubscribe(this)
close(subscriptionId)
onResponse(event)
}
}
@@ -57,10 +56,12 @@ fun NostrClient.downloadFirstEvent(
subscribe(listener)
sendRequest(subscriptionId, filters)
openReqSubscription(subscriptionId, filters)
GlobalScope.launch(Dispatchers.IO) {
delay(30000)
close(subscriptionId)
unsubscribe(listener)
}
}
@@ -126,14 +126,14 @@ class SubscriptionController(
// was active and is not active anymore, just close.
client.close(subId)
} else {
client.sendRequest(subId, updatedFilters)
client.openReqSubscription(subId, updatedFilters)
}
} else {
if (updatedFilters == null) {
// was not active and is still not active, does nothing
} else {
// was not active and becomes active, sends the entire filter.
client.sendRequest(subId, updatedFilters)
client.openReqSubscription(subId, updatedFilters)
}
}
}
@@ -50,7 +50,7 @@ class ReqCmd(
}
@JvmStatic
fun parse(msgArray: JsonNode): com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd {
fun parse(msgArray: JsonNode): ReqCmd {
val filters = mutableListOf<Filter>()
for (i in 2 until msgArray.size()) {
@@ -60,7 +60,7 @@ class ReqCmd(
}
}
return com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd(
return ReqCmd(
msgArray.get(1).asText(),
filters,
)
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
@@ -121,16 +122,37 @@ class PrivateDmEvent(
}
}
fun build(
to: PTag,
encryptedMessage: String,
suspend fun build(
toUser: PTag,
message: String,
imetas: List<IMetaTag>? = null,
replyingTo: EventReference? = null,
createdAt: Long = TimeUtils.now(),
signer: NostrSigner,
initializer: TagArrayBuilder<PrivateDmEvent>.() -> Unit = {},
) = eventTemplate(KIND, encryptedMessage, createdAt) {
) = eventTemplate(
kind = KIND,
description =
signer.nip04Encrypt(
prepareMessageToEncrypt(message, imetas),
toUser.pubKey,
),
createdAt = createdAt,
) {
alt(ALT)
pTag(to)
pTag(toUser)
replyingTo?.let { reply(it) }
initializer()
}
suspend fun create(
to: PTag,
message: String,
imetas: List<IMetaTag>? = null,
replyingTo: EventReference?,
createdAt: Long = TimeUtils.now(),
signer: NostrSigner,
) = signer.sign(build(to, message, imetas, replyingTo, createdAt, signer))
}
}
@@ -35,11 +35,11 @@ class DraftBuilder {
draft: T,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
): DraftWrapEvent {
val encryptedContent = signer.nip44Encrypt(draft.toJson(), signer.pubKey)
val template =
eventTemplate<DraftEvent>(DraftEvent.KIND, encryptedContent, createdAt) {
alt(DraftEvent.ALT_DESCRIPTION)
eventTemplate<DraftWrapEvent>(DraftWrapEvent.KIND, encryptedContent, createdAt) {
alt(DraftWrapEvent.ALT_DESCRIPTION)
dTag(dTag)
kind(draft.kind)
@@ -1,230 +0,0 @@
/**
* Copyright (c) 2025 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.quartz.nip37Drafts
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.core.JsonParseException
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class DraftEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
EventHintProvider,
AddressHintProvider,
PubKeyHintProvider {
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey)
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId)
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
override fun isContentEncoded() = true
fun isDeleted() = content == ""
fun canDecrypt(signer: NostrSigner) = signer.pubKey == pubKey
suspend fun createDeletedEvent(signer: NostrSigner): DraftEvent = signer.sign(createdAt, KIND, tags, "")
suspend fun decryptInnerEvent(signer: NostrSigner): Event {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
val json = signer.nip44Decrypt(content, pubKey)
return try {
fromJson(json)
} catch (e: JsonParseException) {
Log.w("DraftEvent", "Unable to parse inner event of a draft: $json")
throw e
}
}
companion object {
const val KIND = 31234
const val ALT_DESCRIPTION = "Draft Event"
fun createAddressTag(
pubKey: HexKey,
dTag: String,
): String = Address.assemble(KIND, pubKey, dTag)
@Suppress("DEPRECATION")
suspend fun create(
dTag: String,
originalNote: TorrentCommentEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tagsWithMarkers =
originalNote.tags.filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
suspend fun create(
dTag: String,
originalNote: InteractiveStoryBaseEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tags = mutableListOf<Array<String>>()
return create(dTag, originalNote, tags, signer, createdAt)
}
suspend fun create(
dTag: String,
originalNote: LiveActivitiesChatMessageEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tags = mutableListOf<Array<String>>()
originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag(), "", "root")) }
originalNote.replyingTo()?.let { tags.add(arrayOf("e", it, "", "reply")) }
return create(dTag, originalNote, tags, signer, createdAt)
}
suspend fun create(
dTag: String,
originalNote: ChannelMessageEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tags = mutableListOf<Array<String>>()
originalNote.channelId()?.let { tags.add(arrayOf("e", it)) }
return create(dTag, originalNote, tags, signer, createdAt)
}
@Suppress("DEPRECATION")
suspend fun create(
dTag: String,
originalNote: GitReplyEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tags = mutableListOf<Array<String>>()
originalNote.repository()?.let { tags.add(arrayOf("a", it.toTag())) }
originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) }
return create(dTag, originalNote, tags, signer, createdAt)
}
suspend fun create(
dTag: String,
originalNote: PollNoteEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tagsWithMarkers =
originalNote.tags.filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
suspend fun create(
dTag: String,
originalNote: CommentEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tagsWithMarkers = originalNote.rootScopes() + originalNote.directReplies()
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
suspend fun create(
dTag: String,
originalNote: TextNoteEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tagsWithMarkers =
originalNote.tags.filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
return create(dTag, originalNote, tagsWithMarkers, signer, createdAt)
}
suspend fun create(
dTag: String,
innerEvent: Event,
anchorTagArray: List<Array<String>> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftEvent {
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", dTag))
tags.add(arrayOf("k", "${innerEvent.kind}"))
if (anchorTagArray.isNotEmpty()) {
tags.addAll(anchorTagArray)
}
val draft =
signer.sign<DraftEvent>(
createdAt = createdAt,
kind = KIND,
tags = tags.toTypedArray(),
content = signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey),
)
return draft
}
}
}
@@ -29,8 +29,8 @@ class DraftEventCache(
signer: NostrSigner,
) {
private val decryptionCache =
object : LruCache<DraftEvent, DraftEventDecryptCache>(1000) {
override fun create(key: DraftEvent): DraftEventDecryptCache? =
object : LruCache<DraftWrapEvent, DraftEventDecryptCache>(1000) {
override fun create(key: DraftWrapEvent): DraftEventDecryptCache? =
if (!key.isDeleted() && key.pubKey == signer.pubKey) {
DraftEventDecryptCache(signer)
} else {
@@ -38,23 +38,23 @@ class DraftEventCache(
}
}
fun delete(event: DraftEvent) = decryptionCache.remove(event)
fun delete(event: DraftWrapEvent) = decryptionCache.remove(event)
fun preload(
event: DraftEvent,
event: DraftWrapEvent,
result: Event,
) = decryptionCache[event]?.preload(result)
fun preCachedDraft(event: DraftEvent): Event? = decryptionCache[event]?.cached()
fun preCachedDraft(event: DraftWrapEvent): Event? = decryptionCache[event]?.cached()
suspend fun cachedDraft(event: DraftEvent) = decryptionCache[event]?.decrypt(event)
suspend fun cachedDraft(event: DraftWrapEvent) = decryptionCache[event]?.decrypt(event)
}
class DraftEventDecryptCache(
signer: NostrSigner,
) : DecryptCache<DraftEvent, Event>(signer) {
) : DecryptCache<DraftWrapEvent, Event>(signer) {
override suspend fun decryptAndParse(
event: DraftEvent,
event: DraftWrapEvent,
signer: NostrSigner,
): Event = event.decryptInnerEvent(signer)
}
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2025 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.quartz.nip37Drafts
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.core.JsonParseException
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class DraftWrapEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
fun isDeleted() = content == ""
fun canDecrypt(signer: NostrSigner) = signer.pubKey == pubKey
suspend fun decryptInnerEvent(signer: NostrSigner): Event {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
val json = signer.nip44Decrypt(content, pubKey)
return try {
fromJson(json)
} catch (e: JsonParseException) {
Log.w("DraftEvent", "Unable to parse inner event of a draft: $json")
throw e
}
}
companion object {
const val KIND = 31234
const val ALT_DESCRIPTION = "Draft Event"
fun createAddress(
pubKey: HexKey,
dTag: String,
): Address = Address(KIND, pubKey, dTag)
fun createAddressTag(
pubKey: HexKey,
dTag: String,
): String = Address.assemble(KIND, pubKey, dTag)
suspend fun build(
dTag: String,
draft: Event,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DraftWrapEvent>.() -> Unit = {},
) = eventTemplate(
kind = KIND,
description = signer.nip44Encrypt(draft.toJson(), signer.pubKey),
createdAt = createdAt,
) {
alt(ALT_DESCRIPTION)
dTag(dTag)
kind(draft.kind)
initializer()
}
suspend fun buildDeleted(
dTag: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<DraftWrapEvent>.() -> Unit = {},
) = eventTemplate(
kind = KIND,
description = "",
createdAt = createdAt,
) {
alt(ALT_DESCRIPTION)
dTag(dTag)
initializer()
}
suspend fun create(
dTag: String,
draft: Event,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
) = signer.sign(build(dTag, draft, signer, createdAt))
suspend fun createDeletedEvent(
dTag: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): DraftWrapEvent = signer.sign(buildDeleted(dTag, createdAt))
}
}
@@ -76,8 +76,11 @@ class AdvertisedRelayInfo(
@JvmStatic
fun parseReadNorm(tag: Array<String>): NormalizedRelayUrl? {
ensure(match(tag)) { return null }
ensure(AdvertisedRelayType.isRead(tag.getOrNull(2))) { return null }
ensure(tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()) { return null }
if (tag.has(2)) {
ensure(AdvertisedRelayType.isRead(tag[2])) { return null }
}
val relay = RelayUrlNormalizer.normalizeOrNull(tag[1])
@@ -88,8 +91,11 @@ class AdvertisedRelayInfo(
@JvmStatic
fun parseWriteNorm(tag: Array<String>): NormalizedRelayUrl? {
ensure(match(tag)) { return null }
ensure(AdvertisedRelayType.isWrite(tag.getOrNull(2))) { return null }
ensure(tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()) { return null }
if (tag.has(2)) {
ensure(AdvertisedRelayType.isWrite(tag[2])) { return null }
}
val relay = RelayUrlNormalizer.normalizeOrNull(tag[1])