extract actions from amethyst
This commit is contained in:
+6
-22
@@ -23,33 +23,17 @@ package com.vitorpamplona.amethyst.model.nip18Reposts
|
|||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
import com.vitorpamplona.amethyst.commons.actions.RepostAction as CommonsRepostAction
|
||||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android wrapper - pure proxy to commons implementation.
|
||||||
|
* All logic and validation handled in commons.
|
||||||
|
*/
|
||||||
class RepostAction {
|
class RepostAction {
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun repost(
|
suspend fun repost(
|
||||||
note: Note,
|
note: Note,
|
||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
): Event? {
|
): Event? = CommonsRepostAction.repost(note, signer)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-64
@@ -22,14 +22,15 @@ package com.vitorpamplona.amethyst.model.nip25Reactions
|
|||||||
|
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
|
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
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 {
|
class ReactionAction {
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun reactTo(
|
suspend fun reactTo(
|
||||||
@@ -39,65 +40,6 @@ class ReactionAction {
|
|||||||
signer: NostrSigner,
|
signer: NostrSigner,
|
||||||
onPublic: (ReactionEvent) -> Unit,
|
onPublic: (ReactionEvent) -> Unit,
|
||||||
onPrivate: suspend (NIP17Factory.Result) -> Unit,
|
onPrivate: suspend (NIP17Factory.Result) -> Unit,
|
||||||
) {
|
) = CommonsReactionAction.reactTo(note, reaction, by, signer, onPublic, onPrivate)
|
||||||
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<Event>()?.let {
|
|
||||||
onPrivate(
|
|
||||||
NIP17Factory().createReactionWithinGroup(
|
|
||||||
emojiUrl = emojiUrl,
|
|
||||||
originalNote = it,
|
|
||||||
to = users,
|
|
||||||
signer = signer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
note.toEventHint<Event>()?.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<Event>()?.let {
|
|
||||||
onPublic(signer.sign(ReactionEvent.build(reaction, it)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+123
@@ -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<String, ReadWrite>(),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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)
|
||||||
|
}
|
||||||
+193
@@ -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<Event>,
|
||||||
|
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<Event>() ?: return
|
||||||
|
|
||||||
|
reactToWithGroupSupport(noteEvent, eventHint, reaction, signer, onPublic, onPrivate)
|
||||||
|
}
|
||||||
|
}
|
||||||
+95
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-36
@@ -44,16 +44,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.vitorpamplona.amethyst.commons.account.AccountState
|
import com.vitorpamplona.amethyst.commons.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.commons.actions.PublishAction
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -187,35 +179,16 @@ private suspend fun publishNote(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
println("[ComposeNoteDialog] Starting publishNote: content length=${content.length}")
|
println("[ComposeNoteDialog] Starting publishNote: content length=${content.length}")
|
||||||
|
|
||||||
// Build TextNoteEvent using Quartz
|
// Check read-only mode
|
||||||
val template =
|
if (account.isReadOnly) {
|
||||||
TextNoteEvent.build(content) {
|
println("[ComposeNoteDialog] Error: Cannot post in read-only mode")
|
||||||
// If replying, add e-tag and p-tag
|
throw IllegalStateException("Cannot post in read-only mode")
|
||||||
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
|
println("[ComposeNoteDialog] Signing event with pubkey: ${account.pubKeyHex.take(8)}...")
|
||||||
hashtags(findHashtags(content))
|
|
||||||
references(findURLs(content))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the event
|
// Use shared PublishAction from commons
|
||||||
val signedEvent =
|
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
println("[ComposeNoteDialog] Event signed successfully, ID: ${signedEvent.id.take(8)}...")
|
println("[ComposeNoteDialog] Event signed successfully, ID: ${signedEvent.id.take(8)}...")
|
||||||
|
|
||||||
|
|||||||
@@ -40,14 +40,12 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.account.AccountState
|
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.Reply
|
||||||
import com.vitorpamplona.amethyst.commons.icons.Repost
|
import com.vitorpamplona.amethyst.commons.icons.Repost
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -163,12 +161,8 @@ private suspend fun reactToNote(
|
|||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// Build reaction event
|
// Use shared ReactionAction from commons
|
||||||
val reactedTo = EventHintBundle(event, null)
|
val signedEvent = ReactionAction.reactTo(event, reaction, account.signer)
|
||||||
val template = ReactionEvent.like(reactedTo)
|
|
||||||
|
|
||||||
// Sign with user's key
|
|
||||||
val signedEvent = account.signer.sign(template)
|
|
||||||
|
|
||||||
// Broadcast to all relays
|
// Broadcast to all relays
|
||||||
relayManager.broadcastToAll(signedEvent)
|
relayManager.broadcastToAll(signedEvent)
|
||||||
@@ -184,26 +178,8 @@ private suspend fun repostNote(
|
|||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
) {
|
) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// Build repost event
|
// Use shared RepostAction from commons
|
||||||
val template =
|
val signedEvent = RepostAction.repost(event, account.signer)
|
||||||
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)
|
|
||||||
|
|
||||||
// Broadcast to all relays
|
// Broadcast to all relays
|
||||||
relayManager.broadcastToAll(signedEvent)
|
relayManager.broadcastToAll(signedEvent)
|
||||||
|
|||||||
+6
-26
@@ -62,6 +62,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.account.AccountState
|
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.commons.ui.components.LoadingState
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
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.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
|
||||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
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.nip10Notes.TextNoteEvent
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -531,24 +530,8 @@ private suspend fun followUser(
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...")
|
println("[UserProfile] Starting followUser: target=${pubKeyHex.take(8)}...")
|
||||||
|
|
||||||
val updatedEvent =
|
// Use shared FollowAction from commons
|
||||||
if (currentContactList != null) {
|
val updatedEvent = FollowAction.follow(pubKeyHex, account.signer, currentContactList)
|
||||||
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<String, ReadWrite>(),
|
|
||||||
signer = account.signer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
println("[UserProfile] ContactListEvent created, broadcasting...")
|
println("[UserProfile] ContactListEvent created, broadcasting...")
|
||||||
relayManager.broadcastToAll(updatedEvent)
|
relayManager.broadcastToAll(updatedEvent)
|
||||||
@@ -570,12 +553,9 @@ private suspend fun unfollowUser(
|
|||||||
|
|
||||||
if (currentContactList != null) {
|
if (currentContactList != null) {
|
||||||
println("[UserProfile] Removing from existing contact list")
|
println("[UserProfile] Removing from existing contact list")
|
||||||
val updatedEvent =
|
|
||||||
ContactListEvent.unfollowUser(
|
// Use shared FollowAction from commons
|
||||||
earlierVersion = currentContactList,
|
val updatedEvent = FollowAction.unfollow(pubKeyHex, account.signer, currentContactList)
|
||||||
pubKeyHex = pubKeyHex,
|
|
||||||
signer = account.signer,
|
|
||||||
)
|
|
||||||
|
|
||||||
println("[UserProfile] ContactListEvent updated, broadcasting...")
|
println("[UserProfile] ContactListEvent updated, broadcasting...")
|
||||||
relayManager.broadcastToAll(updatedEvent)
|
relayManager.broadcastToAll(updatedEvent)
|
||||||
|
|||||||
Reference in New Issue
Block a user