From 2751855186c6d3287c784671db88c7e5d0f51d61 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 2 Jan 2026 13:29:55 +0200 Subject: [PATCH] extract actions from amethyst --- .../model/nip18Reposts/RepostAction.kt | 28 +-- .../model/nip25Reactions/ReactionAction.kt | 70 +------ .../amethyst/commons/actions/FollowAction.kt | 123 +++++++++++ .../amethyst/commons/actions/PublishAction.kt | 85 ++++++++ .../commons/actions/ReactionAction.kt | 193 ++++++++++++++++++ .../amethyst/commons/actions/RepostAction.kt | 95 +++++++++ .../amethyst/desktop/ui/ComposeNoteDialog.kt | 45 +--- .../amethyst/desktop/ui/NoteActions.kt | 36 +--- .../amethyst/desktop/ui/UserProfileScreen.kt | 32 +-- 9 files changed, 529 insertions(+), 178 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowAction.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/PublishAction.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReactionAction.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/RepostAction.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt index 4d5d1276d..265a60cbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip18Reposts/RepostAction.kt @@ -23,33 +23,17 @@ package com.vitorpamplona.amethyst.model.nip18Reposts import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.amethyst.commons.actions.RepostAction as CommonsRepostAction +/** + * Android wrapper - pure proxy to commons implementation. + * All logic and validation handled in commons. + */ class RepostAction { companion object { suspend fun repost( note: Note, signer: NostrSigner, - ): Event? { - val noteEvent = note.event ?: return null - - if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) { - // has already bosted in the past 5mins - return null - } - - val noteHint = note.relayHintUrl() - val authorHint = note.author?.bestRelayHint() - - val template = - if (noteEvent.kind == 1) { - RepostEvent.build(noteEvent, noteHint, authorHint) - } else { - GenericRepostEvent.build(noteEvent, noteHint, authorHint) - } - - return signer.sign(template) - } + ): Event? = CommonsRepostAction.repost(note, signer) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt index bf580d23c..192625b0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip25Reactions/ReactionAction.kt @@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.model.nip25Reactions import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip17Dm.NIP17Factory -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.amethyst.commons.actions.ReactionAction as CommonsReactionAction +/** + * Android wrapper - pure proxy to commons implementation. + * All logic and validation handled in commons. + */ class ReactionAction { companion object { suspend fun reactTo( @@ -39,65 +40,6 @@ class ReactionAction { signer: NostrSigner, onPublic: (ReactionEvent) -> Unit, onPrivate: suspend (NIP17Factory.Result) -> Unit, - ) { - if (!signer.isWriteable()) return - - if (note.hasReacted(by, reaction)) { - // has already liked this note - return - } - - val noteEvent = note.event - if (noteEvent is NIP17Group) { - val users = noteEvent.groupMembers().toList() - - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - note.toEventHint()?.let { - onPrivate( - NIP17Factory().createReactionWithinGroup( - emojiUrl = emojiUrl, - originalNote = it, - to = users, - signer = signer, - ), - ) - } - - return - } - } - - note.toEventHint()?.let { - onPrivate( - NIP17Factory().createReactionWithinGroup( - content = reaction, - originalNote = it, - to = users, - signer = signer, - ), - ) - } - return - } else { - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - note.event?.let { - val template = ReactionEvent.build(emojiUrl, EventHintBundle(it, note.relayHintUrl())) - - onPublic(signer.sign(template)) - } - - return - } - } - - note.toEventHint()?.let { - onPublic(signer.sign(ReactionEvent.build(reaction, it))) - } - } - } + ) = CommonsReactionAction.reactTo(note, reaction, by, signer, onPublic, onPrivate) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowAction.kt new file mode 100644 index 000000000..1be5372b5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/FollowAction.kt @@ -0,0 +1,123 @@ +/** + * 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.commons.actions + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip02FollowList.ReadWrite +import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag + +/** + * Shared action for following/unfollowing users. + * Creates NIP-02 ContactList events (kind 3). + */ +object FollowAction { + /** + * Follows a user by creating an updated ContactListEvent. + * + * @param pubKeyHex The hex public key of the user to follow + * @param signer The NostrSigner to sign the event + * @param currentContactList The current contact list event (if any) + * @return Signed ContactListEvent with the user added + * @throws IllegalStateException if signer is not writeable + */ + suspend fun follow( + pubKeyHex: String, + signer: NostrSigner, + currentContactList: ContactListEvent? = null, + ): ContactListEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot follow: signer is not writeable") + } + + val updatedEvent = + if (currentContactList != null) { + // Add to existing contact list + ContactListEvent.followUser( + earlierVersion = currentContactList, + pubKeyHex = pubKeyHex, + signer = signer, + ) + } else { + // Create new contact list from scratch + ContactListEvent.createFromScratch( + followUsers = listOf(ContactTag(pubKeyHex)), + relayUse = emptyMap(), + signer = signer, + ) + } + + return updatedEvent + } + + /** + * Unfollows a user by creating an updated ContactListEvent. + * + * @param pubKeyHex The hex public key of the user to unfollow + * @param signer The NostrSigner to sign the event + * @param currentContactList The current contact list event (required for unfollow) + * @return Signed ContactListEvent with the user removed + * @throws IllegalStateException if signer is not writeable + * @throws IllegalArgumentException if currentContactList is null + */ + suspend fun unfollow( + pubKeyHex: String, + signer: NostrSigner, + currentContactList: ContactListEvent?, + ): ContactListEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot unfollow: signer is not writeable") + } + + requireNotNull(currentContactList) { + "Cannot unfollow: current contact list is required" + } + + // Remove from existing contact list + val updatedEvent = + ContactListEvent.unfollowUser( + earlierVersion = currentContactList, + pubKeyHex = pubKeyHex, + signer = signer, + ) + + return updatedEvent + } + + /** + * Checks if a user is currently followed in the contact list. + * + * @param pubKeyHex The hex public key to check + * @param currentContactList The current contact list event (if any) + * @return true if the user is followed, false otherwise + */ + fun isFollowing( + pubKeyHex: String, + currentContactList: ContactListEvent?, + ): Boolean { + if (currentContactList == null) return false + + // Check if the pubKeyHex is in the contact list's p-tags + return currentContactList.tags.any { tag -> + tag.size >= 2 && tag[0] == "p" && tag[1] == pubKeyHex + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/PublishAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/PublishAction.kt new file mode 100644 index 000000000..37814d8a4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/PublishAction.kt @@ -0,0 +1,85 @@ +/** + * 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.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.eTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTag +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findURLs + +/** + * Shared action for publishing new text notes. + * Handles replies, hashtags, and URL references. + */ +object PublishAction { + /** + * Publishes a text note (NIP-01 kind 1). + * + * @param content The text content of the note + * @param signer The NostrSigner to sign the event + * @param replyTo Optional event to reply to (adds e-tag and p-tag) + * @return Signed TextNoteEvent ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun publishTextNote( + content: String, + signer: NostrSigner, + replyTo: Event? = null, + ): TextNoteEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot publish: signer is not writeable") + } + + val template = + TextNoteEvent.build(content) { + // If replying, add e-tag and p-tag + if (replyTo != null) { + val etag = ETag(replyTo.id) + etag.relay = null + etag.author = replyTo.pubKey + eTag(etag) + pTag(PTag(replyTo.pubKey, relayHint = null)) + } + + // Extract hashtags and URLs from content + hashtags(findHashtags(content)) + references(findURLs(content)) + } + + return signer.sign(template) + } + + /** + * Publishes a reply to an existing note. + */ + suspend fun publishReply( + content: String, + replyTo: Event, + signer: NostrSigner, + ): TextNoteEvent = publishTextNote(content, signer, replyTo) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReactionAction.kt new file mode 100644 index 000000000..45dbc088f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ReactionAction.kt @@ -0,0 +1,193 @@ +/** + * 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.commons.actions + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag + +/** + * Shared action for reacting to events (likes, emoji reactions). + * Supports public reactions, custom emoji reactions, and NIP-17 private group reactions. + * + * Full-featured version extracted from Android for maximum code reuse across platforms. + */ +object ReactionAction { + /** + * Creates a signed reaction event. + * + * @param event The event to react to + * @param reaction The reaction content (e.g., "+", "❤️", ":custom_emoji:") + * @param signer The NostrSigner to sign the event + * @param relayHint Optional relay hint URL where the original event was seen + * @return Signed ReactionEvent ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun reactTo( + event: Event, + reaction: String, + signer: NostrSigner, + relayHint: String? = null, + ): ReactionEvent { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot react: signer is not writeable") + } + + val normalizedRelayHint = relayHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val eventHint = EventHintBundle(event, normalizedRelayHint) + + // Handle custom emoji reactions (format: ":emoji_name:") + val template = + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + ReactionEvent.build(emojiUrl, eventHint) + } else { + // Fallback to text if emoji decode fails + ReactionEvent.build(reaction, eventHint) + } + } else { + ReactionEvent.build(reaction, eventHint) + } + + return signer.sign(template) + } + + /** + * Creates a "like" reaction ("+"). + */ + suspend fun like( + event: Event, + signer: NostrSigner, + relayHint: String? = null, + ): ReactionEvent = reactTo(event, "+", signer, relayHint) + + /** + * Advanced: React to an event with support for NIP-17 private groups. + * + * This method handles both public and private group reactions: + * - For NIP17Group events: Creates private reactions within the group + * - For regular events: Creates public reactions + * + * @param event The event to react to + * @param eventHint EventHintBundle with relay information + * @param reaction The reaction content (e.g., "+", "❤️", ":custom_emoji:") + * @param signer The NostrSigner to sign the event + * @param onPublic Callback for public reactions (returns signed ReactionEvent) + * @param onPrivate Callback for private group reactions (returns NIP17Factory.Result) + */ + suspend fun reactToWithGroupSupport( + event: Event, + eventHint: EventHintBundle, + reaction: String, + signer: NostrSigner, + onPublic: suspend (ReactionEvent) -> Unit, + onPrivate: suspend (NIP17Factory.Result) -> Unit, + ) { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot react: signer is not writeable") + } + + // Check if this is a NIP-17 private group event + if (event is NIP17Group) { + val users = event.groupMembers().toList() + + // Handle custom emoji reactions in groups + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + onPrivate( + NIP17Factory().createReactionWithinGroup( + emojiUrl = emojiUrl, + originalNote = eventHint, + to = users, + signer = signer, + ), + ) + return + } + } + + // Regular text reaction in group + onPrivate( + NIP17Factory().createReactionWithinGroup( + content = reaction, + originalNote = eventHint, + to = users, + signer = signer, + ), + ) + } else { + // Public reaction + val template = + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + ReactionEvent.build(emojiUrl, eventHint) + } else { + ReactionEvent.build(reaction, eventHint) + } + } else { + ReactionEvent.build(reaction, eventHint) + } + + onPublic(signer.sign(template)) + } + } + + /** + * Android-compatible overload: React to a note with full validation. + * + * This accepts Note/User directly and handles all validation including + * duplicate checking and writeability checks. + * + * @param note The note to react to + * @param reaction The reaction content + * @param by The user reacting + * @param signer The NostrSigner to sign the event + * @param onPublic Callback for public reactions + * @param onPrivate Callback for private group reactions + */ + suspend fun reactTo( + note: Note, + reaction: String, + by: User, + signer: NostrSigner, + onPublic: (ReactionEvent) -> Unit, + onPrivate: suspend (NIP17Factory.Result) -> Unit, + ) { + // All validation in commons + if (!signer.isWriteable()) return + if (note.hasReacted(by, reaction)) return + + val noteEvent = note.event ?: return + val eventHint = note.toEventHint() ?: return + + reactToWithGroupSupport(noteEvent, eventHint, reaction, signer, onPublic, onPrivate) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/RepostAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/RepostAction.kt new file mode 100644 index 000000000..16f478856 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/RepostAction.kt @@ -0,0 +1,95 @@ +/** + * 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.commons.actions + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent + +/** + * Shared action for reposting (boosting) events. + * Supports both NIP-18 reposts (kind 6 for kind 1) and generic reposts (kind 16 for other kinds). + */ +object RepostAction { + /** + * Creates a signed repost event. + * + * @param event The event to repost + * @param signer The NostrSigner to sign the event + * @param noteHint Optional relay hint where the note was seen + * @param authorHint Optional relay hint for the author's outbox + * @return Signed repost event ready to broadcast + * @throws IllegalStateException if signer is not writeable + */ + suspend fun repost( + event: Event, + signer: NostrSigner, + noteHint: String? = null, + authorHint: String? = null, + ): Event { + if (!signer.isWriteable()) { + throw IllegalStateException("Cannot repost: signer is not writeable") + } + + // Normalize relay hints + val normalizedNoteHint = noteHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } + val normalizedAuthorHint = authorHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + // Use NIP-18 RepostEvent (kind 6) for text notes (kind 1) + // Use GenericRepostEvent (kind 16) for all other kinds + val template = + if (event.kind == 1) { + RepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint) + } else { + GenericRepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint) + } + + return signer.sign(template) + } + + /** + * Android-compatible overload: Repost a note with full validation. + * + * This accepts Note directly and handles all validation including + * duplicate checking (5-minute window) and writeability checks. + * + * @param note The note to repost + * @param signer The NostrSigner to sign the event + * @return Signed repost event, or null if validation fails + */ + suspend fun repost( + note: Note, + signer: NostrSigner, + ): Event? { + // All validation in commons + if (!signer.isWriteable()) return null + if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) return null + + val noteEvent = note.event ?: return null + val noteHint = note.relayHintUrl()?.url + val authorHint = note.author?.bestRelayHint()?.url + + return repost(noteEvent, signer, noteHint, authorHint) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index f9e48c53f..bf6ef13a4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -44,16 +44,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.actions.PublishAction import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.events.eTag -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findURLs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -187,35 +179,16 @@ private suspend fun publishNote( withContext(Dispatchers.IO) { println("[ComposeNoteDialog] Starting publishNote: content length=${content.length}") - // Build TextNoteEvent using Quartz - val template = - TextNoteEvent.build(content) { - // If replying, add e-tag and p-tag - if (replyTo != null) { - val etag = ETag(replyTo.id) - etag.relay = null - etag.author = replyTo.pubKey - eTag(etag) - pTag(PTag(replyTo.pubKey, relayHint = null)) - } + // Check read-only mode + if (account.isReadOnly) { + println("[ComposeNoteDialog] Error: Cannot post in read-only mode") + throw IllegalStateException("Cannot post in read-only mode") + } - // Extract hashtags and URLs from content - hashtags(findHashtags(content)) - references(findURLs(content)) - } + println("[ComposeNoteDialog] Signing event with pubkey: ${account.pubKeyHex.take(8)}...") - // Sign the event - val signedEvent = - if (account.isReadOnly) { - // Read-only mode - can't sign events - println("[ComposeNoteDialog] Error: Cannot post in read-only mode") - throw IllegalStateException("Cannot post in read-only mode") - } else { - // Sign with nsec (full key) - val signer = account.signer - println("[ComposeNoteDialog] Signing event with pubkey: ${account.pubKeyHex.take(8)}...") - signer.sign(template) - } + // Use shared PublishAction from commons + val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo) println("[ComposeNoteDialog] Event signed successfully, ID: ${signedEvent.id.take(8)}...") diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 870296264..328693e26 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -40,14 +40,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.actions.ReactionAction +import com.vitorpamplona.amethyst.commons.actions.RepostAction import com.vitorpamplona.amethyst.commons.icons.Reply import com.vitorpamplona.amethyst.commons.icons.Repost import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -163,12 +161,8 @@ private suspend fun reactToNote( relayManager: DesktopRelayConnectionManager, ) { withContext(Dispatchers.IO) { - // Build reaction event - val reactedTo = EventHintBundle(event, null) - val template = ReactionEvent.like(reactedTo) - - // Sign with user's key - val signedEvent = account.signer.sign(template) + // Use shared ReactionAction from commons + val signedEvent = ReactionAction.reactTo(event, reaction, account.signer) // Broadcast to all relays relayManager.broadcastToAll(signedEvent) @@ -184,26 +178,8 @@ private suspend fun repostNote( relayManager: DesktopRelayConnectionManager, ) { withContext(Dispatchers.IO) { - // Build repost event - val template = - if (event.kind == 1) { - // Text note - use RepostEvent (kind 6) - RepostEvent.build( - boostedPost = event, - eventSourceRelay = null, - authorHomeRelay = null, - ) - } else { - // Other kinds - use GenericRepostEvent (kind 16) - GenericRepostEvent.build( - boostedPost = event, - eventSourceRelay = null, - authorHomeRelay = null, - ) - } - - // Sign with user's key - val signedEvent = account.signer.sign(template) + // Use shared RepostAction from commons + val signedEvent = RepostAction.repost(event, account.signer) // Broadcast to all relays relayManager.broadcastToAll(signedEvent) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index cc4fbf2b0..02fe9ff2c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.actions.FollowAction import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip01Core.core.Event @@ -71,8 +72,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip02FollowList.ReadWrite -import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers @@ -531,24 +530,8 @@ private suspend fun followUser( withContext(Dispatchers.IO) { println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...") - val updatedEvent = - if (currentContactList != null) { - println("[UserProfile] Adding to existing contact list") - // Add to existing contact list - ContactListEvent.followUser( - earlierVersion = currentContactList, - pubKeyHex = pubKeyHex, - signer = account.signer, - ) - } else { - println("[UserProfile] Creating new contact list") - // Create new contact list with this user - ContactListEvent.createFromScratch( - followUsers = listOf(ContactTag(pubKeyHex)), - relayUse = emptyMap(), - signer = account.signer, - ) - } + // Use shared FollowAction from commons + val updatedEvent = FollowAction.follow(pubKeyHex, account.signer, currentContactList) println("[UserProfile] ContactListEvent created, broadcasting...") relayManager.broadcastToAll(updatedEvent) @@ -570,12 +553,9 @@ private suspend fun unfollowUser( if (currentContactList != null) { println("[UserProfile] Removing from existing contact list") - val updatedEvent = - ContactListEvent.unfollowUser( - earlierVersion = currentContactList, - pubKeyHex = pubKeyHex, - signer = account.signer, - ) + + // Use shared FollowAction from commons + val updatedEvent = FollowAction.unfollow(pubKeyHex, account.signer, currentContactList) println("[UserProfile] ContactListEvent updated, broadcasting...") relayManager.broadcastToAll(updatedEvent)