extract actions from amethyst

This commit is contained in:
nrobi144
2026-01-02 13:29:55 +02:00
parent 6581fe18a3
commit 2751855186
9 changed files with 529 additions and 178 deletions
@@ -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
}
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
}