From f665bec63db805c2ccf98d51a9f831eb8f8c9ebc Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 17 Feb 2026 16:00:54 -0500 Subject: [PATCH] adds basic support for NIP85 Polls --- .../vitorpamplona/amethyst/model/Account.kt | 23 + .../amethyst/model/LocalCache.kt | 42 + .../FilterNotificationsToPubkey.kt | 4 + .../FilterRepliesAndReactionsToNotes.kt | 4 +- .../subassemblies/SearchPostsByText.kt | 4 + .../amethyst/ui/actions/NewPollClosing.kt | 110 --- .../ui/actions/NewPollConsensusThreshold.kt | 110 --- .../amethyst/ui/actions/NewPollOption.kt | 95 --- .../ui/actions/NewPollPrimaryDescription.kt | 103 --- .../ui/actions/NewPollRecipientsField.kt | 65 -- .../ui/actions/NewPollVoteValueRange.kt | 128 --- .../ui/actions/uploads/RecordVoiceButton.kt | 12 +- .../amethyst/ui/components/ClickableBox.kt | 8 +- .../ui/components/SensitivityWarning.kt | 10 + .../amethyst/ui/note/NoteCompose.kt | 16 +- .../amethyst/ui/note/TimeAgoFormatter.kt | 40 + .../ui/note/{PollNote.kt => ZapPollNote.kt} | 16 +- ...teViewModel.kt => ZapPollNoteViewModel.kt} | 0 .../creators/invoice/AddLnInvoiceButton.kt | 8 +- .../note/creators/polls/PollDeadlinePicker.kt | 179 ++++ .../note/creators/polls/PollOptionsField.kt | 128 +++ .../ui/note/creators/zappolls/PollField.kt | 74 -- .../amethyst/ui/note/types/Poll.kt | 775 ++++++++++++++++-- .../amethyst/ui/note/types/ZapPoll.kt | 136 +++ .../amethyst/ui/screen/TopNavFilterState.kt | 3 + .../datasource/FilterPostsByGeohash.kt | 4 +- .../datasource/FilterPostsByHashtags.kt | 4 +- .../loggedIn/home/ShortNotePostScreen.kt | 32 +- .../loggedIn/home/ShortNotePostViewModel.kt | 176 ++-- .../home/dal/HomeConversationsFeedFilter.kt | 2 + .../home/dal/HomeNewThreadFeedFilter.kt | 2 + .../nip65Follows/FilterHomePostsByAuthors.kt | 4 + .../FilterHomePostsFromCommunities.kt | 3 + .../dal/NotificationFeedFilter.kt | 2 + .../dal/UserProfileConversationsFeedFilter.kt | 2 + .../datasource/FilterUserProfilePosts.kt | 4 +- .../dal/UserProfileNewThreadFeedFilter.kt | 2 + .../loggedIn/threadview/ThreadFeedView.kt | 14 +- amethyst/src/main/res/values/strings.xml | 6 + .../amethyst/commons/model/Note.kt | 53 ++ .../model/nip88Polls/PollResponsesCache.kt | 146 ++++ .../quartz/nip88Polls/poll/PollEvent.kt | 78 ++ .../nip88Polls/poll/TagArrayBuilderExt.kt | 37 + .../quartz/nip88Polls/poll/TagArrayExt.kt | 43 + .../quartz/nip88Polls/poll/tags/EndsAtTag.kt | 41 + .../quartz/nip88Polls/poll/tags/OptionTag.kt | 54 ++ .../nip88Polls/poll/tags/PollTypeTag.kt | 53 ++ .../quartz/nip88Polls/poll/tags/RelayTag.kt | 48 ++ .../nip88Polls/response/PollResponseEvent.kt | 84 ++ .../nip88Polls/response/TagArrayBuilderExt.kt | 34 + .../quartz/nip88Polls/response/TagArrayExt.kt | 35 + .../nip88Polls/response/tags/PollTag.kt | 105 +++ .../nip88Polls/response/tags/ResponseTag.kt | 43 + .../quartz/utils/EventFactory.kt | 4 + 54 files changed, 2348 insertions(+), 860 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/{PollNote.kt => ZapPollNote.kt} (99%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/{PollNoteViewModel.kt => ZapPollNoteViewModel.kt} (100%) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/EndsAtTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/OptionTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/PollTypeTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/RelayTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/PollTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/ResponseTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d29997fae..3f657b2bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -192,6 +192,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -882,6 +884,10 @@ class Account( } } + if (event is PollEvent) { + relayList.addAll(event.relays()) + } + relayList.addAll(computeRelaysForChannels(event)) return relayList @@ -991,6 +997,23 @@ class Account( cache.justConsumeMyOwnEvent(event) } + suspend fun pollRespond( + event: PollEvent, + responses: Set, + ) { + val poll = cache.getOrCreateNote(event.id).toEventHint() + + if (poll != null) { + val template = PollResponseEvent.build(poll, responses) + + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + + client.send(signedEvent, computeRelayListToBroadcast(signedEvent)) + } + } + suspend fun createNip95( byteArray: ByteArray, headerInfo: FileHeader, 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 f4f8f743b..43414d1ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -180,6 +180,8 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent @@ -1520,6 +1522,12 @@ object LocalCache : ILocalCache, ICacheProvider { deleteNote.author?.statusStateOrNull()?.removeStatus(deleteNote) } + if (deletedEvent is PollResponseEvent) { + deletedEvent.poll()?.eventId?.let { + getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(deleteNote) + } + } + if (deletedEvent is TorrentCommentEvent) { deletedEvent.torrentIds()?.let { getNoteIfExists(it)?.removeReply(deleteNote) @@ -1998,6 +2006,32 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: PollEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: PollResponseEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val pollId = event.poll()?.eventId + if (pollId != null) { + val pollNote = getOrCreateNote(pollId) + val responseNote = getOrCreateNote(event.id) + + val new = consumeRegularEvent(event, relay, wasVerified) + if (new) { + pollNote.pollState().addResponse(responseNote) + } + return new + } + + return false + } + fun consume( event: FileStorageEvent, relay: NormalizedRelayUrl?, @@ -2611,6 +2645,12 @@ object LocalCache : ILocalCache, ICacheProvider { note.author?.statusStateOrNull()?.removeStatus(note) } + if (noteEvent is PollResponseEvent) { + noteEvent.poll()?.eventId?.let { + getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note) + } + } + note.clearFlow() notes.remove(note.idHex) @@ -2998,6 +3038,8 @@ object LocalCache : ILocalCache, ICacheProvider { is PublicMessageEvent -> consume(event, relay, wasVerified) is PeopleListEvent -> consume(event, relay, wasVerified) is PollNoteEvent -> consume(event, relay, wasVerified) + is PollEvent -> consume(event, relay, wasVerified) + is PollResponseEvent -> consume(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) is ContactCardEvent -> consume(event, relay, wasVerified) is RelaySetEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 1ded5bbe5..164291266 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent val SummaryKinds = listOf( @@ -64,6 +66,8 @@ val NotificationsPerKeyKinds = EphemeralChatEvent.KIND, BadgeAwardEvent.KIND, PollNoteEvent.KIND, + PollEvent.KIND, + PollResponseEvent.KIND, PublicMessageEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 4298599fe..404722223 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -49,7 +50,6 @@ val RepliesAndReactionsKinds = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, - PollNoteEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, @@ -62,6 +62,8 @@ val RepliesAndReactionsKinds2 = NIP90StatusEvent.KIND, TorrentCommentEvent.KIND, GitReplyEvent.KIND, + PollResponseEvent.KIND, + PollNoteEvent.KIND, ) fun filterRepliesAndReactionsToNotes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 0e2e2c5b9..25f386b48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val SearchPostsByTextKinds1 = @@ -83,6 +85,8 @@ val SearchPostsByTextKinds3 = InteractiveStorySceneEvent.KIND, FollowListEvent.KIND, NipTextEvent.KIND, + PollEvent.KIND, + PollResponseEvent.KIND, ) fun searchPostsByText( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt deleted file mode 100644 index 13d6c10e4..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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.components - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.CancellationException - -@Composable -fun NewPollClosing(pollViewModel: ShortNotePostViewModel) { - var text by rememberSaveable { mutableStateOf("") } - - pollViewModel.isValidClosedAt.value = true - if (text.isNotEmpty()) { - try { - val int = text.toLong() - if (int < 0) { - pollViewModel.isValidClosedAt.value = false - } else { - pollViewModel.closedAt = int - } - } catch (e: Exception) { - if (e is CancellationException) throw e - pollViewModel.isValidClosedAt.value = false - } - } - - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_closing_time), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_closing_time_days), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollClosingPreview() { - NewPollClosing(ShortNotePostViewModel()) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt deleted file mode 100644 index d90793bf8..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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.components - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.CancellationException - -@Composable -fun NewPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) { - var text by rememberSaveable { mutableStateOf("") } - - pollViewModel.isValidConsensusThreshold.value = true - if (text.isNotEmpty()) { - try { - val int = text.toInt() - if (int < 0 || int > 100) { - pollViewModel.isValidConsensusThreshold.value = false - } else { - pollViewModel.consensusThreshold = int - } - } catch (e: Exception) { - if (e is CancellationException) throw e - pollViewModel.isValidConsensusThreshold.value = false - } - } - - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_consensus_threshold), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_consensus_threshold_percent), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollConsensusThresholdPreview() { - NewPollConsensusThreshold(ShortNotePostViewModel()) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt deleted file mode 100644 index b88e9aa5d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * 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.actions - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.tooling.preview.Preview -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun NewPollOption( - pollViewModel: ShortNotePostViewModel, - optionIndex: Int, -) { - Row { - val deleteIcon: @Composable (() -> Unit) = { - IconButton( - onClick = { - pollViewModel.removePollOption(optionIndex) - }, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringRes(R.string.clear), - ) - } - } - - OutlinedTextField( - modifier = Modifier.weight(1F), - value = pollViewModel.pollOptions[optionIndex] ?: "", - onValueChange = { - pollViewModel.updatePollOption(optionIndex, it) - }, - label = { - Text( - text = stringRes(R.string.poll_option_index).format(optionIndex + 1), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_option_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else - // colorInValid, - trailingIcon = if (optionIndex > 1) deleteIcon else null, - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollOptionPreview() { - NewPollOption(ShortNotePostViewModel(), 0) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt deleted file mode 100644 index d3c5400a7..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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.components - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun NewPollPrimaryDescription(pollViewModel: ShortNotePostViewModel) { - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - var isInputValid = true - if (pollViewModel.message.text - .isEmpty() - ) { - isInputValid = false - } - - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - OutlinedTextField( - value = pollViewModel.message, - onValueChange = { pollViewModel.updateMessage(it) }, - label = { - Text( - text = stringRes(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - modifier = - Modifier.fillMaxWidth().padding(top = 8.dp).focusRequester(focusRequester).onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringRes(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - colors = if (isInputValid) colorValid else colorInValid, - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt deleted file mode 100644 index e52d53a78..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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.components - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun NewPollRecipientsField( - pollViewModel: ShortNotePostViewModel, - account: Account, -) { - // if no recipients, add user's pubkey - if (pollViewModel.zapRecipients.isEmpty()) { - pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex) - } - - // TODO allow add multiple recipients and check input validity - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = pollViewModel.zapRecipients[0], - onValueChange = { /* TODO */ }, - enabled = false, - label = { - Text( - text = stringRes(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt deleted file mode 100644 index 20c9c8dce..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * 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.actions - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Arrangement -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.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun NewPollVoteValueRange(pollViewModel: ShortNotePostViewModel) { - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = pollViewModel.valueMinimum?.toString() ?: "", - onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidValueMinimum.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_zap_value_min), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = DoubleHorzSpacer) - - OutlinedTextField( - value = pollViewModel.valueMaximum?.toString() ?: "", - onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidValueMaximum.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_zap_value_max), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringRes(R.string.poll_zap_value_min_max_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp), - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollVoteValueRangePreview() { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - NewPollVoteValueRange(ShortNotePostViewModel()) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 9e598bc79..32613496f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -49,7 +51,9 @@ fun RecordVoiceButton( var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } - Column { + Column( + verticalArrangement = Arrangement.Center, + ) { // Floating recording indicator at the top FloatingRecordingIndicator( modifier = Modifier.height(50.dp), @@ -58,7 +62,7 @@ fun RecordVoiceButton( ) RecordAudioBox( - modifier = Modifier, + modifier = Modifier.fillMaxSize(), onRecordTaken = { recording -> isRecording = false elapsedSeconds = 0 @@ -77,12 +81,12 @@ fun RecordVoiceButton( } Box( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(42.dp), contentAlignment = Alignment.Center, ) { // Expanding circles background animation ExpandingCirclesAnimation( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(42.dp), isRecording = recordingState, primaryColor = MaterialTheme.colorScheme.primary, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 5d1e2eb44..608424282 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -44,7 +45,7 @@ import com.vitorpamplona.amethyst.ui.theme.ripple24dp fun ClickableBox( modifier: Modifier = Modifier, onClick: () -> Unit, - content: @Composable () -> Unit, + content: @Composable BoxScope.() -> Unit, ) { Box( modifier.clickable( @@ -54,9 +55,8 @@ fun ClickableBox( onClick = onClick, ), contentAlignment = Alignment.Center, - ) { - content() - } + content = content, + ) } @OptIn(ExperimentalFoundationApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index c39f05996..18b45ffc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -57,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW @@ -111,6 +113,14 @@ fun SensitivityWarning( } } +@Preview +@Composable +fun ContentWarningNotePreview() { + ThemeComparisonColumn { + ContentWarningNote({}) + } +} + @Composable fun ContentWarningNote(onDismiss: () -> Unit) { Column { 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 4ff83b259..f4ee5b4fd 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 @@ -131,6 +131,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent +import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader @@ -220,6 +221,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent @@ -967,7 +969,7 @@ private fun RenderNoteRow( } is PollNoteEvent -> { - RenderPoll( + RenderZapPoll( baseNote, makeItShort, canPreview, @@ -979,6 +981,18 @@ private fun RenderNoteRow( ) } + is PollEvent -> { + RenderPoll( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + is FileHeaderEvent -> { FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index dbc0ed5ae..1583fce61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -115,6 +115,46 @@ fun timeAgoNoDot( } } +fun timeAheadNoDot( + time: Long?, + context: Context, +): String { + if (time == null) return " " + if (time == 0L) return " ${stringRes(context, R.string.never)}" + + val timeDifference = time - TimeUtils.now() + + return if (timeDifference > TimeUtils.ONE_YEAR) { + // Dec 12, 2022 + + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } + + yearFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_MONTH) { + // Dec 12 + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } + + monthFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_DAY) { + // 2 days + (timeDifference / TimeUtils.ONE_DAY).toString() + stringRes(context, R.string.d) + } else if (timeDifference > TimeUtils.ONE_HOUR) { + (timeDifference / TimeUtils.ONE_HOUR).toString() + stringRes(context, R.string.h) + } else if (timeDifference > TimeUtils.ONE_MINUTE) { + (timeDifference / TimeUtils.ONE_MINUTE).toString() + stringRes(context, R.string.m) + } else { + stringRes(context, R.string.now) + } +} + fun dateFormatter( time: Long?, never: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt index cc4fc65fe..a9c5f7a81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt @@ -113,7 +113,7 @@ import kotlin.uuid.Uuid @Preview @Composable -fun PollNotePreview() { +fun ZapZapPollNotePreview() { val event = PollNoteEvent( id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72", @@ -171,7 +171,7 @@ fun PollNotePreview() { Column( Modifier.padding(10.dp), ) { - PollNote( + ZapPollNote( baseNote = baseNote, true, remember { mutableStateOf(color) }, @@ -186,7 +186,7 @@ fun PollNotePreview() { @Preview @Composable -fun PollNotePreview2() { +fun ZapZapPollNotePreview2() { val event = PollNoteEvent( id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab", @@ -223,7 +223,7 @@ fun PollNotePreview2() { Column( Modifier.padding(10.dp), ) { - PollNote( + ZapPollNote( baseNote = baseNote, true, remember { mutableStateOf(color) }, @@ -237,7 +237,7 @@ fun PollNotePreview2() { } @Composable -fun PollNote( +fun ZapPollNote( baseNote: Note, canPreview: Boolean, backgroundColor: MutableState, @@ -249,7 +249,7 @@ fun PollNote( pollViewModel.init(accountViewModel.account) pollViewModel.load(baseNote) - PollNote( + ZapPollNote( baseNote = baseNote, pollViewModel = pollViewModel, canPreview = canPreview, @@ -260,7 +260,7 @@ fun PollNote( } @Composable -fun PollNote( +fun ZapPollNote( baseNote: Note, pollViewModel: PollNoteViewModel, canPreview: Boolean, @@ -426,7 +426,7 @@ private fun RenderOptionAfterVote( } @Composable -fun DisplayProgress( +private fun DisplayProgress( poolOption: PollOption, color: Color, modifier: Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt similarity index 100% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt index 155f31fb9..c4c6f9f8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt @@ -20,18 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.invoice -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @Composable fun AddLnInvoiceButton( @@ -45,14 +43,14 @@ fun AddLnInvoiceButton( Icon( imageVector = Icons.Default.CurrencyBitcoin, contentDescription = stringRes(id = R.string.add_bitcoin_invoice), - modifier = Modifier.size(20.dp), + modifier = Size20Modifier, tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( imageVector = Icons.Default.CurrencyBitcoin, contentDescription = stringRes(id = R.string.cancel_bitcoin_invoice), - modifier = Modifier.size(20.dp), + modifier = Size20Modifier, tint = BitcoinOrange, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt new file mode 100644 index 000000000..64a70ae6a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt @@ -0,0 +1,179 @@ +/* + * 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.creators.polls + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PollDeadlinePicker(model: ShortNotePostViewModel) { + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + // Get current time details + val currentTime = LocalDateTime.now() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = + currentTime + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli(), + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + // Only allow today and future dates + return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer + } + }, + ) + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = false, // Set to true if you prefer military time + ) + + val context = LocalContext.current + + OutlinedCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.DateRange, contentDescription = null) + Spacer(Modifier.width(12.dp)) + + if (model.closedAt == null) { + Text(stringRes(R.string.poll_closing_date_time), style = MaterialTheme.typography.bodyLarge) + } else { + Text( + text = timeAheadNoDot(model.closedAt, context), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + + // --- Date Picker Dialog --- + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { Text(stringResource(R.string.next)) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + // --- Time Picker Dialog --- + if (showTimePicker) { + TimePickerDialog( + title = { + Text(stringResource(R.string.closing_time)) + }, + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton(onClick = { + val date = + datePickerState.selectedDateMillis?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault()).toLocalDate() + } ?: LocalDate.now() + + val finalDateTime = + LocalDateTime.of( + date, + LocalTime.of(timePickerState.hour, timePickerState.minute), + ) + + // 2. Convert to Unix Timestamp (Seconds) + val unixTimestamp = + finalDateTime + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond + + model.closedAt = unixTimestamp + showTimePicker = false + }) { Text(stringResource(R.string.confirm)) } + }, + ) { + TimePicker(state = timePickerState) + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun PollDeadlinePickerPreview() { + PollDeadlinePicker( + ShortNotePostViewModel(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt new file mode 100644 index 000000000..5a973d304 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt @@ -0,0 +1,128 @@ +/* + * 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.creators.polls + +import android.annotation.SuppressLint +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.utils.RandomInstance + +@Composable +fun PollOptionsField(postViewModel: ShortNotePostViewModel) { + val optionsList = postViewModel.pollOptions + Column( + modifier = Modifier.fillMaxWidth(), + ) { + optionsList.forEach { option -> + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = postViewModel.pollOptions[option.key]?.label ?: "", + onValueChange = { + postViewModel.updatePollOption(option.key, it) + }, + label = { + Text( + text = stringRes(R.string.poll_option_index, option.key + 1), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringRes(R.string.poll_option_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + trailingIcon = { + IconButton( + onClick = { + postViewModel.removePollOption(option.key) + }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringRes(R.string.clear), + ) + } + }, + ) + } + + Spacer(Modifier.height(8.dp)) + + PollDeadlinePicker(postViewModel) + + Spacer(Modifier.height(2.dp)) + + Button( + onClick = { + postViewModel.pollOptions[postViewModel.pollOptions.size] = OptionTag(RandomInstance.randomChars(6), "") + }, + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + ), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Icon(Icons.Default.Add, contentDescription = stringRes(R.string.add_poll_option_button)) + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun NewPollClosingPreview() { + PollOptionsField( + ShortNotePostViewModel(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt deleted file mode 100644 index ac1d91cc3..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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.creators.zappolls - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.actions.NewPollOption -import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange -import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.theme.Size18Modifier -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun PollField(postViewModel: ShortNotePostViewModel) { - val optionsList = postViewModel.pollOptions - Column( - modifier = Modifier.fillMaxWidth(), - ) { - optionsList.forEach { value -> - NewPollOption(postViewModel, value.key) - } - - NewPollVoteValueRange(postViewModel) - - Button( - onClick = { - // postViewModel.pollOptions[postViewModel.pollOptions.size] = "" - optionsList[optionsList.size] = "" - }, - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.placeholderText, - ), - colors = - ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.placeholderText, - ), - ) { - Image( - painter = painterRes(resourceId = android.R.drawable.ic_input_add, 1), - contentDescription = "Add poll option button", - modifier = Size18Modifier, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index b16f0df29..e472685a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -20,35 +20,83 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import androidx.compose.foundation.layout.Spacer +import android.annotation.SuppressLint +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache +import com.vitorpamplona.amethyst.commons.model.nip88Polls.TallyResults import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.PollNote -import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BigPadding +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.SmallishBorder +import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlin.collections.map @Composable fun RenderPoll( @@ -56,81 +104,692 @@ fun RenderPoll( makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, - unPackReply: Boolean, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PollNoteEvent ?: return - val eventContent = noteEvent.content - - val showReply by - remember(note) { - derivedStateOf { - !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) - } - } - - if (showReply) { - val replyingDirectlyTo = - remember(note) { - val replyingTo = noteEvent.replyingToAddressOrEvent() - if (replyingTo != null) { - val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { - newNote - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } - if (replyingDirectlyTo != null) { - ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) - Spacer(modifier = StdVertSpacer) - } - } + val noteEvent = note.event as? PollEvent ?: return if (makeItShort && accountViewModel.isLoggedUser(note.author)) { Text( - text = eventContent, + text = noteEvent.content, color = MaterialTheme.colorScheme.placeholderText, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } else { - val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } - val callbackUri = remember(note) { note.toNostrUri() } - SensitivityWarning( note = note, accountViewModel = accountViewModel, ) { + InnerRenderPoll(noteEvent, note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + } + } +} + +@Composable +fun InnerRenderPoll( + event: PollEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + val callbackUri = remember(note) { note.toNostrUri() } + + Column( + verticalArrangement = SpacedBy5dp, + ) { + TranslatableRichTextViewer( + content = event.content, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + + RenderPollCard( + event = event, + pollState = note.pollState(), + accountViewModel = accountViewModel, + galleryUser = { user -> + ClickableUserPicture( + user, + Size25dp, + accountViewModel, + onClick = { + nav.nav { routeFor(user) } + }, + ) + }, + ) { code, label -> TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - quotesLeft = quotesLeft, - modifier = Modifier.fillMaxWidth(), + content = label, + canPreview = canPreview, + quotesLeft = 1, + modifier = Modifier, tags = tags, backgroundColor = backgroundColor, - id = note.idHex, + id = note.idHex + code, callbackUri = callbackUri, accountViewModel = accountViewModel, nav = nav, ) - - PollNote( - note, - canPreview = canPreview && !makeItShort, - backgroundColor, - accountViewModel, - nav, - ) } - if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + if (event.hasHashtags()) { + DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav) + } + } +} + +@Stable +class PollCard( + val options: List, + val type: PollType, + val endsAt: Long?, + val isMyPoll: Boolean = false, + val haveIVotedFlow: Flow, + val haveIVoted: () -> Boolean, +) { + fun hasEnded() = endsAt != null && endsAt < TimeUtils.now() +} + +@Stable +class PollItemCard( + val code: String, + val label: String, + val results: Flow, + val currentResults: () -> TallyResults, +) + +@Composable +fun RenderPollCard( + event: PollEvent, + pollState: PollResponsesCache, + accountViewModel: AccountViewModel, + galleryUser: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable RowScope.(code: String, label: String) -> Unit, +) { + val card = + remember(event) { + PollCard( + options = + event.options().map { option -> + PollItemCard( + code = option.code, + label = option.label, + results = + pollState.tallyFlow( + option.code, + accountViewModel.account.pubKey, + accountViewModel.account.allFollows.flow + .map { it.authors }, + ), + currentResults = { + pollState.currentTally( + option.code, + accountViewModel.account.pubKey, + accountViewModel.account.allFollows.flow.value.authors, + ) + }, + ) + }, + type = event.pollType(), + endsAt = event.endsAt(), + isMyPoll = event.pubKey == accountViewModel.account.pubKey, + haveIVotedFlow = pollState.hasPubKeyVotedFlow(accountViewModel.account.userProfile()), + haveIVoted = { + pollState.hasPubKeyVoted(accountViewModel.account.userProfile()) + }, + ) + } + + RenderPollCard( + card = card, + onRespond = { responses -> + accountViewModel.launchSigner { + accountViewModel.account.pollRespond(event, responses) + } + }, + resultContent = galleryUser, + labelContent = labelContent, + ) +} + +@Composable +fun RenderPollCard( + card: PollCard, + onRespond: (Set) -> Unit, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable RowScope.(code: String, label: String) -> Unit, +) { + Column( + verticalArrangement = SpacedBy5dp, + ) { + if (card.isMyPoll) { + RenderResults(card, resultContent, labelContent) + } else { + val haveIVoted = card.haveIVoted() + if (haveIVoted) { + RenderResults(card, resultContent, labelContent) + } else { + // waits for vote + val haveIVoted by card.haveIVotedFlow.collectAsStateWithLifecycle(haveIVoted) + if (haveIVoted) { + RenderResults(card, resultContent, labelContent) + } else if (card.hasEnded()) { + RenderResults(card, resultContent, labelContent) + } else { + when (card.type) { + PollType.SINGLE_CHOICE -> RenderSingleChoiceOptions(card, labelContent, onRespond) + PollType.MULTI_CHOICE -> RenderMultiChoiceOptions(card, labelContent, onRespond) + } + } + } + } + } +} + +@Composable +private fun ColumnScope.RenderSingleChoiceOptions( + card: PollCard, + labelContent: @Composable (RowScope.(String, String) -> Unit), + onRespond: (Set) -> Unit, +) { + card.options.forEach { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallishBorder) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.placeholderText, + shape = SmallishBorder, + ).clickable { + onRespond(setOf(it.code)) + }, + ) { + Row( + modifier = BigPadding, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + labelContent(it.code, it.label) + } + } + } +} + +@Composable +private fun ColumnScope.RenderMultiChoiceOptions( + card: PollCard, + labelContent: @Composable (RowScope.(String, String) -> Unit), + onRespond: (Set) -> Unit, +) { + var multichoice by + remember { + mutableStateOf>(emptySet()) + } + + card.options.forEach { option -> + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallishBorder) + .border(1.dp, MaterialTheme.colorScheme.grayText, SmallishBorder) + .clickable { + multichoice += option.code + }, + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = option.code in multichoice, + onCheckedChange = { checked -> + if (checked) { + multichoice += option.code + } else { + multichoice -= option.code + } + }, + ) + + labelContent(option.code, option.label) + } + } + } + + Button( + onClick = { + onRespond(multichoice) + }, + modifier = Modifier.align(Alignment.End), + enabled = multichoice.isNotEmpty(), + ) { + Text("Submit") + } +} + +@Composable +private fun RenderResults( + card: PollCard, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable (RowScope.(code: String, label: String) -> Unit), +) { + card.options.forEach { pollItem -> + RenderClosedItem(pollItem, resultContent) { + labelContent(pollItem.code, pollItem.label) + } + } +} + +@Composable +private fun RenderClosedItem( + item: PollItemCard, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable RowScope.() -> Unit, +) { + val tally by item.results.collectAsStateWithLifecycle(item.currentResults()) + + RenderClosedItem(tally, resultContent, labelContent) +} + +@Composable +private fun RenderClosedItem( + tally: TallyResults, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable RowScope.() -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallishBorder) + .border( + width = 1.dp, + color = + if (tally.isWinning) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.grayText + }, + shape = SmallishBorder, + ).background( + if (tally.isWinning) { + MaterialTheme.colorScheme.allGoodColor.copy(0.2f) + } else { + MaterialTheme.colorScheme.subtleBorder + }, + ), + ) { + // Animate the progress bar when a vote is cast + val animatedProgress by animateFloatAsState( + targetValue = tally.percent, + animationSpec = tween(durationMillis = 800), + ) + + val progressBarColor = if (tally.isWinning) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.primary + + Box( + modifier = + Modifier + .matchParentSize() + .alpha(0.32f) + .drawWithContent { + // Clip the drawing area to show only the progress amount + clipRect(right = size.width * animatedProgress) { + drawRect(progressBarColor) + } + drawContent() + }, + ) + + Row( + modifier = Modifier.padding(15.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + labelContent() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy10dp, + ) { + UserGallery(tally, resultContent) + + Text( + text = "${(tally.percent * 100).toInt()}%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +@Composable +fun UserGallery( + tally: TallyResults, + galleryUser: @Composable RowScope.(user: User) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-10).dp), + ) { + tally.users.take(6).forEach { + key(it.pubkeyHex) { + galleryUser(it) + } + } + + if (tally.users.size > 6) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(Size25dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(tally.users.size - 6), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Preview +@Composable +fun RenderPollManualPreview() { + val poll = + PollCard( + options = + listOf( + PollItemCard( + code = "1", + label = "Yes", + results = flow {}, + currentResults = { + TallyResults( + percent = 0.9f, + isWinning = true, + ) + }, + ), + PollItemCard( + code = "2", + label = "No", + results = flow {}, + currentResults = { + TallyResults( + percent = 0.1f, + isWinning = false, + ) + }, + ), + ), + type = PollType.SINGLE_CHOICE, + endsAt = null, + isMyPoll = true, + haveIVotedFlow = flow {}, + haveIVoted = { true }, + ) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPollCard(poll, {}, {}) { _, label -> + Text( + text = label, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Preview +@Composable +fun RenderPollGameResultsPreview() { + val event = + PollEvent( + id = "1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299", + pubKey = "c21b1a6cdb247ccbd938dcb16b15a4fa382d00ffd7b12d5cbbad172a0cd4d170", + createdAt = 1770690664, + content = "If there cpuld only be one: ", + tags = + arrayOf( + arrayOf("option", "dzv1u5do4", "GTA V"), + arrayOf("option", "8mr177irx", "Cyberpunk 2077"), + arrayOf("relay", "wss://relay.nostr.band/"), + arrayOf("relay", "wss://relay.primal.net/"), + arrayOf("relay", "wss://nos.lol/"), + arrayOf("relay", "wss://relay.damus.io/"), + arrayOf("relay", "wss://wot.nostr.party/"), + arrayOf("relay", "wss://relay.mostr.pub/"), + arrayOf("relay", "wss://offchain.pub/"), + arrayOf("polltype", "singlechoice"), + ), + sig = "7263d7e98348474c9f6670a98225e3594bffa813ff9c9084b7045126562dc352744450b5e667b0069e0276a63d3b9e14df3b1e1a3e6d5e958c3a7c74390345f5", + ) + + val note = LocalCache.getOrCreateNote("1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299") + + LocalCache.justConsume(event, null, true) + + val response1 = + PollResponseEvent( + "a503bb8b1bd9062fab4acd003f015791faa04a9f321b91fd6d4649315e2a9307", + pubKey = "592295cf2b09a7f9555f43adb734cbee8a84ee892ed3f9336e6a09b6413a0db9", + createdAt = 1770729213, + tags = + arrayOf( + arrayOf("e", "1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299"), + arrayOf("response", "dzv1u5do4"), + ), + content = "", + sig = "0affc0a4f39bbae0802628282af871bbdab8d0338956ebd51cbb5453eac8f66f56013af35dafbe847762f6cfc6f9746be59260d2c462602501dad94d2fb67b63", + ) + + val response2 = + PollResponseEvent( + "1ad46eabd0044911e2c65ace22d4ad19640a74dc2b3daeea7bc2862fb0dcf5fb", + pubKey = "c21b1a6cdb247ccbd938dcb16b15a4fa382d00ffd7b12d5cbbad172a0cd4d170", + createdAt = 1770729213, + tags = + arrayOf( + arrayOf("e", "1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299"), + arrayOf("response", "8mr177irx"), + ), + content = "", + sig = "3efa83a899e1038fde76b8130f99881ab88c330adcd7d9f2578fa3c3e346eba05d627bd75ddb5780ffd460fcefb07839d0b379ba20db30772fa4d847b1dcfa6f", + ) + + LocalCache.justConsume(response1, null, true) + LocalCache.justConsume(response2, null, true) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPoll( + note, + false, + true, + 2, + remember { mutableStateOf(Color.Transparent) }, + mockAccountViewModel(), + EmptyNav(), + ) + } + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Preview +@Composable +fun RenderPollColorResultsPreview() { + val event = + PollEvent( + id = "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91", + pubKey = "feb88e80a63d6a25b4b56eed4fac67342a97f2206e0a04fad89eb24d55ea303f", + createdAt = 1769822711, + content = "Color?", + tags = + arrayOf( + arrayOf("option", "hrwgtnzue", "Blue"), + arrayOf("option", "1cx1gldth", "Red"), + arrayOf("option", "5e29bzfjf", "Green"), + arrayOf("polltype", "singlechoice"), + arrayOf("endsAt", "1769844311"), + arrayOf("relay", "wss://relay.ditto.pub"), + arrayOf("relay", "wss://relay.primal.net"), + arrayOf("relay", "wss://relay.damus.io"), + arrayOf("I", "iso3166:BR"), + arrayOf("K", "iso3166"), + arrayOf("i", "iso3166:BR"), + arrayOf("k", "iso3166"), + ), + sig = "d65af73c3b652ea06df3dc00b05a6f155cf8936d645ca002d2c1a5d59b45e9b072e5217d3339711ad62f5b105859d8da95eb636681bd12ad04063d19359f48b5", + ) + + val note = LocalCache.getOrCreateNote(event) + + LocalCache.justConsume(event, null, true) + + val response1 = + PollResponseEvent( + id = "b756c6b3112d088355f7e3d288f5ccc0633522d86f770e1bdba67fae1b082719", + pubKey = "3ff64bd7dde76af783a9545e2fb3ce84d921be6c8c04f3dc6c4fa734cf7eb812", + createdAt = 1769841653, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "hrwgtnzue"), + arrayOf("client", "www.pollstr.site"), + ), + content = "", + sig = "3a8cc549a5736774568e4b9a32e689ad5882fe2d030c4e05ecf78c6b1cfe5b5c9d3383067f525dfbddae600dc18b1266526e6d5d4e1f43e5723ebc7607b0377d", + ) + + val response2 = + PollResponseEvent( + id = "6aecc88b2acc8145c68553040da333d62085a6f75b874b1b7fceab2c8158069e", + pubKey = "c21b1a6cdb247ccbd938dcb16b15a4fa382d00ffd7b12d5cbbad172a0cd4d170", + createdAt = 1769827608, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "5e29bzfjf"), + ), + content = "", + sig = "1ab865185fa7328d8b64f5c676a370d2dc4896678d6b13786ff142cfe8d7dc635f523740fad25a944de1b01def179548843a893a088f177dd011674a746d02a4", + ) + + val response3 = + PollResponseEvent( + id = "53a5ac0972b08276a0ea34a7dcd1d796b14453f4a76ca4626ed42ad19d67ade9", + pubKey = "feb88e80a63d6a25b4b56eed4fac67342a97f2206e0a04fad89eb24d55ea303f", + createdAt = 1769822723, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "5e29bzfjf"), + ), + content = "", + sig = "d330c6f10d26674785d7e312929f4b4e01272840c087de45dceb0389ce14368908011b621dad24a9e438c1d757f9eac9940b6b9005721ff995c50258d1a7f6fb", + ) + + val response4 = + PollResponseEvent( + id = "5c6670f736f166dad6f1784ebe72bb851a9d82a9b8bb1355d8d74c2c7f1869fd", + pubKey = "feb88e80a63d6a25b4b56eed4fac67342a97f2206e0a04fad89eb24d55ea303f", + createdAt = 1769822720, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "hrwgtnzue"), + ), + content = "", + sig = "d2feee481c0e84306fcb0815c7c671ab697948148b18307f864fe6c96a09485c12bf8f843d406b231914d60bd2735d3f3ed0b4e89377794bccd66e2e0acf2eb5", + ) + + LocalCache.justConsume(response1, null, true) + LocalCache.justConsume(response2, null, true) + LocalCache.justConsume(response3, null, true) + LocalCache.justConsume(response4, null, true) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPoll( + note, + false, + true, + 2, + remember { mutableStateOf(Color.Transparent) }, + mockAccountViewModel(), + EmptyNav(), + ) + } + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Preview +@Composable +fun RenderPollMultiChoicePreview() { + val event = + PollEvent( + id = "ab1cad8d8f24aaa53b8cf34dd06ddedda5961c57da7a0df91349901fc15b5042", + pubKey = "baeb862f3318390ec5af5c9db64ae5ddb2efc1a97db54c6550656bfa2dcc054b", + createdAt = 1769053977, + content = "Have you seen the simply Nostr/greeny show?", + tags = + arrayOf( + arrayOf("option", "py4d986jp", "Yes"), + arrayOf("option", "z0tipo9as", "No "), + arrayOf("option", "59ya8j3jo", "GFY"), + arrayOf("relay", "wss://nos.lol"), + arrayOf("relay", "wss://student.chadpolytechnic.com"), + arrayOf("relay", "wss://nostr-relay.wlvs.space"), + arrayOf("relay", "wss://nostr.ono.re"), + arrayOf("relay", "wss://nostr.bongbong.com"), + arrayOf("polltype", "multiplechoice"), + arrayOf("client", "www.pollstr.site"), + ), + sig = "aaddf12bc4b7947d8c57dd0fb4378c5f0daab6844cc3205bf1480e2a10e077b1abca67d071530e9fecac517fd6d052e49630a32596d9ecfb862967d0b2b2d899", + ) + + val note = LocalCache.getOrCreateNote(event) + + LocalCache.justConsume(event, null, true) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPoll( + note, + makeItShort = false, + canPreview = true, + quotesLeft = 2, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt new file mode 100644 index 000000000..1de8238e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt @@ -0,0 +1,136 @@ +/* + * 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.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition +import com.vitorpamplona.amethyst.ui.note.ZapPollNote +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +@Composable +fun RenderZapPoll( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + unPackReply: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PollNoteEvent ?: return + val eventContent = noteEvent.content + + val showReply by + remember(note) { + derivedStateOf { + !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + } + } + + if (showReply) { + val replyingDirectlyTo = + remember(note) { + val replyingTo = noteEvent.replyingToAddressOrEvent() + if (replyingTo != null) { + val newNote = accountViewModel.getNoteIfExists(replyingTo) + if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { + newNote + } else { + note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } + } + } + if (replyingDirectlyTo != null) { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + } + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + + ZapPollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + nav, + ) + } + + if (noteEvent.hasHashtags()) { + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 4f8368962..3ce666e5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf @@ -386,6 +387,7 @@ val DEFAULT_FEED_KINDS = ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, PollNoteEvent.KIND, + PollEvent.KIND, HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, @@ -406,6 +408,7 @@ val DEFAULT_COMMUNITY_FEEDS = HighlightEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, + PollEvent.KIND, PinListEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 3af73498f..913be738d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -29,12 +29,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val PostsByGeohashKinds = @@ -42,13 +42,13 @@ val PostsByGeohashKinds = TextNoteEvent.KIND, ChannelMessageEvent.KIND, LongTextNoteEvent.KIND, + PollEvent.KIND, PollNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, WikiNoteEvent.KIND, - CommentEvent.KIND, ) fun filterPostsByGeohash( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 0e233a335..69bbeed3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val PostsByHashtagsKinds = @@ -46,7 +47,7 @@ val PostsByHashtagsKinds = TextNoteEvent.KIND, ChannelMessageEvent.KIND, LongTextNoteEvent.KIND, - PollNoteEvent.KIND, + PollEvent.KIND, LiveActivitiesChatMessageEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, @@ -60,6 +61,7 @@ val PostsByHashtagKinds2 = AudioTrackEvent.KIND, AudioHeaderEvent.KIND, NipTextEvent.KIND, + PollNoteEvent.KIND, ) fun filterPostsByHashtags( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 63dce9f11..3e83c5860 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -37,6 +38,8 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Poll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel @@ -83,25 +87,24 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.zappolls.PollField import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.replyModifier import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -289,7 +292,7 @@ private fun NewPostScreenBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { - PollField(postViewModel) + PollOptionsField(postViewModel) } } @@ -542,6 +545,17 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { } } +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +private fun BottomRowActionsPreview() { + val model = ShortNotePostViewModel() + model.canUsePoll = true + ThemeComparisonColumn { + BottomRowActions(model) + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, @@ -552,17 +566,17 @@ private fun AddPollButton( ) { if (!isPollActive) { Icon( - painter = painterRes(R.drawable.ic_poll, 1), + imageVector = Icons.Outlined.Poll, contentDescription = stringRes(id = R.string.poll), - modifier = Size20Modifier, + modifier = Modifier.height(22.dp), tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( - painter = painterRes(R.drawable.ic_lists, 1), + imageVector = Icons.Outlined.Poll, contentDescription = stringRes(id = R.string.disable_poll), - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.height(22.dp), + tint = MaterialTheme.colorScheme.primary, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 314e58db5..0a40b2548 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.content.Context import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -70,15 +69,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.experimental.zapPolls.closedAt -import com.vitorpamplona.quartz.experimental.zapPolls.consensusThreshold -import com.vitorpamplona.quartz.experimental.zapPolls.maxAmount -import com.vitorpamplona.quartz.experimental.zapPolls.minAmount -import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash @@ -112,6 +104,8 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -128,6 +122,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow @@ -224,17 +219,8 @@ open class ShortNotePostViewModel : // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) - var zapRecipients = mutableStateListOf() - var pollOptions = newStateMapPollOptions() - var valueMaximum by mutableStateOf(null) - var valueMinimum by mutableStateOf(null) - var consensusThreshold: Int? = null - var closedAt: Long? = null - - var isValidValueMaximum = mutableStateOf(true) - var isValidValueMinimum = mutableStateOf(true) - var isValidConsensusThreshold = mutableStateOf(true) - var isValidClosedAt = mutableStateOf(true) + var pollOptions: SnapshotStateMap = newStateMapPollOptions() + var closedAt by mutableStateOf(null) // Invoices var canAddInvoice by mutableStateOf(false) @@ -411,9 +397,13 @@ open class ShortNotePostViewModel : private fun loadFromDraft(draft: Note) { val draftEvent = draft.event ?: return - if (draftEvent !is TextNoteEvent) return + if (draftEvent is TextNoteEvent) { + loadFromDraft(draftEvent) + } - loadFromDraft(draftEvent) + if (draftEvent is PollEvent) { + loadFromDraft(draftEvent) + } } private fun loadFromDraft(draftEvent: TextNoteEvent) { @@ -486,20 +476,71 @@ open class ShortNotePostViewModel : wantsForwardZapTo = true } - val polls = draftEvent.tags.filter { it.size > 1 && it[0] == "poll_option" } - wantsPoll = polls.isNotEmpty() + wantsPoll = false - polls.forEach { - pollOptions[it[1].toInt()] = it[2] + message = TextFieldValue(draftEvent.content) + + iMetaAttachments.addAll(draftEvent.imetas()) + + urlPreviews.update(message) + } + + private fun loadFromDraft(draftEvent: PollEvent) { + canAddInvoice = accountViewModel.userProfile().lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null + multiOrchestrator = null + + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + if (geohash != null) { + wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } - val minMax = draftEvent.tags.filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } - minMax.forEach { - if (it[0] == "value_maximum") { - valueMaximum = it[1].toLong() - } else if (it[0] == "value_minimum") { - valueMinimum = it[1].toLong() + val zapRaiser = draftEvent.zapraiserAmount() + wantsZapRaiser = zapRaiser != null + zapRaiserAmount.value = null + if (zapRaiser != null) { + zapRaiserAmount.value = zapRaiser + } + + eTags = + draftEvent.tags.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull { + val note = LocalCache.checkGetOrCreateNote(it[1]) + note } + + pTags = + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { + LocalCache.getOrCreateUser(it[1]) + } + + canUsePoll = originalNote == null + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + val polls = draftEvent.options() + wantsPoll = polls.isNotEmpty() + + polls.forEachIndexed { index, tag -> + pollOptions[index] = tag + } + + draftEvent.endsAt()?.let { + closedAt = it } message = TextFieldValue(draftEvent.content) @@ -648,18 +689,16 @@ open class ShortNotePostViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) "" else null return if (wantsPoll) { - val options = pollOptions.map { PollOptionTag(it.key, it.value) } + val options = pollOptions.map { it.value } if (options.isEmpty()) return null val quotes = findNostrUris(tagger.message) + val relays = + accountViewModel.account.nip65RelayList.outboxFlow.value + .toList() - PollNoteEvent.build(tagger.message, options) { - valueMinimum?.let { minAmount(it) } - valueMaximum?.let { maxAmount(it) } - closedAt?.let { closedAt(it) } - consensusThreshold?.let { consensusThreshold(it / 100.0) } - + PollEvent.build(tagger.message, options, closedAt, relays) { pTags(tagger.directMentionsUsers.map { it.toPTag() }) quotes(quotes) hashtags(findHashtags(tagger.message)) @@ -849,11 +888,7 @@ open class ShortNotePostViewModel : pTags = null wantsPoll = false - zapRecipients = mutableStateListOf() pollOptions = newStateMapPollOptions() - valueMaximum = null - valueMinimum = null - consensusThreshold = null closedAt = null wantsInvoice = false @@ -962,7 +997,11 @@ open class ShortNotePostViewModel : draftTag.newVersion() } - private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) + private fun newStateMapPollOptions(): SnapshotStateMap = + mutableStateMapOf( + 0 to OptionTag(RandomInstance.randomChars(6), ""), + 1 to OptionTag(RandomInstance.randomChars(6), ""), + ) fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) @@ -979,9 +1018,7 @@ open class ShortNotePostViewModel : ( !wantsPoll || ( - pollOptions.values.all { it.isNotEmpty() } && - isValidValueMinimum.value && - isValidValueMaximum.value + pollOptions.isNotEmpty() && pollOptions.all { it.value.label.isNotEmpty() } ) ) && multiOrchestrator == null @@ -1131,28 +1168,6 @@ open class ShortNotePostViewModel : Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") } - fun updateMinZapAmountForPoll(textMin: String) { - valueMinimum = textMin.toLongOrNull()?.takeIf { it > 0 } - checkMinMax() - draftTag.newVersion() - } - - fun updateMaxZapAmountForPoll(textMax: String) { - valueMaximum = textMax.toLongOrNull()?.takeIf { it > 0 } - checkMinMax() - draftTag.newVersion() - } - - fun checkMinMax() { - if ((valueMinimum ?: 0) > (valueMaximum ?: Long.MAX_VALUE)) { - isValidValueMinimum.value = false - isValidValueMaximum.value = false - } else { - isValidValueMinimum.value = true - isValidValueMaximum.value = true - } - } - override fun updateZapPercentage( index: Int, sliderValue: Float, @@ -1178,19 +1193,28 @@ open class ShortNotePostViewModel : draftTag.newVersion() } - fun removePollOption(optionIndex: Int) { - pollOptions.removeOrdered(optionIndex) + fun removePollOption(index: Int) { + pollOptions.removeOrdered(index) draftTag.newVersion() } - private fun MutableMap.removeOrdered(index: Int) { + fun updatePollOption( + index: Int, + label: String, + ) { + val current = pollOptions[index] + pollOptions[index] = OptionTag(current?.code ?: RandomInstance.randomChars(6), label) + draftTag.newVersion() + } + + private fun MutableMap.removeOrdered(index: Int) { val keyList = keys val elementList = values.toMutableList() run stop@{ for (i in index until elementList.size) { val nextIndex = i + 1 if (nextIndex == elementList.size) return@stop - elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } + elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = OptionTag(RandomInstance.randomChars(6), "") } } } elementList.removeAt(elementList.size - 1) @@ -1199,14 +1223,6 @@ open class ShortNotePostViewModel : this.putAll(newEntries) } - fun updatePollOption( - optionIndex: Int, - text: String, - ) { - pollOptions[optionIndex] = text - draftTag.newVersion() - } - fun toggleMarkAsSensitive() { wantsToMarkAsSensitive = !wantsToMarkAsSensitive draftTag.newVersion() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index b4977ddcb..53384e1dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class HomeConversationsFeedFilter( @@ -81,6 +82,7 @@ class HomeConversationsFeedFilter( ( event is TextNoteEvent || event is PollNoteEvent || + event is PollResponseEvent || event is ChannelMessageEvent || event is CommentEvent || event is VoiceReplyEvent || 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 ea33290fe..a179eeb92 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 @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -111,6 +112,7 @@ class HomeNewThreadFeedFilter( (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || + noteEvent is PollEvent || noteEvent is HighlightEvent || noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 798530826..35516310d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -40,6 +40,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent @@ -56,6 +58,7 @@ val HomePostsNewThreadKinds = WikiNoteEvent.KIND, NipTextEvent.KIND, PollNoteEvent.KIND, + PollEvent.KIND, InteractiveStoryPrologueEvent.KIND, ) @@ -67,6 +70,7 @@ val HomePostsConversationKinds = EphemeralChatEvent.KIND, VoiceEvent.KIND, VoiceReplyEvent.KIND, + PollResponseEvent.KIND, ) fun filterNewHomePostsByAuthors( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt index 7c4c6365b..4c7f5c300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val HomePostsFromCommunityKinds = @@ -41,6 +42,7 @@ val HomePostsFromCommunityKinds = LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, + PollEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, CommunityPostApprovalEvent.KIND, @@ -54,6 +56,7 @@ val HomePostsFromCommunityKindsStr = LongTextNoteEvent.KIND.toString(), ClassifiedsEvent.KIND.toString(), HighlightEvent.KIND.toString(), + PollEvent.KIND.toString(), WikiNoteEvent.KIND.toString(), NipTextEvent.KIND.toString(), CommunityPostApprovalEvent.KIND.toString(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index f9449bb97..8fc0f713a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -66,6 +66,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent @@ -105,6 +106,7 @@ class NotificationFeedFilter( LnZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, + PollEvent.KIND, PollNoteEvent.KIND, PrivateDmEvent.KIND, PublicMessageEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index 66f94a7c8..660a390f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class UserProfileConversationsFeedFilter( @@ -64,6 +65,7 @@ class UserProfileConversationsFeedFilter( ( it.event is TextNoteEvent || it.event is PollNoteEvent || + it.event is PollResponseEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent || it.event is CommentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 89d78c81b..f36547074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent @@ -48,7 +49,7 @@ val UserProfilePostKinds1 = GenericRepostEvent.KIND, RepostEvent.KIND, LongTextNoteEvent.KIND, - PollNoteEvent.KIND, + PollEvent.KIND, HighlightEvent.KIND, WikiNoteEvent.KIND, VoiceEvent.KIND, @@ -63,6 +64,7 @@ val UserProfilePostKinds2 = InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, VoiceReplyEvent.KIND, + PollNoteEvent.KIND, PinListEvent.KIND, ) 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 860e270b7..cf401a723 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 @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -80,6 +81,7 @@ class UserProfileNewThreadFeedFilter( it.event is WikiNoteEvent || it.event is NipTextEvent || it.event is PollNoteEvent || + it.event is PollEvent || it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || 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 71947abfd..9f64b6f30 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 @@ -156,6 +156,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment +import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader import com.vitorpamplona.amethyst.ui.painterRes @@ -233,6 +234,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -668,7 +670,7 @@ private fun FullBleedNoteCompose( nav, ) } else if (noteEvent is PollNoteEvent) { - RenderPoll( + RenderZapPoll( baseNote, false, canPreview, @@ -678,6 +680,16 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) + } else if (noteEvent is PollEvent) { + RenderPoll( + note = baseNote, + makeItShort = false, + canPreview = canPreview, + quotesLeft = 3, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } else if (noteEvent is PrivateDmEvent) { RenderPrivateMessage( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0f1b45f0a..571ce3cb1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -460,6 +460,7 @@ Required fields: Zap recipients Primary poll description… + Option Option %s Poll option description Optional fields: @@ -467,6 +468,7 @@ Zap maximum Consensus (0–100)% + Poll Closing Date & Time Close after days Unable to vote @@ -1504,4 +1506,8 @@ Reaction Voice Post Voice Reply + Closing Time + Confirm + Next + Add poll option button diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index ffd4c5dc7..9e95521a2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji import com.vitorpamplona.amethyst.commons.util.replace @@ -131,6 +132,12 @@ open class Note( removeReport(note) } + var poll: PollResponsesCache? = null + + fun pollStateOrNull(): PollResponsesCache? = poll + + fun pollState(): PollResponsesCache = poll ?: PollResponsesCache().also { poll = it } + // These fields are updated every time an event related to this note is received. var replies = listOf() private set @@ -1007,6 +1014,34 @@ public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolea return false } +public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): List { + if (this is Collection && isEmpty()) return emptyList() + + val dest = ArrayList() + for (note in this) { + val noteEvent = note.event as? T + if (noteEvent != null && predicate(noteEvent)) { + dest.add(noteEvent) + } + } + return dest +} + +public inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { + if (this is Collection && isEmpty()) return emptyList() + + val dest = ArrayList() + for (note in this) { + if (note.author?.pubkeyHex != pubkey) { + val noteEvent = note.event as? T + if (noteEvent != null) { + dest.add(noteEvent) + } + } + } + return dest +} + public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { if (this is Collection && isEmpty()) return false for (note in this) { @@ -1015,3 +1050,21 @@ public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): } return false } + +fun List.latestByAuthor(): Map { + val oneResponsePerUser = mutableMapOf() + + forEach { note -> + val author = note.author ?: return@forEach + val event = note.event as? T ?: return@forEach + + val currentResponse = oneResponsePerUser[author] + if (currentResponse == null) { + oneResponsePerUser[author] = event + } else if (event.createdAt > currentResponse.createdAt) { + oneResponsePerUser[author] = event + } + } + + return oneResponsePerUser +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt new file mode 100644 index 000000000..08f17280d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip88Polls + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserDependencies +import com.vitorpamplona.amethyst.commons.model.latestByAuthor +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set + +@Stable +class PollResponsesCache : UserDependencies { + companion object { + val DefaultFeedOrder: Comparator = + compareByDescending { it.createdAt }.thenBy { it.id } + } + + class ResponseTally( + val allResponses: List = emptyList(), + ) { + val votes: Map = allResponses.latestByAuthor() + val tally: Map> = votes.votesByOption() + + fun winning() = tally.maxByOrNull { it.value.size }?.key + + fun totalVotes() = tally.entries.sumOf { it.value.size } + } + + val responses = MutableStateFlow(ResponseTally()) + + fun addResponse(note: Note) { + // if it's already there, quick exit + if (responses.value.allResponses.contains(note)) return + + responses.update { + ResponseTally( + it.allResponses + note, + ) + } + } + + fun removeResponse(deleteNote: Note) { + // if it's not already there, quick exit + if (!responses.value.allResponses.contains(deleteNote)) return + + responses.update { + ResponseTally( + it.allResponses - deleteNote, + ) + } + } + + fun ResponseTally.filterTo( + code: String, + forKey: HexKey, + priority: Set, + ): TallyResults { + val comparator = compareByDescending { it.pubkeyHex == forKey }.thenByDescending { it.pubkeyHex in priority }.thenBy { it.pubkeyHex } + + val usersThatVotedForThisOption = tally[code] ?: emptyList() + + val votes = totalVotes() + + val percent = + if (votes > 0) { + usersThatVotedForThisOption.size.toFloat() / votes.toFloat() + } else { + 0f + } + + val sortedUsers = usersThatVotedForThisOption.sortedWith(comparator) + + return TallyResults(sortedUsers, percent, code == winning()) + } + + fun currentTally( + code: String, + forKey: HexKey, + priorityAccounts: Set, + ): TallyResults = responses.value.filterTo(code, forKey, priorityAccounts) + + fun tallyFlow( + code: String, + forKey: HexKey, + priorityAccounts: Flow>, + ): Flow = + combine(responses, priorityAccounts) { responses, priority -> + responses.filterTo(code, forKey, priority) + } + + fun hasPubKeyVoted(user: User): Boolean = responses.value.votes.containsKey(user) + + fun hasPubKeyVotedFlow(user: User): Flow = responses.map { hasPubKeyVoted(user) }.distinctUntilChanged() +} + +@Stable +class TallyResults( + val users: List = emptyList(), + val percent: Float = 0.0f, + val isWinning: Boolean = false, +) + +fun Map.votesByOption(): Map> { + val tally = mutableMapOf>() + + this.forEach { (user, responseEvent) -> + responseEvent.responses().forEach { code -> + val currentTally = tally[code] + if (currentTally == null) { + tally[code] = mutableSetOf(user) + } else { + currentTally.add(user) + } + } + } + return tally +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt new file mode 100644 index 000000000..63647352e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt @@ -0,0 +1,78 @@ +/* + * 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.nip88Polls.poll + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PollEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + fun options() = tags.options() + + fun relays() = tags.relays() + + fun pollType() = tags.pollType() + + fun endsAt() = tags.endsAt() + + fun hasEnded() = tags.hasEnded() + + companion object { + const val KIND = 1068 + const val ALT_DESCRIPTION = "Poll" + + fun build( + description: String, + options: List, + endsAt: Long?, + relays: List, + pollType: PollType = PollType.SINGLE_CHOICE, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + alt(ALT_DESCRIPTION) + poolType(pollType) + options(options) + relays(relays) + endsAt?.let { + endsAt(it) + } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayBuilderExt.kt new file mode 100644 index 000000000..319f797a3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayBuilderExt.kt @@ -0,0 +1,37 @@ +/* + * 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.nip88Polls.poll + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag + +fun TagArrayBuilder.poolType(type: PollType) = addUnique(PollTypeTag.assemble(type)) + +fun TagArrayBuilder.endsAt(timestamp: Long) = addUnique(EndsAtTag.assemble(timestamp)) + +fun TagArrayBuilder.relays(relays: List) = addAll(RelayTag.assemble(relays)) + +fun TagArrayBuilder.options(options: List) = addAll(OptionTag.assemble(options)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.kt new file mode 100644 index 000000000..86d00ef73 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.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.nip88Polls.poll + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag.Companion.parse +import com.vitorpamplona.quartz.utils.TimeUtils + +fun TagArray.options() = mapNotNull(OptionTag::parse) + +fun TagArray.relays() = mapNotNull(RelayTag::parse) + +fun TagArray.pollType() = firstNotNullOfOrNull(PollTypeTag::parse) ?: PollType.SINGLE_CHOICE + +fun TagArray.endsAt() = firstNotNullOfOrNull(EndsAtTag::parse) + +fun TagArray.hasEnded() = endsAt()?.let { it < TimeUtils.now() } ?: false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/EndsAtTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/EndsAtTag.kt new file mode 100644 index 000000000..341d69be1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/EndsAtTag.kt @@ -0,0 +1,41 @@ +/* + * 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.nip88Polls.poll.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class EndsAtTag { + companion object { + const val TAG_NAME = "endsAt" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(time: Long) = arrayOfNotNull(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/OptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/OptionTag.kt new file mode 100644 index 000000000..0aa40db02 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/OptionTag.kt @@ -0,0 +1,54 @@ +/* + * 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.nip88Polls.poll.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +@Stable +class OptionTag( + val code: String, + val label: String, +) { + companion object { + const val TAG_NAME = "option" + + fun isTag(tag: Array) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + fun parse(tag: Array): OptionTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + return OptionTag(tag[1], tag[2]) + } + + fun assemble( + code: String, + label: String, + ) = arrayOf(TAG_NAME, code, label) + + fun assemble(option: OptionTag) = assemble(option.code, option.label) + + fun assemble(labels: List) = labels.map { opt -> assemble(opt) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/PollTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/PollTypeTag.kt new file mode 100644 index 000000000..a8d50ce6b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/PollTypeTag.kt @@ -0,0 +1,53 @@ +/* + * 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.nip88Polls.poll.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class PollType( + val code: String, +) { + SINGLE_CHOICE("singlechoice"), + MULTI_CHOICE("multiplechoice"), +} + +class PollTypeTag { + companion object { + const val TAG_NAME = "polltype" + + fun isTag(tag: Array) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): PollType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + PollType.SINGLE_CHOICE.code -> PollType.SINGLE_CHOICE + PollType.MULTI_CHOICE.code -> PollType.MULTI_CHOICE + else -> null + } + } + + fun assemble(type: PollType) = arrayOf(TAG_NAME, type.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/RelayTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/RelayTag.kt new file mode 100644 index 000000000..294d69b49 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/RelayTag.kt @@ -0,0 +1,48 @@ +/* + * 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.nip88Polls.poll.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun notMatch(tag: Array) = !(tag.has(0) && tag[0] == TAG_NAME) + + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) + } + + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + + fun assemble(relays: List) = relays.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt new file mode 100644 index 000000000..5ef16deee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt @@ -0,0 +1,84 @@ +/* + * 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.nip88Polls.response + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseId +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.responses +import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PollResponseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + RootScope { + override fun eventHints() = tags.mapNotNull(PollTag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(PollTag::parseId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun responses() = tags.responses() + + fun poll() = tags.poll() + + companion object { + const val KIND = 1018 + const val ALT_DESCRIPTION = "Poll Response" + + fun build( + poll: EventHintBundle, + responses: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + poll(poll) + notifyAuthor(poll) + responses(responses) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayBuilderExt.kt new file mode 100644 index 000000000..6176916f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/* + * 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.nip88Polls.response + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag +import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag + +fun TagArrayBuilder.poll(pollHint: EventHintBundle) = addUnique(PollTag.assemble(pollHint)) + +fun TagArrayBuilder.responses(responses: Set) = addAll(ResponseTag.assemble(responses)) + +fun TagArrayBuilder.notifyAuthor(pollHint: EventHintBundle) = add(pollHint.toPTag().toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt new file mode 100644 index 000000000..83ea261e1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt @@ -0,0 +1,35 @@ +/* + * 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.nip88Polls.response + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag +import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag +import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag.Companion.parse + +fun TagArray.responses() = mapNotNull(ResponseTag::parse) + +fun TagArray.poll() = firstNotNullOfOrNull(PollTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/PollTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/PollTag.kt new file mode 100644 index 000000000..ddaee8e50 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/PollTag.kt @@ -0,0 +1,105 @@ +/* + * 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.nip88Polls.response.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class PollTag( + override val eventId: HexKey, +) : GenericETag { + override var relay: NormalizedRelayUrl? = null + override var author: HexKey? = null + + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.author = authorPubKeyHex + } + + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) + + override fun toTagArray() = assemble(eventId, relay, author) + + companion object { + const val TAG_NAME = "e" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun isTagged( + tag: Array, + eventId: HexKey, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + fun isTagged( + tag: Array, + eventIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds + + fun parse(tag: Array): PollTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return PollTag(tag[1], hint, tag.getOrNull(3)) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + + fun assemble(eventHint: EventHintBundle) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/ResponseTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/ResponseTag.kt new file mode 100644 index 000000000..4ef10f85c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/ResponseTag.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.nip88Polls.response.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ResponseTag { + companion object { + const val TAG_NAME = "response" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + 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(code: String) = arrayOf(TAG_NAME, code) + + fun assemble(responses: Set) = responses.map { code -> assemble(code) } + } +} 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 2b79f5074..4aae539b6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -124,6 +124,8 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent @@ -254,6 +256,8 @@ class EventFactory { PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) + PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig) + PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig)