feat: find collaboration acceptances by their a tag, and apply late captions

Two gaps found reviewing this branch against what divine.video actually
publishes, plus the desktop half of the poll-builder change.

Collaboration acceptances (kind 34238) were looked up by a coordinate we
predicted — `34238:<collaborator>:<video coordinate>`. divine-mobile does key
them that way, but divine-web signs its acceptance with
`['d', crypto.randomUUID()]` (divine-web/src/hooks/useApproveCollab.ts:24), so
no coordinate a reader can construct will ever name it. The lookup now filters
on `kinds:[34238] + authors + #a:[video coord]`, which is the same filter
divine-web itself uses (src/hooks/useVideoCollaboratorStatus.ts) and matches
both publishers. Kind 34238 joins the `a`-tag engagement filter every rendered
addressable note already opens, so the relay side costs no extra REQ, and the
credit's check mark now reads LocalCache instead of opening a subscription per
credit — strictly fewer REQs than before.

Captions that resolve after the first frame never reached the player. A
`text-track` naming a kind-39307 coordinate rebuilds the MediaItem with the same
videoUri once that event arrives, and the warm-pool fast path reads the
unchanged mediaId as "already loaded". A MediaController doesn't carry the
item's localConfiguration across the service IPC, so the subtitles can't be read
back off it to compare; GetVideoController remembers the fingerprint it last
pushed instead, and re-applies at the current position only when it was the one
that loaded the item — a warm player carrying another mount's item is left
alone rather than rebuffered.

desktopApp's voteOnPoll still called PollResponseEvent.build with a Set. It now
dispatches on pollType() the way Account.pollRespond does, so a single-choice
vote writes one response tag rather than leaving the recorded answer to
iteration order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLEq2G6U29mKV8cfSTamZ9
This commit is contained in:
Claude
2026-09-22 00:06:36 +00:00
parent a6a063927f
commit e4f4808c92
18 changed files with 435 additions and 43 deletions
@@ -316,6 +316,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.withoutClientTag
@@ -1209,6 +1210,12 @@ class Account(
}
}
suspend fun changeCaptionsEnabled(enabled: Boolean) {
if (settings.changeCaptionsEnabled(enabled)) {
sendNewAppSpecificData()
}
}
suspend fun changeVideoPlayerButtonItems(items: List<VideoPlayerButtonItem>) {
if (settings.changeVideoPlayerButtonItems(items)) {
sendNewAppSpecificData()
@@ -2309,7 +2316,15 @@ class Account(
val poll = cache.getOrCreateNote(event.id).toEventHint<PollEvent>()
if (poll != null) {
val template = PollResponseEvent.build(poll, responses)
// The poll decides how its answers are counted, so it decides which builder applies:
// a single-choice answer is the first tag only, and sending several would record
// whichever one the reader saw first.
val template =
when (event.pollType()) {
PollType.SINGLE_CHOICE ->
responses.firstOrNull()?.let { PollResponseEvent.buildSingleChoice(poll, it) } ?: return
PollType.MULTI_CHOICE -> PollResponseEvent.buildMultipleChoice(poll, responses)
}
val signedEvent = signer.sign(template)
@@ -410,6 +410,15 @@ class AccountSettings(
return false
}
fun changeCaptionsEnabled(enabled: Boolean): Boolean {
if (syncedSettings.videoPlayer.captionsEnabled.value != enabled) {
syncedSettings.videoPlayer.captionsEnabled.tryEmit(enabled)
saveAccountSettings()
return true
}
return false
}
fun changeAudioVisualizer(style: VisualizerStyle): Boolean {
if (syncedSettings.media.audioVisualizer.value != style) {
syncedSettings.media.audioVisualizer.tryEmit(style)
@@ -70,6 +70,7 @@ class AccountSyncedSettings(
val videoPlayer =
AccountVideoPlayerPreferences(
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
MutableStateFlow(internalSettings.videoPlayer.captionsEnabled),
)
val media =
AccountMediaPreferences(
@@ -118,7 +119,7 @@ class AccountSyncedSettings(
security.sendKind0EventsToLocalRelay.value,
security.addClientTag.value,
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value, videoPlayer.captionsEnabled.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats =
AccountChatPreferencesInternal(
@@ -208,6 +209,10 @@ class AccountSyncedSettings(
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
}
if (videoPlayer.captionsEnabled.value != syncedSettingsInternal.videoPlayer.captionsEnabled) {
videoPlayer.captionsEnabled.tryEmit(syncedSettingsInternal.videoPlayer.captionsEnabled)
}
val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer)
if (media.audioVisualizer.value != newAudioVisualizer) {
media.audioVisualizer.tryEmit(newAudioVisualizer)
@@ -254,6 +259,7 @@ class AccountReactionPreferences(
@Stable
class AccountVideoPlayerPreferences(
var buttonItems: MutableStateFlow<ImmutableList<VideoPlayerButtonItem>>,
val captionsEnabled: MutableStateFlow<Boolean>,
)
@Stable
@@ -186,6 +186,10 @@ class AccountNavigationPreferencesInternal(
@Serializable
class AccountVideoPlayerPreferencesInternal(
var buttonItems: List<VideoPlayerButtonItem> = DefaultVideoPlayerButtonItems,
// Feed videos autoplay muted, so captions are what make them legible — on by default, and
// remembered once the viewer says otherwise. Defaulted rather than required so a blob written
// by a client that predates the field still decodes.
var captionsEnabled: Boolean = true,
)
@Serializable
@@ -70,6 +70,16 @@ fun GetVideoController(
// warm-pool fast path keeps position and buffered data intact.
val keepAlive = remember { mutableStateOf(true) }
// Captions can resolve *after* playback starts: a `text-track` tag naming a kind-39307
// coordinate is only turned into a SubtitleConfiguration once that event arrives, which
// rebuilds `mediaItem` with the same videoUri and a different subtitle list. The mediaId is
// the video URI either way, so the warm-pool fast path below would read "already loaded" and
// the new tracks would never reach the player. A MediaController doesn't carry the item's
// `localConfiguration` across the service IPC, so the subtitles can't be read back off the
// controller to compare — we remember what we last pushed instead. Deliberately keyless so it
// survives the keyed remember below being rebuilt by the very change it has to detect.
val lastAppliedCaptions = remember { mutableStateOf<Int?>(null) }
val controllerState by remember(mediaItem, keepAlive.value) {
if (keepAlive.value) {
PlaybackServiceClient
@@ -118,6 +128,7 @@ fun GetVideoController(
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val captionsFingerprint = mediaItem.src.captions.hashCode()
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
// Cold load: a fresh decoder/codec instance gets allocated here. If a
@@ -127,6 +138,20 @@ fun GetVideoController(
Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" }
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (lastAppliedCaptions.value != null && lastAppliedCaptions.value != captionsFingerprint) {
// Same video, new caption set, and this composable is the one that loaded
// it: re-set the item so the side-loaded subtitle sources get merged in.
// Restarting from the current position keeps the rebuffer down to whatever
// the cache can't serve. The `!= null` guard is what keeps a warm player
// that arrived already carrying someone else's item from being re-prepared
// on a fingerprint we never pushed — that's the rebuffer the pool exists
// to avoid, and its captions are whatever that mount applied.
val resumeAt = state.controller.currentPosition
Log.d("PlaybackService") { "Captions changed for $targetMediaId - re-applying at $resumeAt" }
state.controller.setMediaItem(mediaItem.item, resumeAt)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
@@ -0,0 +1,157 @@
/*
* 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.service.playback.composable.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Locale
/**
* The text tracks a player has, in the order the media declared them.
*
* A NIP-71 video can carry a `text-track` per language, so "captions" is not one thing to switch
* on — it is a list to choose from. Groups the player reports as unsupported are dropped: offering
* a language the decoder will refuse is worse than not offering it.
*/
@OptIn(UnstableApi::class)
internal fun getTextTrackChoices(tracks: Tracks): ImmutableList<CaptionChoice> {
val choices = mutableListOf<CaptionChoice>()
tracks.groups
.filter { it.type == C.TRACK_TYPE_TEXT }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
choices.add(
CaptionChoice(
group = group,
trackIndex = i,
language = format.language,
label = format.label,
isSelected = group.isTrackSelected(i),
),
)
}
}
return choices.toImmutableList()
}
internal data class CaptionChoice(
val group: Tracks.Group,
val trackIndex: Int,
val language: String?,
val label: String?,
val isSelected: Boolean,
) {
/**
* What to print for this track.
*
* The publisher's own label wins when there is one. Otherwise the language code is expanded
* into the reader's locale — "en" is not a word to most people, "English" is — and only a
* track that names neither falls back to a generic term.
*/
fun displayName(fallback: String): String =
label?.takeIf { it.isNotBlank() }
?: language?.takeIf { it.isNotBlank() }?.let { Locale.forLanguageTag(it).displayLanguage.ifBlank { it } }
?: fallback
}
@OptIn(UnstableApi::class)
internal fun selectTextTrack(
player: Player,
choice: CaptionChoice,
) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
// Clearing the type disable as well: picking a language is a request to see captions,
// so it has to undo an earlier "off" rather than silently select a track nothing draws.
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(TrackSelectionOverride(choice.group.mediaTrackGroup, choice.trackIndex))
.build()
}
/**
* The language menu, shown in place of a plain on/off toggle when a video offers more than one
* caption track.
*/
@Composable
internal fun CaptionLanguagePopup(
choices: ImmutableList<CaptionChoice>,
captionsEnabled: Boolean,
onSelectOff: () -> Unit,
onSelectTrack: (CaptionChoice) -> Unit,
onDismiss: () -> Unit,
) {
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
val baseColors = ButtonDefaults.textButtonColors()
val contentColor = MaterialTheme.colorScheme.onBackground
val colors = remember(baseColors, contentColor) { baseColors.copy(contentColor = contentColor) }
val unnamed = stringRes(R.string.captions_unnamed_track)
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextButton(colors = colors, onClick = onSelectOff) {
Text(
stringRes(R.string.captions_off),
fontWeight = if (!captionsEnabled) FontWeight(1000) else FontWeight(400),
)
}
choices.forEach { choice ->
TextButton(colors = colors, onClick = { onSelectTrack(choice) }) {
Text(
choice.displayName(unnamed),
fontWeight = if (captionsEnabled && choice.isSelected) FontWeight(1000) else FontWeight(400),
)
}
}
}
}
}
@@ -147,25 +147,39 @@ fun RenderTopButtons(
val hasMultipleQualities = videoGroup != null && videoGroup.length > 1
// Captions are side-loaded from the event's `text-track` tags (MediaItemCache flags the first
// one default), so the button reflects whether the player currently has the text track on.
// Toggling disables the whole track type rather than deselecting one group: with a single
// side-loaded track those are the same thing, and the type-level switch survives the player
// picking a different track later.
//
// The choice lives on the player instance, so it holds for as long as that player does —
// including the next video the warm pool hands it to — and resets when it is released. Making
// it stick across sessions would mean another synced setting; this is the scope of a button.
var captionsEnabled by remember(player) { mutableStateOf(!player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)) }
// one default). The account setting is the source of truth, not the player: a player comes
// out of the warm pool carrying whatever track selection the previous video left on it, so
// reading the preference off the instance would make the button's state depend on which
// player this video happened to be handed.
val captionsEnabled by accountViewModel.captionsEnabledFlow().collectAsStateWithLifecycle()
// Push the preference onto whichever player is attached, and re-push when either changes.
// Disabling the whole track type rather than deselecting one group: the type-level switch
// survives the player later picking a different track.
LaunchedEffect(player, captionsEnabled) {
val alreadyDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)
if (alreadyDisabled == captionsEnabled) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !captionsEnabled)
.build()
}
}
// A video with one track has nothing to choose between, so the button stays a toggle. Only a
// multi-language video opens a menu — paying a popup for a single "English" row would be a
// worse answer to the same tap.
val captionChoices = remember(tracks) { getTextTrackChoices(tracks) }
val captionsPopupOpen = remember { mutableStateOf(false) }
val onCaptionsClick =
remember(player) {
remember(captionsEnabled, captionChoices) {
{
val enabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !enabled)
.build()
captionsEnabled = enabled
if (captionChoices.size > 1) {
captionsPopupOpen.value = true
} else {
accountViewModel.setCaptionsEnabled(!captionsEnabled)
}
}
}
@@ -242,6 +256,25 @@ fun RenderTopButtons(
accountViewModel = accountViewModel,
)
if (captionsPopupOpen.value) {
CaptionLanguagePopup(
choices = captionChoices,
captionsEnabled = captionsEnabled,
onSelectOff = {
accountViewModel.setCaptionsEnabled(false)
captionsPopupOpen.value = false
},
onSelectTrack = { choice ->
// The override goes straight onto the player (it is per-video), while the
// preference is what persists the fact that captions are wanted at all.
selectTextTrack(player, choice)
accountViewModel.setCaptionsEnabled(true)
captionsPopupOpen.value = false
},
onDismiss = { captionsPopupOpen.value = false },
)
}
if (overflowQualityOpen.value && videoGroup != null) {
VideoQualityPopup(
player = player,
@@ -105,11 +105,10 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
.setUri(key.videoUri)
.apply { normalizedMime?.let { setMimeType(it) } }
.setSubtitleConfigurations(
// Feed videos autoplay muted, so a caption track is what makes them
// legible — flag the first one default and let ExoPlayer select it, and
// RenderCaptions draws the cues. There is no way to turn them off yet:
// the player's button row is a user-configured set, so a CC toggle needs
// to be added there rather than hard-coded into the overlay.
// Flagging the first track default is what decides WHICH track plays
// once text is enabled. WHETHER it is enabled is the account's
// captionsEnabled preference, which RenderTopButtons pushes onto the
// player as a track-type switch — the two are independent.
key.captions.mapIndexed { index, track ->
track.toSubtitleConfiguration(isDefault = index == 0)
},
@@ -34,11 +34,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.credits.CreditTarget
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit
@@ -108,9 +109,9 @@ private fun RenderVideoCredit(
name = "@" + it.toBestDisplayName(),
confirmed = {
// Only a role-marked credit is an invite someone can accept, and each
// lookup costs a LocalCache note plus a relay REQ per credit per
// rendered card. Asking for every `mention` and `inspired-by` would
// spend a subscription per feed item on an event that cannot exist.
// lookup registers a cache observer per credit per rendered card.
// Watching every `mention` and `inspired-by` too would spend one per
// feed item on an event that cannot exist.
if (credit.label.isCollaborationRole()) {
AcceptedCollaboration(target.pubKey, videoAddress, accountViewModel)
}
@@ -141,11 +142,15 @@ private fun RenderVideoCredit(
/**
* Draws a check when [pubKey] has published an accepted [VideoCollaborationEvent] for this video.
*
* The response is addressed by its own coordinate — `34238:<collaborator>:<video coordinate>` —
* so it can be asked for directly instead of scanning every 34238 on the network for one that
* `a`-tags this video. A publisher that keys the response by something else (divine-web uses a
* random `d`) simply never resolves here, and the credit renders without the check rather than
* with a wrong one.
* The response is matched by what both shapes in the wild agree on — the `a` tag naming the video
* — rather than by the response's own coordinate. divine-mobile keys the event by the video
* coordinate (so `34238:<collaborator>:<video coordinate>` would resolve it) but divine-web writes
* a random `d`, and asking for a coordinate nobody can predict found nothing for the client that
* publishes most of these. The relay side is covered by the `a` engagement filter every rendered
* addressable note already opens (see PostsAndChatMessagesToAddresses), so this reads the cache
* and costs no subscription of its own.
*
* Newest-first, so a collaborator who accepted and later withdrew reads as withdrawn.
*/
@Composable
private fun AcceptedCollaboration(
@@ -155,12 +160,19 @@ private fun AcceptedCollaboration(
) {
if (videoAddress == null) return
val responseAddress =
val responseFlow =
remember(pubKey, videoAddress) {
Address(VideoCollaborationEvent.KIND, pubKey, videoAddress.toValue())
accountViewModel.account.cache.observeLatestEvent<VideoCollaborationEvent>(
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(pubKey),
tags = mapOf("a" to listOf(videoAddress.toValue())),
limit = 1,
),
)
}
val note = remember(responseAddress) { accountViewModel.getOrCreateAddressableNote(responseAddress) }
val response by observeNoteEvent<VideoCollaborationEvent>(note, accountViewModel)
val response by responseFlow.collectAsStateWithLifecycle(null)
if (response?.isAccepted() == true) {
Icon(
@@ -2012,6 +2012,12 @@ class AccountViewModel(
fun videoPlayerButtonItemsFlow() = account.settings.syncedSettings.videoPlayer.buttonItems
fun captionsEnabledFlow() = account.settings.syncedSettings.videoPlayer.captionsEnabled
fun setCaptionsEnabled(enabled: Boolean) {
viewModelScope.launch { account.changeCaptionsEnabled(enabled) }
}
fun changeVideoPlayerButtonItems(items: List<com.vitorpamplona.amethyst.model.VideoPlayerButtonItem>) =
launchSigner {
account.changeVideoPlayerButtonItems(items)
+2
View File
@@ -2789,6 +2789,8 @@
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">My Fitness</string>
<string name="captions_off">Off</string>
<string name="captions_unnamed_track">Captions</string>
<string name="captions_turn_on">Turn on captions</string>
<string name="captions_turn_off">Turn off captions</string>
<string name="kind_video_list">Video List</string>
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -67,6 +68,10 @@ val PostsAndChatMessagesToAddresses =
listOf(
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
// A collaborator's answer to being credited on a NIP-71 video. Only the `a` tag is
// common to both shapes in the wild — divine-web keys the event by a random `d`, so
// there is no coordinate to ask for and this `a` filter is the only way to see it.
VideoCollaborationEvent.KIND,
)
/**
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.nip71Video.credits.CreditTarget
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -291,11 +293,54 @@ class DivineVideoInteropTest {
)
assertEquals("Collaborator", response.role())
assertTrue(response.isAccepted())
// Keyed by the video coordinate, so the credited person's answer can be addressed directly
// instead of scanning every 34238 for one that points back here.
// divine-mobile happens to key this one by the video coordinate, but the `a` tag is what
// the lookup matches on — see [bothCollaborationShapesAreFoundByTheirATag].
assertEquals(response.video()?.toValue(), response.dTag())
}
@Test
fun bothCollaborationShapesAreFoundByTheirATag() {
// The `d` tag is the one thing the two publishers disagree on: divine-mobile writes the
// video coordinate, divine-web writes `crypto.randomUUID()`
// (divine-web/src/hooks/useApproveCollab.ts). Matching on `a` + author is what makes the
// credit's check mark appear for both — and is the same filter divine-web itself uses in
// useVideoCollaboratorStatus.ts.
val mobile = Event.fromJson(collabResponse) as VideoCollaborationEvent
val coord = mobile.video()!!.toValue()
val web =
VideoCollaborationEvent(
id = "a".repeat(64),
pubKey = mobile.pubKey,
createdAt = mobile.createdAt,
tags = arrayOf(arrayOf("a", coord), arrayOf("d", "0e1cbb2c-1b5a-4f1f-9f0e-2c6b2c8a9f11")),
content = "",
sig = "",
)
val byATag =
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(mobile.pubKey),
tags = mapOf("a" to listOf(coord)),
)
assertTrue(byATag.match(mobile))
assertTrue(byATag.match(web))
// What the coordinate-addressed lookup used to do: it can only ever name one `d`, so the
// other publisher's answer was invisible however long you waited for it.
val byCoordinate =
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(mobile.pubKey),
tags = mapOf("d" to listOf(coord)),
)
assertTrue(byCoordinate.match(mobile))
assertFalse(byCoordinate.match(web))
}
@Test
fun aResponseWithoutAStatusStillCounts() {
// divine-web publishes no `status` — it only emits the event on approval at all.
@@ -104,6 +104,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -1417,7 +1418,16 @@ suspend fun voteOnPoll(
) {
if (responses.isEmpty()) return
withContext(Dispatchers.IO) {
val template = PollResponseEvent.build(EventHintBundle(poll), responses)
// The poll decides how its answers are counted, so it decides which builder applies:
// a single-choice answer is the first tag only, and sending several would record
// whichever one the reader saw first.
val hint = EventHintBundle(poll)
val template =
when (poll.pollType()) {
PollType.SINGLE_CHOICE ->
responses.firstOrNull()?.let { PollResponseEvent.buildSingleChoice(hint, it) } ?: return@withContext
PollType.MULTI_CHOICE -> PollResponseEvent.buildMultipleChoice(hint, responses)
}
val signed = account.signer.sign(template)
localCache.consume(signed, null, wasVerified = true)
// Publish to the poll's OWN declared relays (NIP-88 `relay` tags) as well as our
@@ -61,11 +61,48 @@ class PollResponseEvent(
companion object {
const val KIND = 1018
fun build(
/**
* The answer to a single-choice poll.
*
* NIP-88: "polltype: singlechoice: The first response tag is to be considered the actual
* response." One code in, one `response` tag out — there is no way to express an answer
* whose meaning depends on which tag the reader happens to look at first.
*/
fun buildSingleChoice(
poll: EventHintBundle<PollEvent>,
response: String,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = build(poll, listOf(response), createdAt, initializer)
/**
* The answer to a multiple-choice poll.
*
* NIP-88: "the first response tag pointing to each id is considered the actual response,
* without considering the order of the response tags" — so a Set is exactly the right
* shape here, and the conversion to a list below is only about writing them down.
*/
fun buildMultipleChoice(
poll: EventHintBundle<PollEvent>,
responses: Set<String>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = build(poll, responses.toList(), createdAt, initializer)
/**
* Writes [responses] as `response` tags in the order given.
*
* Prefer [buildSingleChoice] / [buildMultipleChoice]: the poll type decides whether order
* matters, and this overload cannot know which one it is being used for. It takes a List
* rather than a Set so that a caller who does know keeps control of the order — passing an
* unordered Set here would hand a single-choice answer to whatever iteration order the
* collection happened to have.
*/
fun build(
poll: EventHintBundle<PollEvent>,
responses: List<String>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<PollResponseEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
poll(poll)
notifyAuthor(poll)
@@ -29,6 +29,6 @@ import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag
fun TagArrayBuilder<PollResponseEvent>.poll(pollHint: EventHintBundle<PollEvent>) = addUnique(PollTag.assemble(pollHint))
fun TagArrayBuilder<PollResponseEvent>.responses(responses: Set<String>) = addAll(ResponseTag.assemble(responses))
fun TagArrayBuilder<PollResponseEvent>.responses(responses: List<String>) = addAll(ResponseTag.assemble(responses))
fun TagArrayBuilder<PollResponseEvent>.notifyAuthor(pollHint: EventHintBundle<PollEvent>) = add(pollHint.toPTag().toTagArray())
@@ -38,6 +38,9 @@ class ResponseTag {
fun assemble(code: String) = arrayOf(TAG_NAME, code)
fun assemble(responses: Set<String>) = responses.map { code -> assemble(code) }
// A List, not a Set: NIP-88 reads a single-choice answer as "the first response tag",
// so the order these are written in is load-bearing and an unordered Set would leave the
// recorded answer to iteration order.
fun assemble(responses: List<String>) = responses.map { code -> assemble(code) }
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip88Polls
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.endsAt
@@ -175,6 +176,29 @@ class Nip88PollTagsTest {
)
}
@Test
fun aSingleChoiceAnswerCanOnlyBeOneTag() {
val poll = EventHintBundle(Event.fromJson(specPoll) as PollEvent, null)
val template = PollResponseEvent.buildSingleChoice(poll, "gga6cdnqj")
val responses = template.tags.filter { it[0] == "response" }
// The spec reads the FIRST response tag as the answer, so a single-choice event that
// carries more than one is ambiguous by construction. The typed builder cannot emit one.
assertEquals(listOf(listOf("response", "gga6cdnqj")), responses.map { it.toList() })
assertEquals(PollResponseEvent.KIND, template.kind)
}
@Test
fun aMultipleChoiceAnswerWritesEveryCodeItWasGiven() {
val poll = EventHintBundle(Event.fromJson(specPoll) as PollEvent, null)
val template = PollResponseEvent.buildMultipleChoice(poll, setOf("qj518h583", "gga6cdnqj"))
val codes = template.tags.filter { it[0] == "response" }.map { it[1] }
assertEquals(setOf("qj518h583", "gga6cdnqj"), codes.toSet())
}
@Test
fun theTagNamesAreSpelledTheWayTheSpecSpellsThem() {
// `endsAt` is camelCase in NIP-88 while almost every other nostr tag is lowercase, so it