diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index de1e5ce5fa..6a0a31f9e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 972776ab05..ffbc121bcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Fundraiser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Fundraiser.kt new file mode 100644 index 0000000000..d42b17d868 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Fundraiser.kt @@ -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, + 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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt index f9fa834f7a..e3b823bf55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/followPacks/feed/dal/FollowPackFeedNewThreadFeedFilter.kt @@ -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()) || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index cc00f9fd7e..e1d086328b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -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()) || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt index 0cc86e9416..d451ca0325 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/mutual/dal/UserProfileMutualFeedFilter.kt @@ -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 || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index b96bfb0248..cfcd046558 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -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 || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 5edbaf98b5..9bde3c4686 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eb9f7602a7..40bed9f73f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3136,6 +3136,8 @@ This goal has closed %1$s funded of %2$s sats goal + Ends %1$s + On-chain donation Goal amount (sats) 100000 Describe your goal diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEvent.kt new file mode 100644 index 0000000000..4ec3926354 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEvent.kt @@ -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>, + 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" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/DeadlineTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/DeadlineTag.kt new file mode 100644 index 0000000000..00a6616de8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/DeadlineTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/GoalAmountTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/GoalAmountTag.kt new file mode 100644 index 0000000000..c17155eba8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/GoalAmountTag.kt @@ -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): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/WalletTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/WalletTag.kt new file mode 100644 index 0000000000..bb843485e5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/agora/tags/WalletTag.kt @@ -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? { + 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) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 6f85ea0a3a..f0b180b0c3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEventTest.kt new file mode 100644 index 0000000000..80f11d2975 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/agora/FundraiserEventTest.kt @@ -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()) + } +}