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.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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-64
@@ -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<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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
) = CommonsReactionAction.reactTo(note, reaction, by, signer, onPublic, onPrivate)
|
||||
}
|
||||
}
|
||||
|
||||
+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.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)}...")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+6
-26
@@ -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<String, ReadWrite>(),
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user