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
@@ -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
}