feat(agora): render Agora fundraiser campaigns (kind 33863)

Agora (a crowdfunding client on the Ditto stack) publishes fundraising
campaigns as kind 33863 — an app-specific addressable kind with no NIP.
Amethyst had no parser or renderer, so it hit the "Event Not Supported"
path and was dropped; reposts of one rendered as a permanently blank card.

Add first-class support, modelled on NIP-99 Classifieds (title/image/body)
plus NIP-75 zap goals (goal/deadline/progress).
This commit is contained in:
davotoula
2026-06-06 12:50:02 +02:00
parent 5a5a4a9c27
commit be12abcb21
15 changed files with 530 additions and 0 deletions
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver
import com.vitorpamplona.amethyst.service.BundledInsert
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
@@ -3366,6 +3367,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is FundraiserEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -133,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderFundraiser
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
@@ -208,6 +209,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
@@ -1258,6 +1260,10 @@ private fun RenderNoteRow(
RenderGoal(baseNote, accountViewModel, nav)
}
is FundraiserEvent -> {
RenderFundraiser(baseNote, makeItShort, accountViewModel, nav)
}
is HighlightEvent -> {
RenderHighlight(
baseNote,
@@ -0,0 +1,196 @@
/*
* 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.ui.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CopyIcon
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeaderBackground
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.launch
@Composable
fun RenderFundraiser(
baseNote: Note,
makeItShort: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? FundraiserEvent ?: return
val title = remember(noteEvent) { noteEvent.title()?.ifBlank { null } }
val body = remember(noteEvent) { noteEvent.content.ifBlank { null } }
val coverImage = remember(noteEvent) { noteEvent.coverImage()?.ifBlank { null } }
val goalAmountSats = remember(noteEvent) { noteEvent.goal() ?: 0L }
val deadline = remember(noteEvent) { noteEvent.deadline() }
val wallets = remember(noteEvent) { noteEvent.wallets() }
val topics = remember(noteEvent) { noteEvent.topics() }
Column(MaterialTheme.colorScheme.replyModifier) {
coverImage?.let {
Box {
MyAsyncImage(
imageUrl = it,
contentDescription = stringRes(R.string.preview_card_image_for, it),
contentScale = ContentScale.FillWidth,
mainImageModifier = Modifier.fillMaxWidth(),
loadedImageModifier = Modifier,
accountViewModel = accountViewModel,
onLoadingBackground = { DefaultImageHeaderBackground(baseNote, accountViewModel) },
onError = { DefaultImageHeader(baseNote, accountViewModel) },
)
}
}
Column(Modifier.padding(10.dp)) {
title?.let {
Text(
text = it,
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(8.dp))
}
body?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = if (makeItShort) 5 else Int.MAX_VALUE,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(10.dp))
}
if (goalAmountSats > 0) {
GoalProgressBar(
note = baseNote,
goalAmountSats = goalAmountSats,
accountViewModel = accountViewModel,
)
}
deadline?.let {
if (it > TimeUtils.now()) {
val context = LocalContext.current
Spacer(Modifier.height(6.dp))
Text(
text = stringRes(R.string.fundraiser_ends, timeAheadNoDot(it, context)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
if (wallets.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
OnChainDonation(wallets, accountViewModel)
}
if (topics.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
DisplayUncitedHashtags(
event = noteEvent,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun OnChainDonation(
wallets: List<String>,
accountViewModel: AccountViewModel,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
Text(
text = stringRes(R.string.fundraiser_onchain_donation),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
Spacer(Modifier.height(4.dp))
wallets.forEach { address ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable {
scope.launch {
clipboard.setText(address)
accountViewModel.toastManager.toast(
R.string.copy_to_clipboard,
R.string.copied_to_clipboard,
)
}
},
) {
Text(
text = address,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
CopyIcon(modifier = Size18Modifier)
}
}
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollow
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -66,6 +67,7 @@ class FollowPackFeedNewThreadFeedFilter(
WikiNoteEvent.KIND,
NipTextEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
LongTextNoteEvent.KIND,
)
}
@@ -134,6 +136,7 @@ class FollowPackFeedNewThreadFeedFilter(
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
@@ -68,6 +69,7 @@ class HomeNewThreadFeedFilter(
InteractiveStoryPrologueEvent.KIND,
WikiNoteEvent.KIND,
ClassifiedsEvent.KIND,
FundraiserEvent.KIND,
LongTextNoteEvent.KIND,
LiveChessGameEndEvent.KIND,
AttestationEvent.KIND,
@@ -123,6 +125,7 @@ class HomeNewThreadFeedFilter(
return (
noteEvent is TextNoteEvent ||
noteEvent is ClassifiedsEvent ||
noteEvent is FundraiserEvent ||
noteEvent.isRenderableRepost() ||
(noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) ||
(noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) ||
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -76,6 +77,7 @@ class UserProfileMutualFeedFilter(
(
it.event is TextNoteEvent ||
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
@@ -82,6 +83,7 @@ class UserProfileNewThreadFeedFilter(
it.event is TextNoteEvent ||
it.event is CommentEvent ||
it.event is ClassifiedsEvent ||
it.event is FundraiserEvent ||
it.event.isRenderableRepost() ||
it.event is LongTextNoteEvent ||
it.event is WikiNoteEvent ||
@@ -157,6 +157,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetHeaderForThread
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderFundraiser
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
@@ -221,6 +222,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
@@ -765,6 +767,8 @@ private fun FullBleedNoteCompose(
RenderCalendarDateSlotEvent(baseNote, accountViewModel, nav)
} else if (noteEvent is GoalEvent) {
RenderGoal(baseNote, accountViewModel, nav)
} else if (noteEvent is FundraiserEvent) {
RenderFundraiser(baseNote, makeItShort = false, accountViewModel, nav)
} else if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) {
RenderRepost(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is RelayDiscoveryEvent) {
+2
View File
@@ -3136,6 +3136,8 @@
<!-- NIP-75 Zap Goals -->
<string name="goal_closed">This goal has closed</string>
<string name="goal_progress">%1$s funded of %2$s sats goal</string>
<string name="fundraiser_ends">Ends %1$s</string>
<string name="fundraiser_onchain_donation">On-chain donation</string>
<string name="goal_amount_label">Goal amount (sats)</string>
<string name="goal_amount_placeholder">100000</string>
<string name="goal_description_label">Describe your goal</string>
@@ -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.quartz.experimental.agora
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.agora.tags.DeadlineTag
import com.vitorpamplona.quartz.experimental.agora.tags.GoalAmountTag
import com.vitorpamplona.quartz.experimental.agora.tags.WalletTag
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
/**
* Agora fundraiser / crowdfunding campaign (kind 33863).
*
* An app-specific addressable kind used by the Agora client (built on the Ditto
* stack). It is **not** defined by any NIP; the schema below is derived from
* events seen in the wild. It reuses standard NIP tags wherever possible
* (`title`, `banner`, `imeta`, `t` hashtags, `published_at`) plus a few
* fundraiser-specific tags:
*
* - `goal` fundraising target, integer sats.
* - `deadline` unix-seconds deadline to reach the goal.
* - `w` one or more on-chain donation addresses (Bitcoin / silent
* payment). Display/copy only; Amethyst does not send on-chain.
*
* Amethyst renders progress from zaps to this event (it cannot observe on-chain
* donations), so the progress bar reflects Lightning support, not the full
* amount raised.
*/
@Immutable
class FundraiserEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
PublishedAtProvider {
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun banner() = tags.firstNotNullOfOrNull(BannerTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
/** Best image to show for the campaign: explicit banner, else an `image` tag. */
fun coverImage() = banner() ?: image()
/** Fundraising target in sats, from the `goal` tag. */
fun goal() = tags.firstNotNullOfOrNull(GoalAmountTag::parse)
/** Deadline (unix seconds) from the `deadline` tag. */
fun deadline() = tags.firstNotNullOfOrNull(DeadlineTag::parse)
/** On-chain donation addresses from the `w` tags (may be empty). */
fun wallets() = tags.mapNotNull(WalletTag::parse)
fun topics() = tags.hashtags()
override fun publishedAt(): Long? {
val publishedAt = tags.firstNotNullOfOrNull(PublishedAtTag::parse) ?: return null
// ignore timestamps in the future
return if (publishedAt <= createdAt) publishedAt else null
}
companion object {
const val KIND = 33863
const val ALT_DESCRIPTION = "Fundraiser campaign"
}
}
@@ -0,0 +1,43 @@
/*
* 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.quartz.experimental.agora.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The deadline (unix seconds) by which an Agora fundraiser (kind 33863) aims to
* reach its goal e.g. `["deadline", "9700905600"]`.
*/
class DeadlineTag {
companion object {
const val TAG_NAME = "deadline"
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(deadlineUnixSeconds: Long) = arrayOf(TAG_NAME, deadlineUnixSeconds.toString())
}
}
@@ -0,0 +1,43 @@
/*
* 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.quartz.experimental.agora.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The fundraising target for an Agora fundraiser (kind 33863), as a plain
* integer in sats e.g. `["goal", "10000"]`.
*/
class GoalAmountTag {
companion object {
const val TAG_NAME = "goal"
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1].toLongOrNull()
}
fun assemble(goalInSats: Long) = arrayOf(TAG_NAME, goalInSats.toString())
}
}
@@ -0,0 +1,45 @@
/*
* 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.quartz.experimental.agora.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* An on-chain donation address for an Agora fundraiser (kind 33863) a Bitcoin
* address or BIP-352 silent-payment address e.g. `["w", "bc1p..."]`. A
* fundraiser may carry several. These are display/copy targets only; Amethyst
* does not send on-chain payments.
*/
class WalletTag {
companion object {
const val TAG_NAME = "w"
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(address: String) = arrayOf(TAG_NAME, address)
}
}
@@ -22,6 +22,7 @@
package com.vitorpamplona.quartz.utils
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
@@ -418,6 +419,7 @@ class EventFactory {
FileStorageHeaderEvent.KIND -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
FhirResourceEvent.KIND -> FhirResourceEvent(id, pubKey, createdAt, tags, content, sig)
FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig)
FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig)
GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig)
GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,79 @@
/*
* 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.quartz.experimental.agora
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FundraiserEventTest {
private fun sampleEvent(): Event =
EventFactory.create(
id = "45e3f48a12b461fc4684002136944ad061921368e993a832c506f0eebb5cf807",
pubKey = "652b1b36da75133890148f685af9038e1934e00a276815ac6b16d8b249d53de1",
createdAt = 1_780_094_572L,
kind = FundraiserEvent.KIND,
tags =
arrayOf(
arrayOf("d", "please-help-me-i-m-amira-from-gaza-i-need-your-help"),
arrayOf("title", "Please help me, I'm Amira from Gaza"),
arrayOf("banner", "https://blossom.primal.net/abc.jpg"),
arrayOf("goal", "10000"),
arrayOf("deadline", "9700905600"),
arrayOf("w", "bc1p2zmtzrlxde9zsd3fxhwf5fpxmutzzkvq7gnuwmxcans7wwxjee7s9dft56"),
arrayOf("w", "sp1qqfsmnyaerhghjceqfu95az6qfw0ugf4f"),
arrayOf("t", "community"),
arrayOf("t", "emergency"),
),
content = "Help Amira from Gaza continue her education.",
sig = "00".repeat(64),
)
@Test
fun factoryBuildsFundraiserForKind33863() {
val event = sampleEvent()
assertTrue(
event is FundraiserEvent,
"Expected a FundraiserEvent but got ${event::class.simpleName}",
)
}
@Test
fun kind33863IsNowKnown() {
assertTrue(EventFactory.isKnownKind(FundraiserEvent.KIND), "kind 33863 should be a known kind")
}
@Test
fun parsesFundraiserFields() {
val event = sampleEvent() as FundraiserEvent
assertEquals("Please help me, I'm Amira from Gaza", event.title())
assertEquals("https://blossom.primal.net/abc.jpg", event.coverImage())
assertEquals(10000L, event.goal())
assertEquals(9700905600L, event.deadline())
assertEquals(2, event.wallets().size)
assertEquals("bc1p2zmtzrlxde9zsd3fxhwf5fpxmutzzkvq7gnuwmxcans7wwxjee7s9dft56", event.wallets().first())
assertEquals(listOf("community", "emergency"), event.topics())
assertEquals("please-help-me-i-m-amira-from-gaza-i-need-your-help", event.dTag())
}
}