adds basic support for NIP85 Polls

This commit is contained in:
Vitor Pamplona
2026-02-17 16:00:54 -05:00
parent 893a318fbd
commit f665bec63d
54 changed files with 2348 additions and 860 deletions
@@ -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<String>,
) {
val poll = cache.getOrCreateNote(event.id).toEventHint<PollEvent>()
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,
@@ -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)
@@ -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,
)
@@ -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(
@@ -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(
@@ -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())
}
@@ -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())
}
@@ -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)
}
@@ -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),
)
}
@@ -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,
)
},
)
}
@@ -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())
}
}
@@ -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,
)
@@ -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)
@@ -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 {
@@ -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)
}
@@ -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,
@@ -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<Color>,
@@ -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,
@@ -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,
)
}
@@ -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(),
)
}
@@ -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(),
)
}
@@ -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,
)
}
}
}
@@ -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<Color>,
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<Color>,
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<PollItemCard>,
val type: PollType,
val endsAt: Long?,
val isMyPoll: Boolean = false,
val haveIVotedFlow: Flow<Boolean>,
val haveIVoted: () -> Boolean,
) {
fun hasEnded() = endsAt != null && endsAt < TimeUtils.now()
}
@Stable
class PollItemCard(
val code: String,
val label: String,
val results: Flow<TallyResults>,
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<String>) -> 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<String>) -> 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<String>) -> Unit,
) {
var multichoice by
remember {
mutableStateOf<Set<String>>(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(),
)
}
}
}
@@ -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<Color>,
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)
}
}
}
@@ -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,
@@ -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(
@@ -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(
@@ -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,
)
}
}
@@ -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<HexKey>()
var pollOptions = newStateMapPollOptions()
var valueMaximum by mutableStateOf<Long?>(null)
var valueMinimum by mutableStateOf<Long?>(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<Int, OptionTag> = newStateMapPollOptions()
var closedAt by mutableStateOf<Long?>(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<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
private fun newStateMapPollOptions(): SnapshotStateMap<Int, OptionTag> =
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<Int, String>.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<Int, OptionTag>.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()
@@ -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 ||
@@ -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 ||
@@ -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(
@@ -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(),
@@ -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,
@@ -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 ||
@@ -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,
)
@@ -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 ||
@@ -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,
+6
View File
@@ -460,6 +460,7 @@
<string name="poll_heading_required">Required fields:</string>
<string name="poll_zap_recipients">Zap recipients</string>
<string name="poll_primary_description">Primary poll description…</string>
<string name="poll_option">Option</string>
<string name="poll_option_index">Option %s</string>
<string name="poll_option_description">Poll option description</string>
<string name="poll_heading_optional">Optional fields:</string>
@@ -467,6 +468,7 @@
<string name="poll_zap_value_max">Zap maximum</string>
<string name="poll_consensus_threshold">Consensus</string>
<string name="poll_consensus_threshold_percent">(0100)%</string>
<string name="poll_closing_date_time">Poll Closing Date &amp; Time</string>
<string name="poll_closing_time">Close after</string>
<string name="poll_closing_time_days">days</string>
<string name="poll_unable_to_vote">Unable to vote</string>
@@ -1504,4 +1506,8 @@
<string name="reaction">Reaction</string>
<string name="voice_post">Voice Post</string>
<string name="voice_reply">Voice Reply</string>
<string name="closing_time">Closing Time</string>
<string name="confirm">Confirm</string>
<string name="next">Next</string>
<string name="add_poll_option_button">Add poll option button</string>
</resources>
@@ -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<Note>()
private set
@@ -1007,6 +1014,34 @@ public inline fun <T> Iterable<Note>.anyEvent(predicate: (T) -> Boolean): Boolea
return false
}
public inline fun <T> Iterable<Note>.filterEvents(predicate: (T) -> Boolean): List<T> {
if (this is Collection && isEmpty()) return emptyList()
val dest = ArrayList<T>()
for (note in this) {
val noteEvent = note.event as? T
if (noteEvent != null && predicate(noteEvent)) {
dest.add(noteEvent)
}
}
return dest
}
public inline fun <T> Iterable<Note>.filterAuthoredEvents(pubkey: HexKey): List<T> {
if (this is Collection && isEmpty()) return emptyList()
val dest = ArrayList<T>()
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<Note>.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean {
if (this is Collection && isEmpty()) return false
for (note in this) {
@@ -1015,3 +1050,21 @@ public inline fun Iterable<Note>.anyNotNullEvent(predicate: (Event) -> Boolean):
}
return false
}
fun <T : Event> List<Note>.latestByAuthor(): Map<User, T> {
val oneResponsePerUser = mutableMapOf<User, T>()
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
}
@@ -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<PollResponseEvent> =
compareByDescending<PollResponseEvent> { it.createdAt }.thenBy { it.id }
}
class ResponseTally(
val allResponses: List<Note> = emptyList(),
) {
val votes: Map<User, PollResponseEvent> = allResponses.latestByAuthor()
val tally: Map<String, Set<User>> = 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<HexKey>,
): TallyResults {
val comparator = compareByDescending<User> { 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<HexKey>,
): TallyResults = responses.value.filterTo(code, forKey, priorityAccounts)
fun tallyFlow(
code: String,
forKey: HexKey,
priorityAccounts: Flow<Set<HexKey>>,
): Flow<TallyResults> =
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<Boolean> = responses.map { hasPubKeyVoted(user) }.distinctUntilChanged()
}
@Stable
class TallyResults(
val users: List<User> = emptyList(),
val percent: Float = 0.0f,
val isWinning: Boolean = false,
)
fun Map<User, PollResponseEvent>.votesByOption(): Map<String, Set<User>> {
val tally = mutableMapOf<String, MutableSet<User>>()
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
}
@@ -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<Array<String>>,
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<OptionTag>,
endsAt: Long?,
relays: List<NormalizedRelayUrl>,
pollType: PollType = PollType.SINGLE_CHOICE,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollEvent>.() -> Unit = {},
) = eventTemplate(KIND, description, createdAt) {
alt(ALT_DESCRIPTION)
poolType(pollType)
options(options)
relays(relays)
endsAt?.let {
endsAt(it)
}
initializer()
}
}
}
@@ -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<PollEvent>.poolType(type: PollType) = addUnique(PollTypeTag.assemble(type))
fun TagArrayBuilder<PollEvent>.endsAt(timestamp: Long) = addUnique(EndsAtTag.assemble(timestamp))
fun TagArrayBuilder<PollEvent>.relays(relays: List<NormalizedRelayUrl>) = addAll(RelayTag.assemble(relays))
fun TagArrayBuilder<PollEvent>.options(options: List<OptionTag>) = addAll(OptionTag.assemble(options))
@@ -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
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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())
}
}
@@ -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<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty()
fun parse(tag: Array<String>): 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<OptionTag>) = labels.map { opt -> assemble(opt) }
}
}
@@ -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<String>) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun notMatch(tag: Array<String>) = !(tag.has(0) && tag[0] == TAG_NAME)
fun parse(tag: Array<String>): 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<NormalizedRelayUrl>) = relays.map { assemble(it) }
}
}
@@ -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<Array<String>>,
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<PollEvent>,
responses: Set<String>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
poll(poll)
notifyAuthor(poll)
responses(responses)
initializer()
}
}
}
@@ -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<PollResponseEvent>.poll(pollHint: EventHintBundle<PollEvent>) = addUnique(PollTag.assemble(pollHint))
fun TagArrayBuilder<PollResponseEvent>.responses(responses: Set<String>) = addAll(ResponseTag.assemble(responses))
fun TagArrayBuilder<PollResponseEvent>.notifyAuthor(pollHint: EventHintBundle<PollEvent>) = add(pollHint.toPTag().toTagArray())
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun isTagged(
tag: Array<String>,
eventId: HexKey,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId
fun isTagged(
tag: Array<String>,
eventIds: Set<HexKey>,
) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds
fun parse(tag: Array<String>): 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>): 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<String>): 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<PollEvent>) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey)
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(code: String) = arrayOf(TAG_NAME, code)
fun assemble(responses: Set<String>) = responses.map { code -> assemble(code) }
}
}
@@ -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)