diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9fbaf9fbc5..415463f732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxRelays import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowsPerOutboxRelay +import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip18Reposts.RepostAction @@ -127,10 +128,8 @@ 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.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent @@ -207,10 +206,8 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.math.BigDecimal -import java.util.Base64 import java.util.Locale @OptIn(DelicateCoroutinesApi::class) @@ -305,6 +302,15 @@ class Account( val chatroomList = LocalCache.getOrCreateChatroomList(signer.pubKey) + val otsResolver: OtsResolver = + OtsResolverBuilder().build( + Amethyst.instance.okHttpClients, + ::shouldUseTorForMoneyOperations, + Amethyst.instance.otsBlockHeightCache, + ) + + val otsState = OtsState(signer, cache, otsResolver, scope, settings) + val feedDecryptionCaches = FeedDecryptionCaches( peopleListCache = peopleListDecryptionCache, @@ -457,10 +463,6 @@ class Account( private suspend fun sendNewAppSpecificData() = sendMyPublicAndPrivateOutbox(appSpecific.saveNewAppSpecificData()) - suspend fun countFollowersOf(pubkey: HexKey): Int = cache.users.count { _, it -> it.latestContactList?.isTaggedUser(pubkey) ?: false } - - suspend fun followerCount(): Int = countFollowersOf(signer.pubKey) - suspend fun sendNewUserMetadata( name: String? = null, picture: String? = null, @@ -493,7 +495,7 @@ class Account( github, ) - sendEverywhere(userMetadataEvent) + sendLiterallyEverywhere(userMetadataEvent) } fun reactionTo( @@ -877,47 +879,7 @@ class Account( } } - suspend fun updateAttestations() { - Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") - - val otsResolver = otsResolver() - - settings.pendingAttestations.value.toList().forEach { (key, value) -> - val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver) - - if (otsState != null) { - val hint = cache.getNoteIfExists(key)?.toEventHint() - val template = - if (hint != null) { - OtsEvent.build(hint, otsState) - } else { - OtsEvent.build(key, otsState) - } - - val otsEvent = signer.sign(template) - - cache.justConsumeMyOwnEvent(otsEvent) - client.send(otsEvent, computeRelayListToBroadcast(otsEvent)) - - settings.pendingAttestations.update { it - key } - } - } - } - - fun hasPendingAttestations(note: Note): Boolean { - val id = note.event?.id ?: note.idHex - return settings.pendingAttestations.value[id] != null - } - - fun timestamp(note: Note) { - if (!isWriteable()) return - if (note.isDraft()) return - - val id = note.event?.id ?: note.idHex - val otsResolver = otsResolver() - - settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id, otsResolver))) - } + suspend fun updateAttestations() = sendAutomatic(otsState.updateAttestations()) suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) @@ -943,6 +905,14 @@ class Account( suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) + fun sendAutomatic(events: List) = events.forEach { sendAutomatic(it) } + + fun sendAutomatic(event: Event?) { + if (event == null) return + cache.justConsumeMyOwnEvent(event) + client.send(event, computeRelayListToBroadcast(event)) + } + fun sendMyPublicAndPrivateOutbox(event: Event?) { if (event == null) return client.send(event, outboxRelays.flow.value) @@ -956,7 +926,7 @@ class Account( } } - fun sendEverywhere(event: Event) { + fun sendLiterallyEverywhere(event: Event) { client.send(event, outboxRelays.flow.value + client.relayStatusFlow().value.available) cache.justConsumeMyOwnEvent(event) } @@ -1620,16 +1590,11 @@ class Account( delete(note) } - fun isInPrivateBookmarks(note: Note) = bookmarkState.isInPrivateBookmarks(note) - - fun isInPublicBookmarks(note: Note) = bookmarkState.isInPublicBookmarks(note) - suspend fun addBookmark( note: Note, isPrivate: Boolean, ) { - if (!isWriteable()) return - if (note.isDraft()) return + if (!isWriteable() || note.isDraft()) return sendMyPublicAndPrivateOutbox(bookmarkState.addBookmark(note, isPrivate)) } @@ -1638,8 +1603,7 @@ class Account( note: Note, isPrivate: Boolean, ) { - if (!isWriteable()) return - if (note.isDraft()) return + if (!isWriteable() || note.isDraft()) return val event = bookmarkState.removeBookmark(note, isPrivate) if (event != null) { @@ -1735,16 +1699,7 @@ class Account( } } - suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? = - if (event.isPrivateZap()) { - if (isWriteable()) { - privateZapsDecryptionCache.decryptPrivateZap(event) - } else { - null - } - } else { - null - } + suspend fun decryptZapOrNull(event: LnZapRequestEvent): LnZapPrivateEvent? = if (event.isPrivateZap() && isWriteable()) privateZapsDecryptionCache.decryptPrivateZap(event) else null fun isAllHidden(users: Set): Boolean = users.all { isHidden(it) } @@ -1816,7 +1771,7 @@ class Account( ).toSet() } - suspend fun saveDMRelayList(dmRelays: List) = sendEverywhere(dmRelayList.saveRelayList(dmRelays)) + suspend fun saveDMRelayList(dmRelays: List) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) suspend fun savePrivateOutboxRelayList(relays: List) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) @@ -1826,7 +1781,7 @@ class Account( suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) - suspend fun sendNip65RelayList(relays: List) = sendEverywhere(nip65RelayList.saveRelayList(relays)) + suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) suspend fun sendFileServersList(servers: List) = sendMyPublicAndPrivateOutbox(fileStorageServers.saveFileServersList(servers)) @@ -1965,13 +1920,6 @@ class Account( TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) } - fun otsResolver(): OtsResolver = - OtsResolverBuilder().build( - Amethyst.instance.okHttpClients, - ::shouldUseTorForMoneyOperations, - Amethyst.instance.otsBlockHeightCache, - ) - init { Log.d("AccountRegisterObservers", "Init") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt index 8aad204ccd..961ff0b7f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsState.kt @@ -1,4 +1,80 @@ +/** + * 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.nip03Timestamp -class OtsState { -} \ No newline at end of file +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsResolver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.update +import java.util.Base64 + +class OtsState( + val signer: NostrSigner, + val cache: LocalCache, + val otsResolver: OtsResolver, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + suspend fun updateAttestations(): List { + Log.d("Pending Attestations", "Updating ${settings.pendingAttestations.value.size} pending attestations") + + return settings.pendingAttestations.value.toList().mapNotNull { (key, value) -> + val otsState = OtsEvent.upgrade(Base64.getDecoder().decode(value), key, otsResolver) + + if (otsState != null) { + val hint = cache.getNoteIfExists(key)?.toEventHint() + val template = + if (hint != null) { + OtsEvent.build(hint, otsState) + } else { + OtsEvent.build(key, otsState) + } + + val otsEvent = signer.sign(template) + + settings.pendingAttestations.update { it - key } + + otsEvent + } else { + null + } + } + } + + fun hasPendingAttestations(note: Note): Boolean { + val id = note.event?.id ?: note.idHex + return settings.pendingAttestations.value[id] != null + } + + fun timestamp(note: Note) { + if (note.isDraft()) return + + val id = note.event?.id ?: note.idHex + + settings.addPendingAttestation(id, Base64.getEncoder().encodeToString(OtsEvent.stamp(id, otsResolver))) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index a75771bb55..ef1a115562 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -273,7 +273,7 @@ fun NoteDropDownMenu( }, ) HorizontalDivider(thickness = DividerThickness) - if (accountViewModel.account.hasPendingAttestations(note)) { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { DropdownMenuItem( text = { Text(stringRes(R.string.timestamp_pending)) }, onClick = { @@ -363,8 +363,8 @@ fun WatchBookmarksFollowsAndAccount( val newState = DropDownParams( isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = accountViewModel.isInPrivateBookmarks(note), - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), + isPrivateBookmarkNote = accountViewModel.account.bookmarkState.isInPrivateBookmarks(note), + isPublicBookmarkNote = accountViewModel.account.bookmarkState.isInPublicBookmarks(note), isLoggedUser = accountViewModel.isLoggedUser(note.author), isSensitive = note.event?.isSensitiveOrNSFW() ?: false, showSensitiveContent = showSensitiveContent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 883571d996..bbf0cee47d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -741,33 +741,17 @@ class AccountViewModel( fun bookmarks(user: User): Note = LocalCache.getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user.pubkeyHex)) - fun addPrivateBookmark(note: Note) { - runIOCatching { account.addBookmark(note, true) } - } + fun addPrivateBookmark(note: Note) = runIOCatching { account.addBookmark(note, true) } - fun addPublicBookmark(note: Note) { - runIOCatching { account.addBookmark(note, false) } - } + fun addPublicBookmark(note: Note) = runIOCatching { account.addBookmark(note, false) } - fun removePrivateBookmark(note: Note) { - runIOCatching { account.removeBookmark(note, true) } - } + fun removePrivateBookmark(note: Note) = runIOCatching { account.removeBookmark(note, true) } - fun removePublicBookmark(note: Note) { - runIOCatching { account.removeBookmark(note, false) } - } + fun removePublicBookmark(note: Note) = runIOCatching { account.removeBookmark(note, false) } - fun isInPrivateBookmarks(note: Note): Boolean = account.isInPrivateBookmarks(note) + fun broadcast(note: Note) = runIOCatching { account.broadcast(note) } - fun isInPublicBookmarks(note: Note): Boolean = account.isInPublicBookmarks(note) - - fun broadcast(note: Note) { - runIOCatching { account.broadcast(note) } - } - - fun timestamp(note: Note) { - runIOCatching { account.timestamp(note) } - } + fun timestamp(note: Note) = runIOCatching { account.otsState.timestamp(note) } var lastTimeItTriedToUpdateAttestations: Long = 0