diff --git a/.gitignore b/.gitignore index d67bb1a41..a167f9389 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /.idea/navEditor.xml /.idea/assetWizardSettings.xml /.idea/androidTestResultsUserPreferences.xml +/.idea/deploymentTargetDropDown.xml .DS_Store /build /captures diff --git a/README.md b/README.md index e31320ea1..bde5d6e5c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Amethyst brings the best social network to your Android phone. Just insert your - [ ] Delegated Event Signing (NIP-26) - [ ] Account Creation / Backup Guidance (NIP-06) - [ ] Message Sent feedback (NIP-20) +- [ ] Polls (NIP-69) # Development Overview diff --git a/app/build.gradle b/app/build.gradle index d324adec4..2da3a6079 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,10 @@ android { applicationIdSuffix '.debug' versionNameSuffix '-DEBUG' resValue "string", "app_name", "@string/app_name_debug" + + lintOptions{ + disable 'MissingTranslation' + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2fa0088c7..dea1ea08d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -30,7 +30,11 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.relays.* +import kotlinx.coroutines.* import nostr.postr.Persona +import java.util.* import java.util.Locale val DefaultChannels = setOf( @@ -152,13 +156,18 @@ class Account( } } - fun createZapRequestFor(note: Note): LnZapRequestEvent? { + fun createZapRequestFor(note: Note, pollOption: Int?): LnZapRequestEvent? { if (!isWriteable()) return null - note.event?.let { - return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!) + note.event?.let { event -> + return LnZapRequestEvent.create( + event, + userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } + ?: localRelays.map { it.url }.toSet(), + loggedIn.privKey!!, + pollOption + ) } - return null } @@ -373,6 +382,39 @@ class Account( LocalCache.consume(signedEvent) } + fun sendPoll( + message: String, + replyTo: List?, + mentions: List?, + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int? + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } + + val signedEvent = PollNoteEvent.create( + msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, + addresses = addresses, + privateKey = loggedIn.privKey!!, + pollOptions = pollOptions, + valueMaximum = valueMaximum, + valueMinimum = valueMinimum, + consensusThreshold = consensusThreshold, + closedAt = closedAt + ) + println("Sending new PollNoteEvent: %s".format(signedEvent.toJson())) + Client.send(signedEvent) + LocalCache.consume(signedEvent) + } + fun sendChannelMessage(message: String, toChannel: String, replyingTo: Note? = null, mentions: List?) { if (!isWriteable()) return @@ -716,7 +758,7 @@ class Account( isAcceptableDirect(note) && ( note.event !is RepostEvent || - (note.event is RepostEvent && note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) + (note.replyTo?.firstOrNull { isAcceptableDirect(it) } != null) ) // is not a reaction about a blocked post } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index da8686412..d94924206 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.service.model.RecommendRelayEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.google.gson.reflect.TypeToken +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledUpdate import fr.acinq.secp256k1.Hex @@ -241,6 +243,42 @@ object LocalCache { } } + fun consume(event: PollNoteEvent, relay: Relay? = null) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + if (antiSpam.isSpam(event)) { + relay?.let { + it.spamCounter++ + } + return + } + + val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } + + note.loadEvent(event, author, replyTo) + + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}") + + // Prepares user's profile view. + author.addNote(note) + + // Counts the replies + replyTo.forEach { + it.addReply(note) + } + + refreshObservers() + } + fun consume(event: BadgeDefinitionEvent) { val note = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) @@ -642,6 +680,7 @@ object LocalCache { fun findNotesStartingWith(text: String): List { return notes.values.filter { (it.event is TextNoteEvent && it.event?.content()?.contains(text, true) ?: false) || + (it.event is PollNoteEvent && it.event?.content()?.contains(text, true) ?: false) || (it.event is ChannelMessageEvent && it.event?.content()?.contains(text, true) ?: false) || it.idHex.startsWith(text, true) || it.idNote().startsWith(text, true) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 749e6ec72..5e50fa84b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.BookmarkListEvent @@ -78,6 +79,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, + PollNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index b2b4e851f..755fbf607 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service import android.util.Log import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent @@ -90,6 +91,7 @@ abstract class NostrDataSource(val debugName: String) { LocalCache.consume(event) } is TextNoteEvent -> LocalCache.consume(event, relay) + is PollNoteEvent -> LocalCache.consume(event, relay) else -> { Log.w("Event Not Supported", event.toJson()) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt index 4a8830dea..dbcb987e6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter @@ -11,7 +12,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") { fun createGlobalFilter() = TypedFilter( types = setOf(FeedType.GLOBAL), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind), limit = 200 ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt index f94e1a8c0..7e8556efc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.UserState import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter @@ -51,7 +52,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") { return TypedFilter( types = setOf(FeedType.FOLLOWS), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), authors = followSet, limit = 400 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 3fae3330a..b59120a58 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -1,12 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.decodePublicKey -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.MetadataEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -65,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { TypedFilter( types = FeedType.values().toSet(), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), + kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), search = mySearchString, limit = 20 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 3494b3aa0..272a93b39 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -2,19 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent -import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent -import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent -import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent -import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent -import com.vitorpamplona.amethyst.service.model.LnZapEvent -import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent -import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent -import com.vitorpamplona.amethyst.service.model.ReactionEvent -import com.vitorpamplona.amethyst.service.model.ReportEvent -import com.vitorpamplona.amethyst.service.model.RepostEvent -import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -42,7 +30,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind + BadgeAwardEvent.kind, BadgeDefinitionEvent.kind, BadgeProfilesEvent.kind, + PollNoteEvent.kind ), tags = mapOf("a" to listOf(aTag.toTag())), since = it.lastReactionsDownloadTime @@ -95,7 +84,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, - LnZapRequestEvent.kind + LnZapRequestEvent.kind, + PollNoteEvent.kind ), tags = mapOf("e" to listOf(it.idHex)), since = it.lastReactionsDownloadTime @@ -128,7 +118,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, LongTextNoteEvent.kind, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind, - ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind + ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind, + PollNoteEvent.kind ), ids = interestedEvents.toList() ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 7ce4626ea..111842b39 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -43,7 +44,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { TypedFilter( types = FeedType.values().toSet(), filter = JsonFilter( - kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind), + kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), authors = listOf(it.pubkeyHex), limit = 200 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 9b7e232ae..14fc3538f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -227,6 +227,7 @@ open class Event( LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) + PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) RecommendRelayEvent.kind -> RecommendRelayEvent(id, pubKey, createdAt, tags, content, sig, lenient) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index ff42b7676..d92584ed6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -19,6 +19,17 @@ class LnZapEvent( .filter { it.firstOrNull() == "e" } .mapNotNull { it.getOrNull(1) } +/* // TODO add poll_option tag to LnZapEvent + override fun zappedPollOption(): Int? = tags + .filter { it.firstOrNull() == "poll_option" } + .getOrNull(1)?.getOrNull(1)?.toInt() +*/ + // TODO replace this hacky way to get poll option with above function + override fun zappedPollOption(): Int? = description() + ?.substringAfter("poll_option\",\"") + ?.substringBefore("\"") + ?.toInt() + override fun zappedAuthor() = tags .filter { it.firstOrNull() == "p" } .mapNotNull { it.getOrNull(1) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt index cc95d34b3..c6ca7efb3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt @@ -6,6 +6,8 @@ interface LnZapEventInterface : EventInterface { fun zappedPost(): List + fun zappedPollOption(): Int? + fun zappedAuthor(): List fun taggedAddresses(): List diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 8863b7af4..360793456 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -23,6 +23,7 @@ class LnZapRequestEvent( originalNote: EventInterface, relays: Set, privateKey: ByteArray, + pollOption: Int?, createdAt: Long = Date().time / 1000 ): LnZapRequestEvent { val content = "" @@ -35,6 +36,9 @@ class LnZapRequestEvent( if (originalNote is LongTextNoteEvent) { tags = tags + listOf(listOf("a", originalNote.address().toTag())) } + if (pollOption != null && pollOption >= 0) { + tags = tags + listOf(listOf(POLL_OPTION, pollOption.toString())) + } val id = generateId(pubKey, createdAt, kind, tags, content) val sig = Utils.sign(id, privateKey) @@ -89,7 +93,11 @@ class LnZapRequestEvent( "wss://nostr.bitcoiner.social", "ws://monad.jb55.com:8080", "wss://relay.snort.social" + ], + [ + "poll_option", "n" ] - ] + ], + "ots": // TODO } */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt new file mode 100644 index 000000000..a6bcc89b8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt @@ -0,0 +1,106 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +const val POLL_OPTION = "poll_option" +const val VALUE_MAXIMUM = "value_maximum" +const val VALUE_MINIMUM = "value_minimum" +const val CONSENSUS_THRESHOLD = "consensus_threshold" +const val CLOSED_AT = "closed_at" + +class PollNoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + // ots: String?, TODO implement OTS: https://github.com/opentimestamps/java-opentimestamps + content: String, + sig: HexKey +) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) { + fun pollOptions(): Map { + val map = mutableMapOf() + tags.filter { it.first() == POLL_OPTION } + .forEach { map[it[1].toInt()] = it[2] } + return map + } + + fun getTagInt(property: String): Int? { + val tagList = tags.filter { + it.firstOrNull() == property + } + val tag = tagList.getOrNull(0) + val s = tag?.getOrNull(1) + + return if (s.isNullOrBlank() || s == "null") { + null + } else { + s.toInt() + } + } + + companion object { + const val kind = 6969 + + fun create( + msg: String, + replyTos: List?, + mentions: List?, + addresses: List?, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000, + pollOptions: Map, + valueMaximum: Int?, + valueMinimum: Int?, + consensusThreshold: Int?, + closedAt: Int? + ): PollNoteEvent { + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = mutableListOf>() + replyTos?.forEach { + tags.add(listOf("e", it)) + } + mentions?.forEach { + tags.add(listOf("p", it)) + } + addresses?.forEach { + tags.add(listOf("a", it.toTag())) + } + pollOptions.forEach { poll_op -> + tags.add(listOf(POLL_OPTION, poll_op.key.toString(), poll_op.value)) + } + tags.add(listOf(VALUE_MAXIMUM, valueMaximum.toString())) + tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString())) + tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString())) + tags.add(listOf(CLOSED_AT, closedAt.toString())) + val id = generateId(pubKey, createdAt, kind, tags, msg) + val sig = Utils.sign(id, privateKey) + return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey()) + } + } +} + +/* +{ + "id": <32-bytes lowercase hex-encoded sha256 of the serialized event data> + "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, + "created_at": , + "kind": 6969, + "tags": [ + ["e", <32-bytes hex of the id of the poll event>, ], + ["p", <32-bytes hex of the key>, ], + ["poll_option", "0", "poll option 0 description string"], + ["poll_option", "1", "poll option 1 description string"], + ["poll_option", "n", "poll option description string"], + ["value_maximum", "maximum satoshi value for inclusion in tally"], + ["value_minimum", "minimum satoshi value for inclusion in tally"], + ["consensus_threshold", "required percentage to attain consensus <0..100>"], + ["closed_at", "unix timestamp in seconds"], + ], + "ots": + "content": , + "sig": <64-bytes hex of the signature of the sha256 hash of the serialized event data, which is the same as the "id" field> +} + */ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt new file mode 100644 index 000000000..ec4b7d631 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt @@ -0,0 +1,79 @@ +package com.vitorpamplona.amethyst.ui.components + +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.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +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.res.stringResource +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.actions.NewPollViewModel + +@Composable +fun NewPollClosing(pollViewModel: NewPollViewModel) { + var text by rememberSaveable { mutableStateOf("") } + + pollViewModel.isValidClosedAt.value = true + if (text.isNotEmpty()) { + try { + val int = text.toInt() + if (int < 0) { + pollViewModel.isValidClosedAt.value = false + } else { pollViewModel.closedAt = int } + } catch (e: Exception) { pollViewModel.isValidClosedAt.value = false } + } + + val colorInValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.error, + unfocusedBorderColor = Color.Red + ) + val colorValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + 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 = stringResource(R.string.poll_closing_time), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_closing_time_days), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + ) + } +} + +@Preview +@Composable +fun NewPollClosingPreview() { + NewPollClosing(NewPollViewModel()) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt new file mode 100644 index 000000000..21e346f43 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt @@ -0,0 +1,79 @@ +package com.vitorpamplona.amethyst.ui.components + +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.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +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.res.stringResource +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.actions.NewPollViewModel + +@Composable +fun NewPollConsensusThreshold(pollViewModel: NewPollViewModel) { + 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) { pollViewModel.isValidConsensusThreshold.value = false } + } + + val colorInValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.error, + unfocusedBorderColor = Color.Red + ) + val colorValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + 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 = stringResource(R.string.poll_consensus_threshold), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_consensus_threshold_percent), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + ) + } +} + +@Preview +@Composable +fun NewPollConsensusThresholdPreview() { + NewPollConsensusThreshold(NewPollViewModel()) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt new file mode 100644 index 000000000..c96af2a23 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt @@ -0,0 +1,72 @@ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R + +@Composable +fun NewPollOption(pollViewModel: NewPollViewModel, optionIndex: Int) { + val colorInValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.error, + unfocusedBorderColor = Color.Red + ) + val colorValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + Row { + OutlinedTextField( + modifier = Modifier + .weight(1F), + value = pollViewModel.pollOptions[optionIndex] ?: "", + onValueChange = { pollViewModel.pollOptions[optionIndex] = it }, + label = { + Text( + text = stringResource(R.string.poll_option_index).format(optionIndex), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_option_description), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else colorInValid + ) + if (optionIndex > 1) { + Button( + modifier = Modifier + .padding(start = 6.dp, top = 2.dp) + .imePadding(), + onClick = { pollViewModel.pollOptions.remove(optionIndex) }, + border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + ) { + Image( + painterResource(id = android.R.drawable.ic_delete), + contentDescription = "Remove poll option button", + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +@Preview +@Composable +fun NewPollOptionPreview() { + NewPollOption(NewPollViewModel(), 0) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt new file mode 100644 index 000000000..1084cf41c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt @@ -0,0 +1,77 @@ +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.material.* +import androidx.compose.runtime.* +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.res.stringResource +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.NewPollViewModel +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun NewPollPrimaryDescription(pollViewModel: NewPollViewModel) { + // 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 = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.error, + unfocusedBorderColor = Color.Red + ) + val colorValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + OutlinedTextField( + value = pollViewModel.message, + onValueChange = { + pollViewModel.updateMessage(it) + }, + label = { + Text( + text = stringResource(R.string.poll_primary_description), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + 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 = stringResource(R.string.poll_primary_description), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + colors = if (isInputValid) colorValid else colorInValid, + visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt new file mode 100644 index 000000000..c8aac5cfc --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt @@ -0,0 +1,43 @@ +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.actions.NewPollViewModel + +@Composable +fun NewPollRecipientsField(pollViewModel: NewPollViewModel, 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, // TODO enable add recipients + label = { + Text( + text = stringResource(R.string.poll_zap_recipients), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + placeholder = { + Text( + text = stringResource(R.string.poll_zap_recipients), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt new file mode 100644 index 000000000..f16623cce --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt @@ -0,0 +1,197 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.ui.components.* +import com.vitorpamplona.amethyst.ui.note.ReplyInformation +import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine +import kotlinx.coroutines.delay + +@Composable +fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) { + val pollViewModel: NewPollViewModel = viewModel() + + val context = LocalContext.current + + val scrollState = rememberScrollState() + + LaunchedEffect(Unit) { + pollViewModel.load(account, baseReplyTo, quote) + delay(100) + + pollViewModel.imageUploadingError.collect { error -> + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + Column( + modifier = Modifier + .padding(start = 10.dp, end = 10.dp, top = 10.dp) + .imePadding() + .weight(1f) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + pollViewModel.cancel() + onClose() + }) + + PollButton( + onPost = { + pollViewModel.sendPoll() + onClose() + }, + isActive = pollViewModel.message.text.isNotBlank() && + pollViewModel.pollOptions.values.all { it.isNotEmpty() } && + pollViewModel.isValidRecipients.value && + pollViewModel.isValidvalueMaximum.value && + pollViewModel.isValidvalueMinimum.value && + pollViewModel.isValidConsensusThreshold.value && + pollViewModel.isValidClosedAt.value + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + ) { + if (pollViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) { + ReplyInformation(pollViewModel.replyTos, pollViewModel.mentions, account, "✖ ") { + pollViewModel.removeFromReplyList(it) + } + } + + Text(stringResource(R.string.poll_heading_required)) + // NewPollRecipientsField(pollViewModel, account) + NewPollPrimaryDescription(pollViewModel) + pollViewModel.pollOptions.values.forEachIndexed { index, element -> + NewPollOption(pollViewModel, index) + } + Button( + onClick = { pollViewModel.pollOptions[pollViewModel.pollOptions.size] = "" }, + border = BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f)), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + ) { + Image( + painterResource(id = android.R.drawable.ic_input_add), + contentDescription = "Add poll option button", + modifier = Modifier.size(18.dp) + ) + } + Text(stringResource(R.string.poll_heading_optional)) + NewPollVoteValueRange(pollViewModel) + NewPollConsensusThreshold(pollViewModel) + NewPollClosing(pollViewModel) + } + } + + val userSuggestions = pollViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp + ), + modifier = Modifier.heightIn(0.dp, 300.dp) + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex } + ) { index, item -> + UserLine(item, account) { + pollViewModel.autocompleteWithUser(item) + } + } + } + } + + Row(modifier = Modifier.fillMaxWidth()) { + /*UploadFromGallery( + isUploading = pollViewModel.isUploadingImage + ) { + pollViewModel.upload(it, context) + }*/ + } + } + } + } + } +} + +@Composable +fun PollButton(modifier: Modifier = Modifier, onPost: () -> Unit = {}, isActive: Boolean) { + Button( + modifier = modifier, + onClick = { + if (isActive) { + onPost() + } + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray + ) + ) { + Text(text = stringResource(R.string.post_poll), color = Color.White) + } +} + +/*@Preview +@Composable +fun NewPollViewPreview() { + NewPollView(onClose = {}, account = Account(loggedIn = Persona())) +}*/ diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollViewModel.kt new file mode 100644 index 000000000..2bd10b6a2 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollViewModel.kt @@ -0,0 +1,141 @@ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.model.* +import com.vitorpamplona.amethyst.service.nip19.Nip19 + +class NewPollViewModel : NewPostViewModel() { + + var zapRecipients = mutableStateListOf() + var pollOptions = newStateMapPollOptions() + var valueMaximum: Int? = null + var valueMinimum: Int? = null + var consensusThreshold: Int? = null + var closedAt: Int? = null + + var isValidRecipients = mutableStateOf(true) + var isValidvalueMaximum = mutableStateOf(true) + var isValidvalueMinimum = mutableStateOf(true) + var isValidConsensusThreshold = mutableStateOf(true) + var isValidClosedAt = mutableStateOf(true) + + override fun load(account: Account, replyingTo: Note?, quote: Note?) { + super.load(account, replyingTo, quote) + } + + override fun addUserToMentions(user: User) { + super.addUserToMentions(user) + } + + override fun addNoteToReplyTos(note: Note) { + super.addNoteToReplyTos(note) + } + + override fun tagIndex(user: User): Int { + return super.tagIndex(user) + } + + override fun tagIndex(note: Note): Int { + return super.tagIndex(note) + } + + fun sendPoll() { + // adds all references to mentions and reply tos + message.text.split('\n').forEach { paragraph: String -> + paragraph.split(' ').forEach { word: String -> + val results = parseDirtyWordForKey(word) + + if (results?.key?.type == Nip19.Type.USER) { + addUserToMentions(LocalCache.getOrCreateUser(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.NOTE) { + addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex)) + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + addNoteToReplyTos(note) + } + } + } + } + + // Tags the text in the correct order. + val newMessage = message.text.split('\n').map { paragraph: String -> + paragraph.split(' ').map { word: String -> + val results = parseDirtyWordForKey(word) + if (results?.key?.type == Nip19.Type.USER) { + val user = LocalCache.getOrCreateUser(results.key.hex) + + "#[${tagIndex(user)}]${results.restOfWord}" + } else if (results?.key?.type == Nip19.Type.NOTE) { + val note = LocalCache.getOrCreateNote(results.key.hex) + + "#[${tagIndex(note)}]${results.restOfWord}" + } else if (results?.key?.type == Nip19.Type.ADDRESS) { + val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) + if (note != null) { + "#[${tagIndex(note)}]${results.restOfWord}" + } else { + word + } + } else { + word + } + }.joinToString(" ") + }.joinToString("\n") + + /* if (originalNote?.channel() != null) { + account?.sendChannelMessage(newMessage, originalNote!!.channel()!!.idHex, originalNote!!, mentions) + } else { + account?.sendPoll(newMessage, replyTos, mentions) + }*/ + + account?.sendPoll(newMessage, replyTos, mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt) + + clearPollStates() + } + + override fun cancel() { + super.cancel() + + clearPollStates() + } + + override fun findUrlInMessage(): String? { + return super.findUrlInMessage() + } + + override fun removeFromReplyList(it: User) { + super.removeFromReplyList(it) + } + + override fun updateMessage(it: TextFieldValue) { + super.updateMessage(it) + } + + override fun autocompleteWithUser(item: User) { + super.autocompleteWithUser(item) + } + + // clear all states + private fun clearPollStates() { + message = TextFieldValue("") + urlPreview = null + isUploadingImage = false + mentions = null + + zapRecipients = mutableStateListOf() + pollOptions = newStateMapPollOptions() + valueMaximum = null + valueMinimum = null + consensusThreshold = null + closedAt = null + } + + private fun newStateMapPollOptions(): SnapshotStateMap { + return mutableStateMapOf(Pair(0, ""), Pair(1, "")) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt new file mode 100644 index 000000000..63a075759 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt @@ -0,0 +1,126 @@ +package com.vitorpamplona.amethyst.ui.actions + +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.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +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.res.stringResource +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 + +@Composable +fun NewPollVoteValueRange(pollViewModel: NewPollViewModel) { + var textMax by rememberSaveable { mutableStateOf("") } + var textMin by rememberSaveable { mutableStateOf("") } + + // check for zapMax amounts < 1 + pollViewModel.isValidvalueMaximum.value = true + if (textMax.isNotEmpty()) { + try { + val int = textMax.toInt() + if (int < 1) { + pollViewModel.isValidvalueMaximum.value = false + } else { pollViewModel.valueMaximum = int } + } catch (e: Exception) { pollViewModel.isValidvalueMaximum.value = false } + } + + // check for minZap amounts < 1 + pollViewModel.isValidvalueMinimum.value = true + if (textMin.isNotEmpty()) { + try { + val int = textMin.toInt() + if (int < 1) { + pollViewModel.isValidvalueMinimum.value = false + } else { pollViewModel.valueMinimum = int } + } catch (e: Exception) { pollViewModel.isValidvalueMinimum.value = false } + } + + // check for zapMin > zapMax + if (textMin.isNotEmpty() && textMax.isNotEmpty()) { + try { + val intMin = textMin.toInt() + val intMax = textMax.toInt() + + if (intMin > intMax) { + pollViewModel.isValidvalueMinimum.value = false + pollViewModel.isValidvalueMaximum.value = false + } + } catch (e: Exception) { + pollViewModel.isValidvalueMinimum.value = false + pollViewModel.isValidvalueMaximum.value = false + } + } + + val colorInValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.error, + unfocusedBorderColor = Color.Red + ) + val colorValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + OutlinedTextField( + value = textMin, + onValueChange = { textMin = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidvalueMinimum.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_zap_value_min), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + placeholder = { + Text( + text = stringResource(R.string.sats), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + ) + OutlinedTextField( + value = textMax, + onValueChange = { textMax = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidvalueMaximum.value) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_zap_value_max), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + placeholder = { + Text( + text = stringResource(R.string.sats), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + ) + } +} + +@Preview +@Composable +fun NewPollVoteValueRangePreview() { + NewPollVoteValueRange(NewPollViewModel()) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 881947e14..0b83d9faa 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -19,9 +19,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -class NewPostViewModel : ViewModel() { - private var account: Account? = null - private var originalNote: Note? = null +open class NewPostViewModel : ViewModel() { + var account: Account? = null + var originalNote: Note? = null var mentions by mutableStateOf?>(null) var replyTos by mutableStateOf?>(null) @@ -34,7 +34,7 @@ class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null - fun load(account: Account, replyingTo: Note?, quote: Note?) { + open fun load(account: Account, replyingTo: Note?, quote: Note?) { originalNote = replyingTo replyingTo?.let { replyNote -> this.replyTos = (replyNote.replyTo ?: emptyList()).plus(replyNote) @@ -58,21 +58,21 @@ class NewPostViewModel : ViewModel() { this.account = account } - fun addUserToMentions(user: User) { + open fun addUserToMentions(user: User) { mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user) } - fun addNoteToReplyTos(note: Note) { + open fun addNoteToReplyTos(note: Note) { note.author?.let { addUserToMentions(it) } replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note) } - fun tagIndex(user: User): Int { + open fun tagIndex(user: User): Int { // Postr Events assembles replies before mentions in the tag order return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0) } - fun tagIndex(note: Note): Int { + open fun tagIndex(note: Note): Int { // Postr Events assembles replies before mentions in the tag order return (if (originalNote?.channel() != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0) } @@ -163,14 +163,14 @@ class NewPostViewModel : ViewModel() { ) } - fun cancel() { + open fun cancel() { message = TextFieldValue("") urlPreview = null isUploadingImage = false mentions = null } - fun findUrlInMessage(): String? { + open fun findUrlInMessage(): String? { return message.text.split('\n').firstNotNullOfOrNull { paragraph -> paragraph.split(' ').firstOrNull { word: String -> isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() @@ -178,11 +178,11 @@ class NewPostViewModel : ViewModel() { } } - fun removeFromReplyList(it: User) { + open fun removeFromReplyList(it: User) { mentions = mentions?.minus(it) } - fun updateMessage(it: TextFieldValue) { + open fun updateMessage(it: TextFieldValue) { message = it urlPreview = findUrlInMessage() @@ -197,7 +197,7 @@ class NewPostViewModel : ViewModel() { } } - fun autocompleteWithUser(item: User) { + open fun autocompleteWithUser(item: User) { userSuggestionAnchor?.let { val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") val lastWordStart = it.end - lastWord.length diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt new file mode 100644 index 000000000..1a0715ef2 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt @@ -0,0 +1,97 @@ +package com.vitorpamplona.amethyst.ui.buttons + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.NostrAccountDataSource +import com.vitorpamplona.amethyst.ui.actions.NewPollView +import com.vitorpamplona.amethyst.ui.actions.NewPostView + +@Composable +fun FabColumn(account: Account) { + var isOpen by remember { + mutableStateOf(false) + } + var wantsToPoll by remember { + mutableStateOf(false) + } + var wantsToPost by remember { + mutableStateOf(false) + } + + Column() { + if (isOpen) { + OutlinedButton( + onClick = { + wantsToPoll = true + isOpen = false + }, + modifier = Modifier.size(45.dp), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_poll), + null, + modifier = Modifier.size(26.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + OutlinedButton( + onClick = { + wantsToPost = true + isOpen = false + }, + modifier = Modifier.size(45.dp), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_lists), + null, + modifier = Modifier.size(26.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + } + OutlinedButton( + onClick = { isOpen = !isOpen }, + modifier = Modifier.size(55.dp), + shape = CircleShape, + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_compose), + null, + modifier = Modifier.size(26.dp), + tint = Color.White + ) + } + } + + if (wantsToPost) { + NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) + } + + if (wantsToPoll) { + NewPollView({ wantsToPoll = false }, account = account) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt deleted file mode 100644 index b16534b28..000000000 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.vitorpamplona.amethyst.buttons - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -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.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.actions.NewPostView - -@Composable -fun NewNoteButton(account: Account) { - var wantsToPost by remember { - mutableStateOf(false) - } - - if (wantsToPost) { - NewPostView({ wantsToPost = false }, account = account) - } - - OutlinedButton( - onClick = { wantsToPost = true }, - modifier = Modifier.size(55.dp), - shape = CircleShape, - colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary), - contentPadding = PaddingValues(0.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_compose), - null, - modifier = Modifier.size(26.dp), - tint = Color.White - ) - } -} diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt similarity index 91% rename from app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt rename to app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 920b0b077..5f4659d44 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslateableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -1,26 +1,26 @@ -package com.vitorpamplona.amethyst.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.navigation.NavController -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel - -@Composable -fun TranslateableRichTextViewer( - content: String, - canPreview: Boolean, - modifier: Modifier = Modifier, - tags: List>?, - backgroundColor: Color, - accountViewModel: AccountViewModel, - navController: NavController -) = ExpandableRichTextViewer( - content, - canPreview, - modifier, - tags, - backgroundColor, - accountViewModel, - navController -) +package com.vitorpamplona.amethyst.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun TranslatableRichTextViewer( + content: String, + canPreview: Boolean, + modifier: Modifier = Modifier, + tags: List>?, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) = ExpandableRichTextViewer( + content, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + navController +) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt index ffed429cc..f44b43bb4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt @@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent object GlobalFeedFilter : FeedFilter() { @@ -17,7 +18,7 @@ object GlobalFeedFilter : FeedFilter() { val notes = LocalCache.notes.values .asSequence() .filter { - (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is ChannelMessageEvent) && + (it.event is TextNoteEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent) && it.replyTo.isNullOrEmpty() } .filter { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt index 50385acfd..78df542bc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt @@ -3,6 +3,8 @@ package com.vitorpamplona.amethyst.ui.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.model.PollNoteEvent +import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent object HomeConversationsFeedFilter : FeedFilter() { @@ -15,7 +17,7 @@ object HomeConversationsFeedFilter : FeedFilter() { return LocalCache.notes.values .filter { - (it.event is TextNoteEvent) && + (it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is RepostEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it) } ?: true && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt index ca88aaa96..f452a5c17 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt @@ -4,6 +4,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent @@ -17,7 +18,7 @@ object HomeNewThreadFeedFilter : FeedFilter() { val notes = LocalCache.notes.values .filter { it -> - (it.event is TextNoteEvent || it.event is RepostEvent) && + (it.event is TextNoteEvent || it.event is RepostEvent || it.event is PollNoteEvent) && (it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) && // && account.isAcceptable(it) // This filter follows only. No need to check if acceptable it.author?.let { !account.isHidden(it) } ?: true && diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt index 39dcc920e..2b01244b0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/NotificationFeedFilter.kt @@ -26,6 +26,11 @@ object NotificationFeedFilter : FeedFilter() { it.replyTo?.any { it.author == loggedInUser } == true || loggedInUser in it.directlyCiteUsers() } + .filter { it -> + it.event !is PollNoteEvent || + it.replyTo?.any { it.author == account.userProfile() } == true || + account.userProfile() in it.directlyCiteUsers() + } .filter { it.event !is ReactionEvent || it.replyTo?.lastOrNull()?.author == loggedInUser || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 701b2f50b..1f329f01d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -63,12 +63,8 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status -import com.vitorpamplona.amethyst.ui.components.ResizeImage -import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage -import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy -import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer +import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog @@ -490,7 +486,7 @@ fun NoteComposeInner( ) { val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) } - TranslateableRichTextViewer( + TranslatableRichTextViewer( stringResource( id = R.string.private_conversation_notification, "@${note.author?.pubkeyNpub()}", @@ -528,7 +524,7 @@ fun NoteComposeInner( overflow = TextOverflow.Ellipsis ) } else { - TranslateableRichTextViewer( + TranslatableRichTextViewer( eventContent, canPreview = canPreview && !makeItShort, Modifier.fillMaxWidth(), @@ -540,6 +536,27 @@ fun NoteComposeInner( DisplayUncitedHashtags(noteEvent, eventContent, navController) } +/* + TranslateableRichTextViewer( + eventContent, + canPreview = canPreview && !makeItShort, + Modifier.fillMaxWidth(), + noteEvent.tags(), + backgroundColor, + accountViewModel, + navController + ) +*/ + + if (noteEvent is PollNoteEvent) { + PollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + navController + ) + } } if (!makeItShort) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt new file mode 100644 index 000000000..640fecffb --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -0,0 +1,341 @@ +package com.vitorpamplona.amethyst.ui.note + +import android.widget.Toast +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.NavController +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.* + +@Composable +fun PollNote( + note: Note, + canPreview: Boolean, + backgroundColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val pollViewModel = PollNoteViewModel() + pollViewModel.load(note) + + pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> + val optionTally = pollViewModel.optionVoteTally(poll_op.key) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + TranslatableRichTextViewer( + poll_op.value, + canPreview, + modifier = Modifier + .width(250.dp) + .padding(0.dp) // padding outside border + .border(BorderStroke(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.32f))) + .padding(4.dp), // padding between border and text + pollViewModel.pollEvent?.tags(), + backgroundColor, + accountViewModel, + navController + ) + + ZapVote(note, accountViewModel, pollViewModel, poll_op.key) + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + // only show tallies after user has zapped note + if (note.isZappedBy(accountViewModel.userProfile())) { + LinearProgressIndicator( + modifier = Modifier.width(250.dp), + color = if ( + pollViewModel.consensusThreshold != null && + optionTally >= pollViewModel.consensusThreshold!! + ) { + Color.Green + } else { MaterialTheme.colors.primary }, + progress = optionTally + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ZapVote( + baseNote: Note, + accountViewModel: AccountViewModel, + pollViewModel: PollNoteViewModel, + pollOption: Int, + modifier: Modifier = Modifier +) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val zapsState by baseNote.live().zaps.observeAsState() + val zappedNote = zapsState?.note + + var wantsToZap by remember { mutableStateOf(false) } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var zappingProgress by remember { mutableStateOf(0f) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .then(Modifier.size(20.dp)) + .combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp), + onClick = { + if (!accountViewModel.isWriteable()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (pollViewModel.isPollClosed) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.poll_is_closed), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (pollViewModel.isVoteAmountAtomic()) { + // only allow one vote per option when min==max, i.e. atomic vote amount specified + if (pollViewModel.isPollOptionZappedBy(pollOption, account.userProfile())) { + scope.launch { + Toast + .makeText(context, R.string.one_vote_per_user_on_atomic_votes, Toast.LENGTH_SHORT) + .show() + } + return@combinedClickable + } + accountViewModel.zap( + baseNote, + pollViewModel.valueMaximum!!.toLong() * 1000, + pollOption, + "", + context, + onError = { + scope.launch { + zappingProgress = 0f + Toast + .makeText(context, it, Toast.LENGTH_SHORT) + .show() + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { + zappingProgress = it + } + } + ) + } else { + wantsToZap = true + } + }, + onLongClick = {} + ) + ) { + if (wantsToZap) { + ZapVoteAmountChoicePopup( + baseNote, + accountViewModel, + pollViewModel, + pollOption, + onDismiss = { + wantsToZap = false + }, + onError = { + scope.launch { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { + zappingProgress = it + } + } + ) + } + + if (pollViewModel.isPollOptionZappedBy(pollOption, account.userProfile())) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange + ) + } else { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = stringResource(id = R.string.zaps), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + + // only show tallies after a user has zapped note + if (zappedNote?.isZappedBy(account.userProfile()) == true) { + Text( + showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = modifier + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ZapVoteAmountChoicePopup( + baseNote: Note, + accountViewModel: AccountViewModel, + pollViewModel: PollNoteViewModel, + pollOption: Int, + onDismiss: () -> Unit, + onError: (text: String) -> Unit, + onProgress: (percent: Float) -> Unit +) { + val context = LocalContext.current + + var inputAmountText by rememberSaveable { mutableStateOf("") } + + val colorInValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.error, + unfocusedBorderColor = Color.Red + ) + val colorValid = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = MaterialTheme.colors.primary, + unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + Dialog( + onDismissRequest = { onDismiss() }, + properties = DialogProperties( + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + Surface { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(10.dp) + ) { + val amount = pollViewModel.inputVoteAmountLong(inputAmountText) + + OutlinedTextField( + value = inputAmountText, + onValueChange = { inputAmountText = it }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(150.dp), + colors = if (pollViewModel.isValidInputVoteAmount(amount)) colorValid else colorInValid, + label = { + Text( + text = stringResource(R.string.poll_zap_amount), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + placeholder = { + Text( + text = pollViewModel.voteAmountPlaceHolderText(context.getString(R.string.sats)), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + ) + + val isValidInputAmount = pollViewModel.isValidInputVoteAmount(amount) + Button( + modifier = Modifier.padding(horizontal = 3.dp), + enabled = isValidInputAmount, + onClick = { + if (amount != null && isValidInputAmount) { + accountViewModel.zap( + baseNote, + amount * 1000, + pollOption, + "", + context, + onError, + onProgress + ) + onDismiss() + } + }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text( + "⚡ ${showAmount(amount?.toBigDecimal()?.setScale(1))}", + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.combinedClickable( + onClick = { + if (amount != null && isValidInputAmount) { + accountViewModel.zap( + baseNote, + amount * 1000, + pollOption, + "", + context, + onError, + onProgress + ) + onDismiss() + } + }, + onLongClick = {} + ) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt new file mode 100644 index 000000000..77080d3e0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -0,0 +1,119 @@ +package com.vitorpamplona.amethyst.ui.note + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.* +import java.math.BigDecimal +import java.util.* + +class PollNoteViewModel { + var account: Account? = null + private var pollNote: Note? = null + + var pollEvent: PollNoteEvent? = null + private var pollOptions: Map? = null + var valueMaximum: Int? = null + private var valueMinimum: Int? = null + private var closedAt: Int? = null + var consensusThreshold: Float? = null + + fun load(note: Note?) { + pollNote = note + pollEvent = pollNote?.event as PollNoteEvent + pollOptions = pollEvent?.pollOptions() + valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM) + valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM) + consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100) + closedAt = pollEvent?.getTagInt(CLOSED_AT) + } + + fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum + + val isPollClosed: Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate + pollNote?.createdAt()?.plus(it * (86400 + 120))!! > Date().time / 1000 + } == true + + fun voteAmountPlaceHolderText(sats: String): String = if (valueMinimum == null && valueMaximum == null) { + sats + } else if (valueMinimum == null) { + "1—$valueMaximum $sats" + } else if (valueMaximum == null) { + ">$valueMinimum $sats" + } else { + "$valueMinimum—$valueMaximum $sats" + } + + fun inputVoteAmountLong(textAmount: String) = if (textAmount.isEmpty()) { null } else { + try { + textAmount.toLong() + } catch (e: Exception) { null } + } + + fun isValidInputVoteAmount(amount: Long?): Boolean { + if (amount == null) { + return false + } else if (valueMinimum == null && valueMaximum == null) { + if (amount > 0) { + return true + } + } else if (valueMinimum == null) { + if (amount > 0 && amount <= valueMaximum!!) { + return true + } + } else if (valueMaximum == null) { + if (amount >= valueMinimum!!) { + return true + } + } else { + if ((valueMinimum!! <= amount) && (amount <= valueMaximum!!)) { + return true + } + } + return false + } + + fun optionVoteTally(op: Int): Float { + val tally = zappedPollOptionAmount(op).toFloat().div(zappedVoteTotal()) + return if (tally.isNaN()) { // catch div by 0 + 0f + } else { tally } + } + + private fun zappedVoteTotal(): Float { + var total = 0f + pollOptions?.keys?.forEach { + total += zappedPollOptionAmount(it).toFloat() + } + return total + } + + fun isPollOptionZappedBy(option: Int, user: User): Boolean { + if (pollNote?.zaps?.any { it.key.author == user } == true) { + pollNote!!.zaps.mapNotNull { it.value?.event } + .filterIsInstance() + .map { + val zappedOption = it.zappedPollOption() + if (zappedOption == option) { + return true + } + } + } + return false + } + + fun zappedPollOptionAmount(option: Int): BigDecimal { + return if (pollNote != null) { + pollNote!!.zaps.mapNotNull { it.value?.event } + .filterIsInstance() + .mapNotNull { + val zappedOption = it.zappedPollOption() + if (zappedOption == option) { + it.amount + } else { null } + }.sumOf { it } + } else { + BigDecimal(0) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index b9b6be39b..83e6599a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -347,6 +347,7 @@ fun ZapReaction( accountViewModel.zap( baseNote, account.zapAmountChoices.first() * 1000, + null, "", context, onError = { @@ -547,6 +548,7 @@ fun ZapAmountChoicePopup( accountViewModel.zap( baseNote, amountInSats * 1000, + null, "", context, onError, @@ -571,6 +573,7 @@ fun ZapAmountChoicePopup( accountViewModel.zap( baseNote, amountInSats * 1000, + null, "", context, onError, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index b5e05d6f9..73bdf39b5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -21,6 +21,7 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.SnackbarDefaults.backgroundColor import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert @@ -53,8 +54,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent +import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status -import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.note.* import com.vitorpamplona.amethyst.ui.note.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.DisplayFollowingHashtagsInPost @@ -344,7 +347,7 @@ fun NoteMaster( !noteForReports.hasAnyReports() if (eventContent != null) { - TranslateableRichTextViewer( + TranslatableRichTextViewer( eventContent, canPreview, Modifier.fillMaxWidth(), @@ -355,6 +358,16 @@ fun NoteMaster( ) DisplayUncitedHashtags(noteEvent, eventContent, navController) + + if (noteEvent is PollNoteEvent) { + PollNote( + note, + canPreview, + backgroundColor, + accountViewModel, + navController + ) + } } ReactionsRow(note, accountViewModel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 30ebf2b30..a82deb3bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -52,7 +52,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { account.delete(account.boostsTo(note)) } - suspend fun zap(note: Note, amount: Long, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) { + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) { val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() if (lud16.isNullOrBlank()) { @@ -60,7 +60,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { return } - val zapRequest = account.createZapRequestFor(note) + val zapRequest = account.createZapRequestFor(note, pollOption) onProgress(0.10f) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index e2e22b469..df2c14bc5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.* import androidx.compose.material.DrawerValue import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme @@ -22,7 +23,8 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.buttons.NewChannelButton -import com.vitorpamplona.amethyst.buttons.NewNoteButton +import com.vitorpamplona.amethyst.ui.buttons.FabColumn +import com.vitorpamplona.amethyst.ui.navigation.* import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.AppNavigation @@ -64,7 +66,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun DrawerContent(navController, scaffoldState, sheetState, accountViewModel) }, floatingActionButton = { - FloatingButton(navController, accountStateViewModel) + FloatingButtons(navController, accountStateViewModel) }, scaffoldState = scaffoldState ) { @@ -76,7 +78,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun } @Composable -fun FloatingButton(navController: NavHostController, accountViewModel: AccountStateViewModel) { +fun FloatingButtons(navController: NavHostController, accountViewModel: AccountStateViewModel) { val accountState by accountViewModel.accountContent.collectAsState() if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { @@ -89,7 +91,7 @@ fun FloatingButton(navController: NavHostController, accountViewModel: AccountSt // Does nothing. } is AccountState.LoggedIn -> { - NewNoteButton(state.account) + FabColumn(state.account) } } } diff --git a/app/src/main/res/drawable-hdpi/ic_poll.png b/app/src/main/res/drawable-hdpi/ic_poll.png new file mode 100644 index 000000000..560b632f2 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_poll.png b/app/src/main/res/drawable-mdpi/ic_poll.png new file mode 100644 index 000000000..6a27ba8a7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_poll.png b/app/src/main/res/drawable-xhdpi/ic_poll.png new file mode 100644 index 000000000..02383e0b9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_poll.png b/app/src/main/res/drawable-xxhdpi/ic_poll.png new file mode 100644 index 000000000..3dae6049d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_poll.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_poll.png b/app/src/main/res/drawable-xxxhdpi/ic_poll.png new file mode 100644 index 000000000..b0a5d1034 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_poll.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a330edca..e3b99fe47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,6 +267,22 @@ nsec / hex private key Pledge Amount in Sats + Post Poll + Required fields: + Zap recipients + Primary poll description… + Option %s + Poll option description + Optional fields: + Zap minimum + Zap maximum + Consensus + (0–100)% + Close after + days + Poll is closed to new votes + Zap amount + Only one vote per user is allowed on this type of poll "Looking for Event %1$s"