mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -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()) ||
|
||||
|
||||
+3
@@ -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()) ||
|
||||
|
||||
+2
@@ -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 ||
|
||||
|
||||
+2
@@ -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 ||
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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.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"
|
||||
}
|
||||
}
|
||||
+43
@@ -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())
|
||||
}
|
||||
}
|
||||
+43
@@ -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())
|
||||
}
|
||||
}
|
||||
+45
@@ -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)
|
||||
|
||||
+79
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user