Merge remote-tracking branch 'origin/main' into claude/clever-brown-ln4hj2

This commit is contained in:
Claude
2026-09-22 19:17:00 +00:00
134 changed files with 4929 additions and 132 deletions
@@ -138,6 +138,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
@@ -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)
@@ -31,11 +31,46 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.coroutines.cancellation.CancellationException
/**
* How long a [LatestKeyPackageOwner.NONE] answer may be reused.
*
* Much shorter than a definite answer's life, because NONE is ambiguous:
* `fetchAll` returns an empty list for a device that reached no relay at all
* rather than throwing, so "nobody has published one" and "we are offline"
* arrive identically. Long enough to stop repeated entries from re-fanning the
* query, short enough that the banner is not suppressed for a quarter of an
* hour after the network comes back.
*/
private const val NONE_MAX_AGE_SECONDS = 60L
/**
* Which install owns the newest KeyPackage currently on the account's relays.
*
* An inviter picks the highest `created_at` kind:30443 and nothing else
* ([KeyPackageFetcher.fetchKeyPackage]), and only the install holding that
* bundle's private keys can open the Welcome it produces. With the same
* account signed in twice, the two installs publish under different random
* d-tag slots, so both KeyPackages persist and the most recent publisher
* silently owns every future invite.
*/
enum class LatestKeyPackageOwner {
/** This install holds the bundle — invites land here. */
THIS_DEVICE,
/** A newer KeyPackage we have no private keys for — invites land elsewhere. */
OTHER_DEVICE,
/** Nothing published for this account, or nowhere to ask. */
NONE,
}
/**
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
@@ -47,6 +82,9 @@ import kotlin.coroutines.cancellation.CancellationException
class AccountMarmotActions(
private val account: Account,
) {
/** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */
private var lastOwnerCheck: Pair<Long, LatestKeyPackageOwner>? = null
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
@@ -432,6 +470,10 @@ class AccountMarmotActions(
}
account.client.publish(event, relays)
}
// A rotation we just published makes this device the newest owner.
// Leaving the old answer in place would keep the banner warning for
// up to its full life about a state that no longer exists.
lastOwnerCheck = null
}
}
@@ -452,6 +494,9 @@ class AccountMarmotActions(
}
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relays)
// Same as the rotation path: we have just changed who owns the newest
// KeyPackage, so any cached answer is stale by construction.
lastOwnerCheck = null
}
/**
@@ -502,6 +547,98 @@ class AccountMarmotActions(
return manager.hasActiveKeyPackages()
}
/**
* Ask the relays which install currently owns this account's invites.
*
* Deliberately a read, never a self-correcting one. Republishing whenever
* the answer is [LatestKeyPackageOwner.OTHER_DEVICE] would deadlock two
* installs against each other — each device's correction is the other's
* trigger, and neither ever settles — so the decision belongs to the user,
* with this as the evidence.
*
* Queries the same set we publish our own KeyPackages to, which is the
* inviter's view of us minus their own outbox: `fetchRelaysFor` unions our
* NIP-65 write set and our legacy kind:10051 with the inviter's outbox, and
* the first two are exactly [keyPackagePublishRelays].
*/
suspend fun latestKeyPackageOwner(maxAgeSeconds: Long = 0L): LatestKeyPackageOwner {
val manager = account.marmotManager ?: return LatestKeyPackageOwner.NONE
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return LatestKeyPackageOwner.NONE
// A passive caller (the groups-screen banner) may reuse a recent answer.
// Without this, every entry to that screen fanned a REQ out across the
// whole write set — and the thing it asks about only changes when
// another device publishes, which is rare enough to cache.
val cached = lastOwnerCheck
if (maxAgeSeconds > 0 && cached != null) {
val maxAge =
if (cached.second == LatestKeyPackageOwner.NONE) {
minOf(maxAgeSeconds, NONE_MAX_AGE_SECONDS)
} else {
maxAgeSeconds
}
if (TimeUtils.now() - cached.first <= maxAge) return cached.second
}
val latest = KeyPackageFetcher.fetchKeyPackage(account.client, account.signer.pubKey, relays)
val owner =
when {
latest == null -> LatestKeyPackageOwner.NONE
manager.ownsKeyPackage(latest) -> LatestKeyPackageOwner.THIS_DEVICE
else -> LatestKeyPackageOwner.OTHER_DEVICE
}
Log.d("MarmotDbg") {
"latestKeyPackageOwner: newest KeyPackage id=${latest?.id?.take(8)}… " +
"createdAt=${latest?.createdAt} owner=$owner"
}
// Cached even when nothing was found: that answer cost the same fan-out
// as any other, so leaving it uncached would re-run the whole query on
// every entry for exactly the accounts with nothing on their relays.
lastOwnerCheck = TimeUtils.now() to owner
return owner
}
/**
* The user-initiated republish behind the invite-device banner and the
* settings row. Returns whether a relay actually accepted the KeyPackage.
*
* Deliberately not [publishMarmotKeyPackage]. That one is the best-effort
* startup path: it early-returns in silence for a read-only account or an
* empty relay set and then hands the event to a fire-and-forget
* `client.publish`, so a caller reporting the outcome to someone watching
* would call every one of those failures a success.
*/
suspend fun republishKeyPackageConfirmed(): Boolean {
val manager = account.marmotManager ?: return false
if (!account.isWriteable()) return false
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return false
// Minting no longer destroys the displaced bundle — the rotation
// manager retains it, keyed by the event id it was published as — but
// every regeneration still costs a keypair, a relay round trip, and a
// slot in the bounded retention map, where it can evict a bundle
// someone is about to invite us through. Republishing when we already
// own the newest KeyPackage buys none of that back, since the answer
// cannot change, so that case is a no-op reporting success truthfully.
if (latestKeyPackageOwner() == LatestKeyPackageOwner.THIS_DEVICE) {
Log.d("MarmotDbg") { "republishKeyPackageConfirmed: already the newest; not minting" }
return true
}
val event = manager.generateKeyPackageEvent(relays.toList())
account.cache.justConsumeMyOwnEvent(event)
val accepted = account.client.publishAndConfirm(event, relays)
Log.d("MarmotDbg") {
"republishKeyPackageConfirmed: id=${event.id.take(8)}… accepted=$accepted on ${relays.size} relay(s)"
}
// The answer we just changed; a stale cache would keep the banner up.
lastOwnerCheck = if (accepted) TimeUtils.now() to LatestKeyPackageOwner.THIS_DEVICE else null
return accepted
}
/**
* Create a new Marmot MLS group under the CURRENT profile.
*
@@ -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
@@ -107,6 +107,7 @@ enum class VideoPlayerAction {
Download,
PictureInPicture,
Cast,
Captions,
}
@Serializable
@@ -127,6 +128,7 @@ val DefaultVideoPlayerButtonItems =
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Captions, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
@@ -184,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()
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.Fullscree
import com.vitorpamplona.amethyst.service.playback.composable.controls.LogVideoQualitySelection
import com.vitorpamplona.amethyst.service.playback.composable.controls.METERED_MAX_SHORT_SIDE_PX
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCaptions
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
@@ -217,6 +218,15 @@ fun RenderVideoPlayer(
shutter = {},
)
// Above the picture but below the controls, so the transport bar and the bottom gradient
// draw over the cues rather than the other way around.
if (mediaItem.src.captions.isNotEmpty()) {
RenderCaptions(
controllerState = controllerState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
RenderPlaybackError(
controllerState = controllerState,
videoUri = mediaItem.src.videoUri,
@@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
@@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
class WaveformData(
@@ -73,6 +76,7 @@ fun VideoView(
thumbhash: String? = null,
isLiveStream: Boolean = false,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val borderModifier =
if (roundedCorner) {
@@ -83,7 +87,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash, captions = captions)
}
@Composable
@@ -107,6 +111,7 @@ fun VideoView(
accountViewModel: AccountViewModel,
thumbhash: String? = null,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
// Reset the manual-show toggle when the video URI changes so a recycled feed slot
@@ -180,6 +185,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -236,6 +242,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -26,10 +26,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val DEFAULT_MUTED_SETTING = mutableStateOf(true)
@@ -57,6 +60,7 @@ fun VideoViewInner(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
accountViewModel: AccountViewModel,
) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -83,6 +87,7 @@ fun VideoViewInner(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -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),
)
}
}
}
}
}
@@ -79,6 +79,9 @@ fun OverflowMenuButtonPreview() {
onCastClick = {},
castIcon = MaterialSymbols.Cast,
castContentDescription = "",
onCaptionsClick = {},
captionsIcon = MaterialSymbols.ClosedCaption,
captionsContentDescription = "",
)
}
}
@@ -98,6 +101,9 @@ fun AnimatedOverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@@ -118,6 +124,9 @@ fun AnimatedOverflowMenuButton(
onCastClick = onCastClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
}
@@ -135,6 +144,9 @@ fun OverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
) {
val menuExpanded = remember { mutableStateOf(false) }
@@ -239,6 +251,16 @@ fun OverflowMenuButton(
onCastClick()
}
}
VideoPlayerAction.Captions -> {
M3ActionRow(
icon = captionsIcon,
text = captionsContentDescription,
) {
menuExpanded.value = false
onCaptionsClick()
}
}
}
}
}
@@ -0,0 +1,103 @@
/*
* 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.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
/**
* Draws the cues of the currently selected text track over the player.
*
* media3's Compose UI has no subtitle view — that lives in the View-based `PlayerView`, which this
* app does not use — so the cues are collected from the player and laid out here. Only the cue
* text is honoured: a WebVTT region/position would need the player's video rect, and every
* NIP-71 caption seen in the wild is plain bottom-centred dialogue.
*
* Nothing renders when no text track is selected, so this composable costs one listener on videos
* without captions.
*/
@Composable
fun RenderCaptions(
controllerState: MediaControllerState,
modifier: Modifier = Modifier,
) {
val player = controllerState.controller
var lines by remember(player) { mutableStateOf<List<String>>(emptyList()) }
DisposableEffect(player) {
lines =
player.currentCues.cues
.mapNotNull { it.text?.toString() }
.filter { it.isNotBlank() }
val listener =
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
lines = cueGroup.cues.mapNotNull { it.text?.toString() }.filter { it.isNotBlank() }
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
if (lines.isEmpty()) return
Column(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
lines.forEach { line ->
Text(
text = line,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
// A scrim behind the glyphs rather than a full-width bar: captions sit over the
// picture, and a translucent box only where there is text keeps the frame visible.
modifier =
Modifier
.background(CaptionScrim, MaterialTheme.shapes.extraSmall)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
private val CaptionScrim = Color.Black.copy(alpha = 0.6f)
@@ -46,6 +46,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -98,6 +99,8 @@ fun RenderTopButtonsPreview() {
onPictureInPictureClick = {},
onZoomClick = {},
onOverflowQualityClick = {},
captionsEnabled = true,
onCaptionsClick = {},
modifier = Modifier,
accountViewModel = mockAccountViewModel(),
)
@@ -143,6 +146,43 @@ fun RenderTopButtons(
val videoGroup = getVideoTrackGroup(tracks)
val hasMultipleQualities = videoGroup != null && videoGroup.length > 1
// Captions are side-loaded from the event's `text-track` tags (MediaItemCache flags the first
// 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(captionsEnabled, captionChoices) {
{
if (captionChoices.size > 1) {
captionsPopupOpen.value = true
} else {
accountViewModel.setCaptionsEnabled(!captionsEnabled)
}
}
}
val overflowQualityOpen = remember { mutableStateOf(false) }
// Pause local playback while this video is casting so audio doesn't
@@ -210,10 +250,31 @@ fun RenderTopButtons(
}
},
onOverflowQualityClick = { overflowQualityOpen.value = true },
captionsEnabled = captionsEnabled,
onCaptionsClick = onCaptionsClick,
modifier = modifier,
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,
@@ -237,10 +298,15 @@ fun RenderTopButtons(
onPictureInPictureClick: () -> Unit,
onZoomClick: (() -> Unit)?,
onOverflowQualityClick: () -> Unit,
captionsEnabled: Boolean,
onCaptionsClick: () -> Unit,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val captionsIcon = if (captionsEnabled) MaterialSymbols.ClosedCaption else MaterialSymbols.ClosedCaptionDisabled
val captionsContentDescription =
stringRes(if (captionsEnabled) R.string.captions_turn_off else R.string.captions_turn_on)
val shareDialogVisible = remember { mutableStateOf(false) }
val castDialogVisible = remember { mutableStateOf(false) }
val castSessionState by Amethyst.instance.castRegistry.sessionState
@@ -297,21 +363,28 @@ fun RenderTopButtons(
VideoPlayerAction.Cast -> {
BuildConfig.IS_CASTING_AVAILABLE && mediaData.videoUri.startsWith("http", ignoreCase = true)
}
// A video with no `text-track` has nothing to toggle, so the button stays out of the
// row entirely rather than sitting there inert.
VideoPlayerAction.Captions -> {
mediaData.captions.isNotEmpty()
}
}
val canFullscreen = onZoomClick != null
val hasCaptions = mediaData.captions.isNotEmpty()
// ImmutableList so Compose can treat the action lists as stable parameters when they're
// passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the
// overflow tree to recompose whenever any unrelated parent state ticks.
val topBarActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
.map { it.action }
.toImmutableList()
}
val overflowActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }
.map { it.action }
@@ -377,6 +450,15 @@ fun RenderTopButtons(
onClick = onCastButtonClick,
)
}
VideoPlayerAction.Captions -> {
AnimatedTopBarIconButton(
controllerVisible = controllerVisible,
symbol = captionsIcon,
contentDescription = captionsContentDescription,
onClick = onCaptionsClick,
)
}
}
}
@@ -394,6 +476,9 @@ fun RenderTopButtons(
onCastClick = onCastButtonClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
@@ -21,11 +21,16 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val mediaItemCache = MediaItemCache()
@@ -46,10 +51,21 @@ fun GetMediaItem(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val data =
remember(videoUri) {
// Captions join the key. They resolve asynchronously — a `text-track` that names a
// kind-39307 coordinate has to reach LocalCache first — so keying on the URI alone would
// lock in the caption-less item built on the first frame. The list only ever goes empty ->
// resolved, so this rebuilds at most once.
//
// What that rebuild reaches depends on when it lands. A feed video is mounted long before
// it is scrolled into view and played, so the item carrying the tracks is the one the
// controller loads. For a video already playing, GetVideoController's warm-path check sees
// the same mediaId and deliberately does not re-set the item, so the late tracks wait for
// the next mount rather than restarting playback under the viewer.
remember(videoUri, captions) {
MediaItemData(
videoUri = videoUri,
authorName = authorName,
@@ -66,6 +82,7 @@ fun GetMediaItem(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
)
}
@@ -79,7 +96,19 @@ fun GetMediaItem(
) {
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem?.let {
// produceCachedState re-remembers on a new key and starts at null while the replacement is
// built off-thread. Rendering that null would drop the whole player subtree for a frame and
// rebuild it — a visible blink — and for the one case that changes the key mid-playback
// (captions resolving after the first frame) the warm-pool check then declines to re-set an
// item carrying the same mediaId, so the blink would buy nothing. Keep drawing the item we
// already have until its replacement exists.
val previous = remember { mutableStateOf<LoadedMediaItem?>(null) }
val current = mediaItem
if (current != null && previous.value !== current) {
SideEffect { previous.value = current }
}
(current ?: previous.value)?.let {
inner(it)
}
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import android.os.Bundle
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -61,6 +63,25 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
return null
}
// A side-loaded track needs its media type declared: a NIP-71 `text-track` says nothing
// about the format, and a blossom-hosted one is `https://host/<sha256>` with no extension
// for ExoPlayer to infer from either. A track flagged default is the one ExoPlayer
// selects without being asked.
internal fun CaptionTrack.toSubtitleConfiguration(isDefault: Boolean = false): MediaItem.SubtitleConfiguration =
MediaItem.SubtitleConfiguration
.Builder(url.toUri())
.setMimeType(mimeType)
.setLanguage(language)
.setLabel(label())
.setSelectionFlags(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0)
.build()
private fun CaptionTrack.label(): String? =
when {
language != null && type != null -> "$language \u00B7 $type"
else -> language ?: type
}
// Restrict `.m3u8` matching to the path component so a query param or
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
// MP4 URI doesn't misroute to HlsMediaSource.
@@ -83,7 +104,15 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.apply { normalizedMime?.let { setMimeType(it) } }
.setMediaMetadata(
.setSubtitleConfigurations(
// 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)
},
).setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
@@ -22,8 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class MediaItemData(
@@ -48,6 +51,9 @@ data class MediaItemData(
val dim: DimensionTag? = null,
val hash: String? = null,
val thumbhash: String? = null,
// NIP-71 `text-track` captions to side-load. A data class is the cache key, so this has to be
// a value that compares by content — ImmutableList keeps that true and keeps the key @Stable.
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
)
@Immutable
@@ -29,6 +29,8 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
@@ -104,6 +106,7 @@ class CustomMediaSourceFactory(
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
private val plainDataSource: DataSource.Factory = dataSourceFactory
private val cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(cachingDataSource)
@@ -119,9 +122,10 @@ class CustomMediaSourceFactory(
//
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
// be mirrored here.
// live target-offset defaults. Subtitles ARE reachable now (MediaItemCache side-loads NIP-71
// `text-track` captions), so createMediaSource below mirrors that wrap for the HLS path;
// clipping and ad insertion remain unused, and the live setters aren't on the
// MediaSource.Factory interface. Anything else added later must be mirrored here too.
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
@@ -137,7 +141,13 @@ class CustomMediaSourceFactory(
return this
}
// Retained as well as forwarded: the subtitle sources in withSideLoadedSubtitles are built per
// call rather than living in allFactories, so this is the only way they can be given the same
// retry/backoff behaviour the player configured for everything else.
private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
return this
}
@@ -161,7 +171,16 @@ class CustomMediaSourceFactory(
} else {
if (bypassCache) nonCachingFactory else cachingFactory
}
val source = factory.createMediaSource(mediaItem)
// DefaultMediaSourceFactory is what merges side-loaded subtitle tracks into the playback
// source. The explicit HLS factory above does not, so on that path a caption track would
// load into nothing; mirror the wrap here. Progressive items already got it from the
// Default factory, so they must NOT be wrapped again.
val source =
if (hls) {
withSideLoadedSubtitles(factory.createMediaSource(mediaItem), mediaItem, bypassCache)
} else {
factory.createMediaSource(mediaItem)
}
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
// shouldBypassCache.
@@ -172,6 +191,38 @@ class CustomMediaSourceFactory(
return source
}
/**
* Rebuilds what [DefaultMediaSourceFactory] does for `subtitleConfigurations`: each side-loaded
* track becomes its own single-sample source, merged alongside the video.
*
* Subtitle files are small, immutable documents, so they follow the video's cache decision —
* a track fetched beside a cached VOD playlist is worth keeping too, and one beside a live
* stream is not worth a cache entry for a stream that is never replayed.
*/
private fun withSideLoadedSubtitles(
source: MediaSource,
mediaItem: MediaItem,
bypassCache: Boolean,
): MediaSource {
val subtitles = mediaItem.localConfiguration?.subtitleConfigurations.orEmpty()
if (subtitles.isEmpty()) return source
val dataSource = if (bypassCache) plainDataSource else cachingDataSource
val sources =
Array(subtitles.size + 1) { index ->
if (index == 0) {
source
} else {
SingleSampleMediaSource
.Factory(dataSource)
.apply { loadErrorHandlingPolicy?.let { setLoadErrorHandlingPolicy(it) } }
.createMediaSource(subtitles[index - 1], C.TIME_UNSET)
}
}
return MergingMediaSource(*sources)
}
// Only the explicit event-kind flag (kind:30311 live activities). Returns false when the flag
// is absent or false; the HLS URL check in createMediaSource covers everything else.
private fun isFlaggedLive(mediaItem: MediaItem): Boolean {
@@ -523,6 +523,7 @@ private fun RenderImageOrVideo(
controllerVisible = controllerVisible,
hasBlurhash = content.blurhash != null,
isFullscreen = true,
captions = content.captions,
accountViewModel = accountViewModel,
)
}
@@ -277,6 +277,7 @@ fun ZoomableContentView(
thumbhash = content.thumbhash,
isLiveStream = content.isLiveStream,
hash = content.hash,
captions = content.captions,
)
}
}
@@ -73,6 +73,7 @@ enum class NavBarItem {
PUBLIC_CHATS,
RELAY_GROUPS,
CONCORD,
MARMOT_GROUPS,
GEOHASH_CHATS,
FOLLOW_PACKS,
LIVE_STREAMS,
@@ -387,6 +388,22 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Group,
resolveRoute = { Route.Concords },
),
NavBarItem.MARMOT_GROUPS to
NavBarItemDef(
id = NavBarItem.MARMOT_GROUPS,
// Duplicates the already-translated `marmot_groups_title` in
// commonsUI's composeResources, which the screen's own title uses.
// Unavoidable here: `labelRes` is an Android @StringRes and every
// other catalog entry is one, so a Compose resource cannot be
// referenced without changing the type for all ~60 of them. Crowdin
// manages both resource sets, so this one gets translated too.
labelRes = R.string.marmot_groups_title,
// Lock, not Group: the rooms list already labels a Marmot room
// with this symbol, so it is the signifier users have learned
// for these, and it keeps the row distinct from Concord's.
icon = MaterialSymbols.Lock,
resolveRoute = { Route.MarmotGroupList },
),
NavBarItem.GEOHASH_CHATS to
NavBarItemDef(
id = NavBarItem.GEOHASH_CHATS,
@@ -522,6 +539,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.GEOHASH_CHATS,
),
),
@@ -144,6 +144,7 @@ private val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
@@ -221,6 +221,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderThread
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
import com.vitorpamplona.amethyst.ui.note.types.RenderVideoCollaboration
import com.vitorpamplona.amethyst.ui.note.types.RenderVideoCurationSet
import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiMergeAcceptance
@@ -229,6 +231,25 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderWikiRedirect
import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderAppCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderArticleCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderFavoriteAlgoFeedsList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGeohashList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitAuthorList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitRepositoryList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiAuthorList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiRelayList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderHashtagList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderInterestSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderKindMuteSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderLabeledBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaFollowList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaStarterPack
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMuteList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderOldBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderPictureCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderSimpleGroupList
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
@@ -291,6 +312,7 @@ import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.experimental.ratings.RelayReviewEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
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.metadata.MetadataEvent
@@ -328,8 +350,26 @@ import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
@@ -338,6 +378,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -1579,6 +1621,14 @@ private fun RenderNoteRow(
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
}
is VideoCollaborationEvent -> {
RenderVideoCollaboration(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is VideoCurationSetEvent -> {
RenderVideoCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is PictureEvent -> {
PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
}
@@ -1788,6 +1838,82 @@ private fun RenderNoteRow(
)
}
is MuteListEvent -> {
RenderMuteList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is BookmarkListEvent -> {
RenderBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is OldBookmarkListEvent -> {
RenderOldBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is LabeledBookmarkListEvent -> {
RenderLabeledBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is ArticleCurationSetEvent -> {
RenderArticleCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is PictureCurationSetEvent -> {
RenderPictureCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AppCurationSetEvent -> {
RenderAppCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is GitRepositoryListEvent -> {
RenderGitRepositoryList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is FavoriteAlgoFeedsListEvent -> {
RenderFavoriteAlgoFeedsList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is GitAuthorListEvent -> {
RenderGitAuthorList(baseNote, backgroundColor, accountViewModel, nav)
}
is MediaFollowListEvent -> {
RenderMediaFollowList(baseNote, backgroundColor, accountViewModel, nav)
}
is GoodWikiAuthorListEvent -> {
RenderGoodWikiAuthorList(baseNote, backgroundColor, accountViewModel, nav)
}
is KindMuteSetEvent -> {
RenderKindMuteSet(baseNote, backgroundColor, accountViewModel, nav)
}
is MediaStarterPackEvent -> {
RenderMediaStarterPack(baseNote, backgroundColor, accountViewModel, nav)
}
is HashtagListEvent -> {
RenderHashtagList(baseNote, backgroundColor, accountViewModel, nav)
}
is InterestSetEvent -> {
RenderInterestSet(baseNote, backgroundColor, accountViewModel, nav)
}
is GeohashListEvent -> {
RenderGeohashList(baseNote, backgroundColor, accountViewModel)
}
is SimpleGroupListEvent -> {
RenderSimpleGroupList(baseNote, backgroundColor, accountViewModel)
}
is GoodWikiRelayListEvent -> {
RenderGoodWikiRelayList(baseNote, backgroundColor, accountViewModel)
}
else -> {
RenderTextEvent(
baseNote,
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.model.nip71Video.captionTracks
import com.vitorpamplona.amethyst.commons.model.nip71Video.mergeCaptionTracks
import com.vitorpamplona.amethyst.commons.model.nip71Video.toCaptionTrack
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Resolves the caption tracks a NIP-71 video advertises, fetching the addressable ones.
*
* A `text-track` that names a URL is usable immediately. One that names a
* `39307:<pubkey>:<d>` coordinate is not, so it gets an [observeNoteEvent] of its own — that both
* reads the event out of LocalCache and puts it on an EventFinder subscription, so a track the
* device has never seen is requested from relays and appears when it lands.
*
* The same track is routinely advertised both ways at once (divine.video publishes the Blossom URL
* and its wrapping event side by side), so the two are merged by URL: the player is handed one
* configuration per distinct file, never a duplicate pair it would offer twice in its track menu.
*/
@Composable
fun rememberCaptionTracks(
videoEvent: VideoEvent,
accountViewModel: AccountViewModel,
): ImmutableList<CaptionTrack> {
val refs = remember(videoEvent) { videoEvent.captionTracks() }
// No early return: the pending list changes size when a recycled feed slot switches videos,
// and `key` is what lets Compose add and drop those child subscriptions in place.
val resolved =
refs.pending.map { pending ->
key(pending.address.toValue()) {
val note = remember(pending.address) { accountViewModel.getOrCreateAddressableNote(pending.address) }
val trackEvent by observeNoteEvent<TextTrackEvent>(note, accountViewModel)
trackEvent?.toCaptionTrack(pending.tag)
}
}
return remember(refs, resolved) {
if (refs.direct.isEmpty() && resolved.all { it == null }) {
persistentListOf()
} else {
mergeCaptionTracks(refs.direct, resolved.filterNotNull()).toImmutableList()
}
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
/**
* A collaborator's acceptance (or refusal) of a credit, rendered on its own.
*
* The event carries no content — everything it says is in the video it points at — so the video is
* embedded and the event itself is reduced to one line of context above it: the role the
* collaborator was given, and whether they took it.
*/
@Composable
fun RenderVideoCollaboration(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? VideoCollaborationEvent ?: return
val video = remember(noteEvent) { noteEvent.video() }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val headline =
if (noteEvent.isAccepted()) {
stringRes(R.string.video_collaboration_accepted_on)
} else {
stringRes(R.string.video_collaboration_declined_on)
}
val role = remember(noteEvent) { noteEvent.role() }
Text(
text = if (role != null) "$headline · $role" else headline,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// quotesLeft guards the recursion: the embedded video can itself credit other work, and
// NoteCompose is what decrements it on the way down.
if (video != null && quotesLeft > 0) {
LoadAddressableNote(video, accountViewModel) { videoNote ->
videoNote?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
@@ -0,0 +1,248 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
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.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
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
/**
* The line of credits under a video: who it features, who inspired it, whose audio it reuses.
*
* Every entry is a link, because the credit is only worth printing if it takes you to the person
* or the work. A credited person who has published a [VideoCollaborationEvent] for this video gets
* a check beside their name — they are not merely tagged, they took the credit.
*
* A `p` tag marked `mention` is kept even though the description may name the same person: on
* divine.video the description writes a plain `@handle`, which no client can turn into a link, so
* the tag is the only thing that resolves to an actual profile. The cost is a chip that repeats a
* `nostr:` mention already rendered inline, which is redundancy rather than a wrong credit.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderVideoCredits(
videoEvent: VideoEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
val credits = remember(videoEvent) { videoEvent.credits() }
if (credits.isEmpty()) return
// Only an addressable video has a coordinate for a collaborator to answer, so only there can
// an acceptance be looked up at all.
val videoAddress = remember(videoEvent) { (videoEvent as? AddressableEvent)?.address() }
FlowRow(modifier = HalfTopPadding) {
credits.forEach { credit ->
key(credit.target) {
RenderVideoCredit(credit, videoAddress, accountViewModel, nav)
}
}
}
}
@Composable
private fun RenderVideoCredit(
credit: VideoCredit,
videoAddress: Address?,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (val target = credit.target) {
is CreditTarget.Person ->
LoadUser(target.pubKey, accountViewModel) { user ->
// Until the profile arrives there is no name to print, and a hex key in the
// credits reads as noise rather than as a person.
user?.let {
CreditRow(
label = personLabel(credit.label),
// toBestDisplayName() is read inside the row so a profile that lands later
// still renames the credit.
name = "@" + it.toBestDisplayName(),
confirmed = {
// Only a role-marked credit is an invite someone can accept, and each
// 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)
}
},
onClick = { nav.nav(Route.Profile(target.pubKey)) },
)
}
}
is CreditTarget.Video ->
CreditRow(
label = workLabel(credit.label),
name = target.address.dTag.take(SHORT_REF_LENGTH),
onClick = {
nav.nav(Route.Note(target.address.toTag()))
},
)
is CreditTarget.Event ->
CreditRow(
label = workLabel(credit.label),
name = target.eventId.take(SHORT_REF_LENGTH),
onClick = { nav.nav(Route.Note(target.eventId)) },
)
}
}
/**
* Draws a check when [pubKey] has published an accepted [VideoCollaborationEvent] for this video.
*
* 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(
pubKey: String,
videoAddress: Address?,
accountViewModel: AccountViewModel,
) {
if (videoAddress == null) return
val responseFlow =
remember(pubKey, videoAddress) {
accountViewModel.account.cache.observeLatestEvent<VideoCollaborationEvent>(
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(pubKey),
tags = mapOf("a" to listOf(videoAddress.toValue())),
limit = 1,
),
)
}
val response by responseFlow.collectAsStateWithLifecycle(null)
if (response?.isAccepted() == true) {
Icon(
symbol = MaterialSymbols.CheckCircle,
contentDescription = stringRes(R.string.video_credit_accepted),
modifier = Modifier.padding(start = 2.dp),
tint = MaterialTheme.colorScheme.lessImportantLink,
)
}
}
@Composable
private fun CreditRow(
label: String,
name: String,
confirmed: @Composable () -> Unit = {},
onClick: () -> Unit,
) {
Row(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (label.isNotBlank()) {
Text(
text = "$label ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
ClickableTextColor(
text = name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
linkColor = MaterialTheme.colorScheme.lessImportantLink,
onClick = onClick,
)
confirmed()
}
}
// A person tagged without a stated capacity is still in the video — that is what NIP-71's `p`
// means — so the neutral credit, not a blank one, is the honest default.
@Composable
private fun personLabel(marker: String?): String =
when (marker) {
INSPIRED_BY_MARKER -> stringRes(R.string.video_credit_inspired_by)
MENTION_MARKER, null -> stringRes(R.string.video_credit_featuring)
// Anything else is a role the video's author typed ("Collaborator", "Director"): print it.
else -> marker
}
// The same treatment for a credited work. Both reference kinds route through here so an `e` tag
// and an `a` tag carrying the same marker read identically — the `e` path used to print the raw
// protocol token, so a `mention` showed up in the UI as the literal word "mention".
@Composable
private fun workLabel(marker: String?): String =
when (marker) {
AUDIO_MARKER -> stringRes(R.string.video_credit_audio_from)
MENTION_MARKER, null -> stringRes(R.string.video_credit_references)
else -> marker
}
/** True for a marker that names a role rather than describing a reference. */
private fun String?.isCollaborationRole(): Boolean = this != null && this != MENTION_MARKER && this != INSPIRED_BY_MARKER && this != AUDIO_MARKER
private const val INSPIRED_BY_MARKER = "inspired-by"
private const val MENTION_MARKER = "mention"
private const val AUDIO_MARKER = "audio"
// Enough of a hex id or `d` tag to tell two references apart without wrapping the row.
private const val SHORT_REF_LENGTH = 8
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.utils.Log
/**
* A NIP-51 kind-30005 video list: its name, what it is for, and a strip of its videos.
*
* Members may be public tags or NIP-51 private ones inside the encrypted content — divine.video
* puts every member there, so for anyone but the list's owner the strip is legitimately empty and
* the card says so rather than looking broken.
*/
@Composable
fun RenderVideoCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? VideoCurationSetEvent ?: return
val publicItems = remember(noteEvent) { noteEvent.publicItems() }
// Decryption needs the signer and only succeeds for the list's owner, so it runs off the
// composition and simply yields nothing for everyone else.
//
// Null means "we did not read the content": not the owner, or the decrypt failed. An empty
// list means we read it and it holds nothing. The two have to stay apart, because a list whose
// encrypted content decrypts to `[]` is an empty list, not a private one — divine.video
// publishes exactly that for a list a user created and never added to.
val privateItems by
produceState<List<BookmarkIdTag>?>(initialValue = null, noteEvent) {
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
value =
try {
noteEvent.privateItems(accountViewModel.account.signer)
} catch (e: Exception) {
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
Log.w("RenderVideoCurationSet", "Cannot decrypt private items of ${noteEvent.id}", e)
null
}
}
val items = remember(publicItems, privateItems) { publicItems + (privateItems ?: emptyList()) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
(noteEvent.title() ?: noteEvent.dTag()).takeIf { it.isNotBlank() }?.let {
Text(
text = it,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
noteEvent.description()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
// An empty strip has two very different causes. Encrypted content we could not read means
// the members may be there and private; content we did read, or no content at all, means
// the list is simply empty. Calling that "Private list" would be a lie — to its own author
// most of all, who just decrypted it and found nothing in it.
val membersAreHidden = items.isEmpty() && noteEvent.content.isNotBlank() && privateItems == null
Text(
text =
if (membersAreHidden) {
stringRes(R.string.video_curation_set_private_items)
} else {
pluralStringResource(R.plurals.video_curation_set_video_count, items.size, items.size)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (items.isNotEmpty() && quotesLeft > 0) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items.take(MAX_POSTERS).forEach { item ->
key(item.toTagIdOnly().joinToString(":")) {
VideoListMemberPoster(item, accountViewModel, nav)
}
}
}
}
}
}
/** One video in the strip: its poster, or its blurhash until that loads. */
@Composable
private fun VideoListMemberPoster(
item: BookmarkIdTag,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (item) {
is AddressBookmark ->
LoadAddressableNote(item.address, accountViewModel) { note ->
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(addressTag(item.address))) } }
}
is EventBookmark ->
LoadNote(item.eventId, accountViewModel) { note ->
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(item.eventId)) } }
}
}
}
@Composable
private fun VideoPoster(
note: Note,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val videoEvent = note.event as? VideoEvent ?: return
// The poster, not the video: a strip of simultaneously-playing players is unusable, and every
// NIP-71 imeta that has a thumbnail carries it as `image`.
val imeta = remember(videoEvent) { videoEvent.imetaTags().firstOrNull { it.image.isNotEmpty() } ?: videoEvent.imetaTags().firstOrNull() }
val poster = imeta?.image?.firstOrNull()
val tileModifier = Modifier.width(POSTER_WIDTH).height(POSTER_HEIGHT).clickable(onClick = onClick)
if (poster == null) {
imeta?.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) }
return
}
MyAsyncImage(
imageUrl = poster,
contentDescription = videoEvent.title(),
contentScale = ContentScale.Crop,
mainImageModifier = tileModifier,
loadedImageModifier = MaterialTheme.colorScheme.imageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { imeta.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) } },
onError = null,
)
}
private fun addressTag(address: Address) = Address.assemble(address.kind, address.pubKeyHex, address.dTag)
// Enough to show what kind of list this is without turning a feed card into a gallery.
private const val MAX_POSTERS = 8
private val POSTER_WIDTH = 96.dp
private val POSTER_HEIGHT = 128.dp
@@ -86,9 +86,12 @@ fun VideoDisplay(
val image = remember(imeta) { imeta.image.firstOrNull() }
val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") }
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val captions = rememberCaptionTracks(videoEvent, accountViewModel)
val content: BaseMediaContent =
remember(note) {
// Keyed on the captions too: an addressable `text-track` resolves after the first frame,
// and the player only picks up a track that is in the MediaItemData it was handed.
remember(note, captions) {
val description = videoEvent.content.ifBlank { null } ?: event.alt()
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
@@ -118,6 +121,7 @@ fun VideoDisplay(
mimeType = imeta.mimeType,
blurhash = imeta.blurhash,
thumbhash = imeta.thumbhash,
captions = captions,
)
}
}
@@ -200,6 +204,11 @@ fun VideoDisplay(
}
}
}
// Outside the summary block on purpose: divine.video shorts routinely credit a
// soundtrack or a collaborator while leaving the description empty, and those credits
// are the only attribution the reused work gets.
RenderVideoCredits(videoEvent, accountViewModel, nav)
}
}
}
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
/** NIP-51 kind 10018: the git repositories this user follows. */
@Composable
fun RenderGitRepositoryList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GitRepositoryListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicRepositories() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRepositories(it) }
ListCard(
title = stringRes(R.string.kind_git_repositories),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { repo ->
key(repo.address.toValue()) {
AddressMemberRow(repo.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/** NIP-51 kind 10090: the DVM content feeds this user keeps to hand. */
@Composable
fun RenderFavoriteAlgoFeedsList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? FavoriteAlgoFeedsListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicFavoriteAlgoFeeds() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFavoriteAlgoFeeds(it) }
ListCard(
title = stringRes(R.string.kind_favorite_algo_feeds),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { feed ->
key(feed.address.toValue()) {
AddressMemberRow(feed.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/** NIP-51 kind 30267: "references to multiple software applications". */
@Composable
fun RenderAppCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? AppCurationSetEvent ?: return
val apps = remember(noteEvent) { noteEvent.apps() }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_app_curation_set),
description = noteEvent.description(),
items = apps,
// Not a PrivateTagArrayEvent: an app set has no encrypted half to be locked out of.
hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor,
) { app ->
key(app.address.toValue()) {
AddressMemberRow(app.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/**
* NIP-51 kind 10009: the simple groups this user belongs to.
*
* A group is named by an id on a relay rather than by a coordinate, so it gets its own row: the
* group's name when it carries one, and the relay that hosts it underneath.
*/
@Composable
fun RenderSimpleGroupList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? SimpleGroupListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicGroups() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateGroups(it) }
ListCard(
title = stringRes(R.string.kind_simple_groups),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { group ->
key(group.groupId + group.relayUrl) {
LabelMemberRow(group.name?.takeIf { it.isNotBlank() } ?: group.groupId)
}
}
}
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent
/** NIP-51 kind 10003: the user's bookmarks — notes, articles, hashtags and links. */
@Composable
fun RenderBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? BookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), null, R.string.kind_bookmark_list),
description = null,
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* NIP-51 kind 30001: the deprecated bookmark set. Still rendered, because the events are on relays
* whether or not the spec wants them to be.
*/
@Suppress("DEPRECATION")
@Composable
fun RenderOldBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? OldBookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_bookmark_list),
description = null,
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30003: "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups". */
@Composable
fun RenderLabeledBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? LabeledBookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.titleOrName(), noteEvent.dTag(), R.string.kind_bookmark_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30004: "groups of articles picked by users as interesting". */
@Composable
fun RenderArticleCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? ArticleCurationSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicItems() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_article_curation_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30006: "groups of pictures picked by users as interesting". */
@Composable
fun RenderPictureCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? PictureCurationSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicItems() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_picture_curation_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* The five bookmark-shaped kinds differ only in where their name comes from; what they hold is the
* same `e`/`a` reference, so the body they draw is the same one.
*/
@Composable
private fun BookmarkCard(
title: String,
description: String?,
public: List<BookmarkIdTag>,
private: List<BookmarkIdTag>?,
hidesPrivate: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
ListCard(
title = title,
description = description,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = hidesPrivate,
backgroundColor = backgroundColor,
) { item ->
key(item.toTagIdOnly().joinToString(":")) {
BookmarkMemberRow(item, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent
/** NIP-51 kind 10102: the relays this user trusts for wiki articles. */
@Composable
fun RenderGoodWikiRelayList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? GoodWikiRelayListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicRelays() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRelays(it) }
ListCard(
title = stringRes(R.string.kind_good_wiki_relays),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { relay ->
key(relay.url) { LabelMemberRow(relay.displayUrl()) }
}
}
@@ -0,0 +1,185 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
/** How many members a list card shows before it asks to be expanded. Matches DisplayPeopleList. */
private const val COLLAPSED_MEMBERS = 3
/**
* The shared shape of every NIP-51 list card: a name, an optional description, the members, and —
* when the viewer is not the list's author — an honest line about the ones they cannot see.
*
* Each list kind supplies its own title, its own member type and its own row, because a mute list
* of pubkeys and a bookmark list of notes are not the same thing and should not pretend to be.
* What they share is this frame.
*/
@Composable
fun <T> ListCard(
title: String,
description: String?,
items: List<T>,
/** True when the event carries private members this viewer has no key for. */
hasUnreadablePrivateItems: Boolean,
backgroundColor: MutableState<Color>,
itemContent: @Composable (T) -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val shown = if (expanded) items else items.take(COLLAPSED_MEMBERS)
Text(
text = title,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
textAlign = TextAlign.Center,
)
description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Only when there is nothing else to show. A card with visible members already reads as a list,
// and most lists keep something private, so saying so on every one of them is noise. The line
// exists for the case that would otherwise be a title and blank space — an all-private list,
// which is what divine.video publishes — so that it reads as closed rather than as broken.
if (hasUnreadablePrivateItems && items.isEmpty()) {
Text(
text = stringRes(R.string.nip51_list_has_private_members),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 2.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
shown.forEach { itemContent(it) }
}
if (items.size > COLLAPSED_MEMBERS && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
ShowMoreButton { expanded = true }
}
}
}
}
/**
* Decrypts a list's private members, and keeps "could not read them" apart from "there are none".
*
* `null` means we never got the plaintext — the viewer is not the author, or the decrypt failed.
* An empty list means we read the content and it holds nothing, which is an empty list and must
* not be labelled private: its author can see straight into it.
*/
@Composable
fun <T> loadPrivateItems(
noteEvent: Event,
accountViewModel: AccountViewModel,
load: suspend (NostrSigner) -> List<T>?,
): State<List<T>?> =
produceState<List<T>?>(initialValue = null, noteEvent) {
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
value =
try {
load(accountViewModel.account.signer)
} catch (e: Exception) {
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
Log.w("NIP51ListCard", "Cannot decrypt private members of ${noteEvent.id}", e)
null
}
}
/**
* True when this event is withholding members from the viewer.
*
* Both halves matter: an empty `content` is a list with no private members at all, and a non-null
* [privateItems] means we already read them, so neither is hidden.
*/
fun Event.hidesPrivateMembers(privateItems: List<*>?): Boolean = content.isNotEmpty() && privateItems == null
/** A list's own name if it has one, else the `d` tag it is addressed by, else the kind's name. */
@Composable
fun listTitle(
explicitTitle: String?,
dTag: String?,
fallback: Int,
): String =
explicitTitle?.takeIf { it.isNotBlank() }
?: dTag?.takeIf { it.isNotBlank() }
?: stringRes(fallback)
/** Remembers the concatenation of a list's public and (once decrypted) private members. */
@Composable
fun <T> rememberAllMembers(
public: List<T>,
private: List<T>?,
): List<T> = remember(public, private) { if (private == null) public else public + private }
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
/**
* A member that is a person. Loaded lazily and skipped until the profile arrives, because a bare
* hex key in a list of people reads as noise rather than as a member.
*/
@Composable
fun UserMemberRow(
pubKey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadUser(pubKey, accountViewModel) { user ->
user?.let {
Column(modifier = Modifier.fillMaxWidth()) {
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
/**
* A member that is a note or an addressable event — a bookmark, a curated article, a repository.
*
* [quotesLeft] guards the recursion: a bookmarked note can itself quote things, and NoteCompose
* decrements it on the way down.
*/
@Composable
fun BookmarkMemberRow(
item: BookmarkIdTag,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
when (item) {
is AddressBookmark -> AddressMemberRow(item.address, quotesLeft, backgroundColor, accountViewModel, nav)
is EventBookmark ->
LoadNote(item.eventId, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
/** A member named by coordinate: an article, a repository, an app, a DVM feed. */
@Composable
fun AddressMemberRow(
address: Address,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
LoadAddressableNote(address, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
/**
* A member that is just a word: a hashtag, a geohash, a muted word.
*
* [onClick] is optional because only some of these lead anywhere — a hashtag opens its feed, a
* muted word opens nothing.
*/
@Composable
fun LabelMemberRow(
label: String,
onClick: (() -> Unit)? = null,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
/** A hashtag member, which opens its feed. */
@Composable
fun HashtagMemberRow(
hashtag: String,
nav: INav,
) = LabelMemberRow("#$hashtag") { nav.nav(Route.Hashtag(hashtag)) }
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
/**
* NIP-51 kind 10000: the mute list.
*
* The only list whose members are of four different kinds at once — people, hashtags, words and
* threads — so it is the one kind that cannot borrow another card's body. Each member is drawn as
* what it actually is: a profile row for a person, a hashtag that opens its feed, a word as
* itself, and a muted thread as the note it silences.
*
* Most mute lists are mostly private, and for anyone but their author this renders as a title and
* an honest line saying so — which is the entire point, given that it used to render as the
* NIP-44 ciphertext.
*/
@Composable
fun RenderMuteList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MuteListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicMutes() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutes(it) }
ListCard(
title = stringRes(R.string.kind_mute_list),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { mute ->
when (mute) {
is UserTag -> key(mute.pubKey) { UserMemberRow(mute.pubKey, accountViewModel, nav) }
is HashtagTag -> key(mute.hashtag) { HashtagMemberRow(mute.hashtag, nav) }
is WordTag -> key(mute.word) { LabelMemberRow(mute.word) }
is EventTag ->
key(mute.eventId) {
MutedThreadRow(mute.eventId, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
}
/** A muted conversation, shown as the note it silences. */
@Composable
private fun MutedThreadRow(
eventId: String,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
LoadNote(eventId, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
/** NIP-51 kind 10015: the hashtags this user follows. */
@Composable
fun RenderHashtagList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? HashtagListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicHashtags() }
// HashtagListEvent exposes no private accessor of its own, but it is a PrivateTagArrayEvent
// like the rest, so its private half is read the same way InterestSetEvent reads its own.
val private by loadPrivateItems(noteEvent, accountViewModel) { signer ->
noteEvent.privateTags(signer)?.mapNotNull(HashtagTag::parse)
}
ListCard(
title = stringRes(R.string.kind_hashtag_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { tag ->
key(tag) { HashtagMemberRow(tag, nav) }
}
}
/** NIP-51 kind 30015: "interest topics represented by a bunch of hashtags". */
@Composable
fun RenderInterestSet(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? InterestSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicHashtags() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateHashtags(it) }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_interest_set),
description = noteEvent.description(),
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { tag ->
key(tag) { HashtagMemberRow(tag, nav) }
}
}
/**
* NIP-51 kind 10081: the places this user follows.
*
* A geohash is not a hashtag — it is a location, and it gets no `#`. Shown verbatim because a
* geohash is what the list stores; turning it into a place name would need a geocoder we don't
* have on this path.
*/
@Composable
fun RenderGeohashList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? GeohashListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicGeohashes() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.decryptPrivateGeohashes(it) }
ListCard(
title = stringRes(R.string.kind_geohash_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { geohash ->
key(geohash) { LabelMemberRow(geohash) }
}
}
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent
/** NIP-51 kind 10017: the git authors whose patches this user wants to see. */
@Composable
fun RenderGitAuthorList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GitAuthorListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicAuthors() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
ListCard(
title = stringRes(R.string.kind_git_authors),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { author ->
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
}
}
/** NIP-51 kind 10020: the people whose media this user follows. */
@Composable
fun RenderMediaFollowList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MediaFollowListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicFollows() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFollows(it) }
ListCard(
title = stringRes(R.string.kind_media_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { follow ->
key(follow.pubKey) { UserMemberRow(follow.pubKey, accountViewModel, nav) }
}
}
/** NIP-51 kind 10101: the authors whose wiki articles this user trusts. */
@Composable
fun RenderGoodWikiAuthorList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GoodWikiAuthorListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicAuthors() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
ListCard(
title = stringRes(R.string.kind_good_wiki_authors),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { author ->
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
}
}
/**
* NIP-51 kind 30007: people muted for one kind only.
*
* "`d` tag MUST be the kind string" — so the `d` tag is not a name here, it is the kind being
* muted, and printing it as a title the way other sets do would be wrong.
*/
@Composable
fun RenderKindMuteSet(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? KindMuteSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicMutedUsers() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutedUsers(it) }
val mutedKind = remember(noteEvent) { noteEvent.dTag() }
ListCard(
title = stringRes(R.string.kind_mute_set_for_kind, mutedKind),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { muted ->
key(muted.pubKey) { UserMemberRow(muted.pubKey, accountViewModel, nav) }
}
}
/**
* NIP-51 kind 39092: a media starter pack — "a named set of profiles to be shared around with the
* goal of being followed together". Public by construction: a pack exists to be handed out.
*/
@Composable
fun RenderMediaStarterPack(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MediaStarterPackEvent ?: return
val members = remember(noteEvent) { noteEvent.followIds() }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_media_starter_pack),
description = noteEvent.description(),
items = members,
hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor,
) { pubKey ->
key(pubKey) { UserMemberRow(pubKey, accountViewModel, nav) }
}
}
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCacheAsync
import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
@@ -2012,6 +2013,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)
@@ -2554,6 +2561,20 @@ class AccountViewModel(
suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage()
/**
* Which install currently owns this account's Marmot invites. See [LatestKeyPackageOwner].
*
* [maxAgeSeconds] lets a passive caller reuse a recent answer instead of
* fanning a REQ across the write set; 0 always asks the relays.
*/
suspend fun latestKeyPackageOwner(maxAgeSeconds: Long = 0L): LatestKeyPackageOwner = account.marmot.latestKeyPackageOwner(maxAgeSeconds)
/** Republishes this device's KeyPackage; true only when a relay accepted it. */
suspend fun republishKeyPackage(): Boolean = account.marmot.republishKeyPackageConfirmed()
/** False for a read-only (pubkey-only) login, which cannot publish at all. */
fun canPublish(): Boolean = account.isWriteable()
/**
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
* advertising where it publishes KeyPackages.
@@ -154,6 +154,12 @@ private fun PreloadFor(
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
// The Marmot list renders from local state alone — marmotGroupList.rooms plus
// marmotManager.activeGroupIds() — and MarmotManager already owns a per-group
// subscription for every group it restores or joins. There is no list-level REQ
// to warm up, and adding one would duplicate those.
NavBarItem.MARMOT_GROUPS -> Unit
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/** Counts above this render as "N+" so a very busy room doesn't blow out the row. */
const val CHAT_UNREAD_CAP = 99
/**
* The unread pill every chat list shares: Concord channels, Marmot groups.
*
* Sizing is `sizeIn` + padding rather than a fixed `size`, so "99+" widens the
* pill instead of being clipped by it, and the label rides `labelSmall` rather
* than a hardcoded sp so it still tracks the reader's font scale.
*
* [contentDescription] is the caller's, because the plural that reads well out
* loud is per-surface ("3 unread messages" vs "3 unread invites"); the pill
* itself is the same object everywhere.
*/
@Composable
fun ChatUnreadBadge(
count: Int,
contentDescription: String,
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val label = if (count > CHAT_UNREAD_CAP) "$CHAT_UNREAD_CAP+" else count.toString()
Box(
modifier =
modifier
.semantics { this.contentDescription = contentDescription }
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
// snapping — a small touch that makes new activity feel noticed.
.animateContentSize()
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -166,6 +168,11 @@ fun CreateGroupScreen(
.padding(padding)
.consumeWindowInsets(padding)
.imePaddingSafe()
// The form is taller than the window once the IME is up, so
// `imePaddingSafe` alone just clips the retention picker and
// the footer off the bottom. Scrolling is what makes them
// reachable while the keyboard covers half the screen.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Text(
@@ -219,7 +226,7 @@ fun CreateGroupScreen(
Text(
stringRes(Res.string.marmot_create_group_footer),
modifier = Modifier.padding(top = 12.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -157,6 +159,11 @@ fun EditGroupInfoScreen(
.padding(padding)
.consumeWindowInsets(padding)
.imePaddingSafe()
// Same reason as CreateGroupScreen: name + description +
// avatar-url + footer is taller than what is left once the
// IME is up, so the padding alone would clip the lower
// fields with no way to reach them.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(8.dp))
@@ -241,6 +248,7 @@ fun EditGroupInfoScreen(
text = stringRes(Res.string.marmot_edit_info_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -29,10 +28,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -52,13 +51,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -66,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_create_group
import com.vitorpamplona.amethyst.commons.resources.marmot_empty_create_action
import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name
import com.vitorpamplona.amethyst.commons.resources.marmot_groups_title
import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups
@@ -73,17 +71,29 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups_desc
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations_desc
import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests_count
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@@ -142,6 +152,11 @@ fun MarmotGroupListScreen(
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
// Above the tabs, because the state it reports is the reason both
// tabs can be empty: invites landing on another install look
// exactly like no invites at all.
MarmotInviteDeviceBanner(accountViewModel)
PrimaryTabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
@@ -204,13 +219,25 @@ fun MarmotGroupListScreen(
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
// Only the Known tab gets a button. There is no action
// that fills the New Requests tab — you cannot make
// someone invite you — so offering one there would be a
// dead end dressed up as a next step.
if (selectedTab == 0) {
Button(
onClick = { nav.nav(Route.CreateMarmotGroup) },
modifier = Modifier.padding(top = 20.dp),
) {
Text(stringRes(Res.string.marmot_empty_create_action))
}
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(visibleGroups, key = { it.first }) { (groupId, chatroom) ->
itemsIndexed(visibleGroups, key = { _, it -> it.first }) { index, (groupId, chatroom) ->
MarmotGroupListItem(
groupId = groupId,
chatroom = chatroom,
@@ -219,7 +246,11 @@ fun MarmotGroupListScreen(
nav.nav(Route.MarmotGroupChat(groupId))
},
)
HorizontalDivider()
// Between rows only: a divider under the last one draws a
// line across empty space with nothing beneath it.
if (index < visibleGroups.lastIndex) {
HorizontalDivider()
}
}
}
}
@@ -264,6 +295,54 @@ fun MarmotGroupListItem(
// to ~100 entries, so counting per recomposition is cheap.
val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime }
// A preview has to survive the messages that carry no text: a system row
// keeps its state in tags and MIP-04 media keeps its in imeta, so both used
// to render as a blank second line under the group name.
val myPubKey = accountViewModel.account.signer.pubKey
val previewEvent = newestMessage?.event
val previewBody =
when {
newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet)
previewEvent == null -> stringRes(Res.string.marmot_preview_no_text)
previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated)
// Text first: an attachment usually carries a caption, and showing
// "Attachment" over the words the sender actually wrote would be a
// step back from the raw `content` this replaced.
previewEvent.content.isNotBlank() -> previewEvent.content
hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media)
else -> stringRes(Res.string.marmot_preview_no_text)
}
// Only a genuinely known name earns the prefix: `bestName` returns null
// without metadata, and "a1b2c3d4: hi" is noise, not attribution.
//
// Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a
// plain StateFlow.value read, so a kind:0 arriving after the row composed
// would never reach it.
//
// Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only
// subscribes for a sender the cache already knows, and a sender it does not
// simply goes unprefixed. Creating a User per unknown sender would put a
// metadata REQ behind every row of a list that is mostly strangers' names
// the reader never asked for — a preview line is not worth that.
val senderUser =
previewEvent
?.pubKey
?.takeIf { it != myPubKey }
?.let { accountViewModel.getUserIfExists(it) }
val senderName =
if (senderUser != null) {
observeUserInfo(senderUser, accountViewModel).value?.info?.bestName()
} else {
null
}
val previewText =
// A system caption already names its actor, so prefixing one would say it twice.
if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) {
stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody)
} else {
previewBody
}
Row(
modifier =
Modifier
@@ -276,7 +355,7 @@ fun MarmotGroupListItem(
if (memberPubkeys.isNotEmpty()) {
NonClickableUserPictures(
userHexList = memberPubkeys,
size = 55.dp,
size = Size55dp,
accountViewModel = accountViewModel,
)
}
@@ -304,47 +383,36 @@ fun MarmotGroupListItem(
overflow = TextOverflow.Ellipsis,
)
}
if (newestMessage != null) {
Text(
text = newestMessage.event?.content ?: "",
style = MaterialTheme.typography.bodySmall,
Text(
text = previewText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
// The list is sorted by newestMessage.createdAt(), so showing that time
// is what makes the ordering legible; the message count it replaced was
// a number no reader was asking for.
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
newestMessage?.createdAt()?.let { createdAt ->
ToggleableTimeAgoText(
timestamp = createdAt,
style = TimeAgoStyle.Short,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
} else {
Text(
text = stringRes(Res.string.marmot_no_messages_yet),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
// The row is the tap target. A toggleable timestamp would
// swallow taps meant to open the group.
toggleable = false,
)
}
}
Column(horizontalAlignment = Alignment.End) {
if (unread > 0) {
Box(
modifier =
Modifier
.size(22.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(
text = if (unread > 99) "99+" else unread.toString(),
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
} else {
Text(
text = pluralStringResource(R.plurals.marmot_message_count, chatroom.messages.size, chatroom.messages.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
ChatUnreadBadge(
count = unread,
contentDescription = pluralStringResource(R.plurals.marmot_unread_messages, unread, unread),
)
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
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.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_banner
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_banner_dismiss
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_publish
import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* How long a passive owner check may reuse its previous answer. What it reports
* only changes when another device publishes, which no user does often.
*/
private const val OWNER_CHECK_MAX_AGE_SECONDS = 15L * 60L
/**
* Warns, on the Marmot groups screen, that another install of this account is
* the one receiving its invites.
*
* This is the symptom's own screen. A user whose invites are landing on a
* second device sees an empty group list and no new requests — exactly what
* "nobody has invited me" looks like — so without a banner here the failure is
* indistinguishable from nothing happening, and the diagnosis sits behind a
* settings row nobody has a reason to open.
*
* Publishing stays a tap, never automatic: an inviter takes the newest
* KeyPackage, so a banner that republished itself on sight would deadlock the
* two installs against each other, each device's correction being the other's
* trigger.
*/
@Composable
fun MarmotInviteDeviceBanner(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
var owner by remember { mutableStateOf<LatestKeyPackageOwner?>(null) }
// Saveable: a rotation is not a decision to un-dismiss a warning the user
// has already read and waved away.
var dismissed by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
// A read-only login cannot publish at all, so the only action this banner
// offers is impossible for it. Better to say nothing than to offer a button
// that silently does nothing.
val canPublish = accountViewModel.canPublish()
LaunchedEffect(Unit) {
if (!canPublish) return@LaunchedEffect
try {
owner =
withContext(Dispatchers.IO) {
// Passive check: reuse a recent answer rather than fanning a
// REQ across the whole write set on every entry to this screen.
accountViewModel.latestKeyPackageOwner(maxAgeSeconds = OWNER_CHECK_MAX_AGE_SECONDS)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// A relay we cannot reach is not evidence of anything, so it stays
// silent rather than accusing a device that may well be this one.
owner = null
}
}
AnimatedVisibility(visible = owner == LatestKeyPackageOwner.OTHER_DEVICE && !dismissed) {
// Message on its own line, actions under it. All three in one Row fit
// only on a wide screen with default font scale; at phone width with
// large fonts the label and the button fought for the same space.
//
// `surfaceVariant`, not `errorContainer`: this is an explanation, and
// most users will never see it. The error palette would make it the
// loudest thing on a screen whose actual subject is the group list.
Column(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 4.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Text(
text = stringRes(Res.string.marmot_invite_device_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, end = 4.dp).weight(1f),
)
IconButton(onClick = { dismissed = true }, modifier = Modifier.size(24.dp)) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(Res.string.marmot_invite_device_banner_dismiss),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(
onClick = {
// Hidden optimistically, but only *stays* hidden once a
// relay has accepted: `republishKeyPackage` waits for the
// OK, so a rejection, a read-only account or an empty relay
// set come back false instead of being reported as the
// success they are not.
owner = LatestKeyPackageOwner.THIS_DEVICE
scope.launch(Dispatchers.IO) {
val successMessage = stringRes(context, R.string.marmot_invite_device_success)
val rejectedMessage = stringRes(context, R.string.marmot_invite_device_rejected)
try {
val accepted = accountViewModel.republishKeyPackage()
launch(Dispatchers.Main) {
if (accepted) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, rejectedMessage, Toast.LENGTH_LONG).show()
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
}
} catch (e: CancellationException) {
// Leaving the screen mid-publish is not a failure to
// report, and swallowing it here would break the
// scope's cancellation as well as pop a toast for a
// banner that is already gone.
throw e
} catch (e: Exception) {
val failureMessage =
stringRes(context, R.string.marmot_invite_device_failure, e.message ?: "")
launch(Dispatchers.Main) {
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
// The warning was right after all, so put it back.
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
}
}
},
) {
Text(
text = stringRes(Res.string.marmot_invite_device_publish),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
}
@@ -20,32 +20,21 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */
private const val CONCORD_UNREAD_CAP = 99
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.CHAT_UNREAD_CAP
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
/**
* A small pill showing an unread-message [count] (new messages since this account last read).
* Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at
* [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers.
* [CHAT_UNREAD_CAP]+ and carries a plural content description for screen readers.
*
* The pill itself is [ChatUnreadBadge], shared with the other chat lists; this
* wrapper only supplies Concord's own plural so the two surfaces cannot drift
* apart visually.
*/
@Composable
fun ConcordUnreadBadge(
@@ -53,26 +42,9 @@ fun ConcordUnreadBadge(
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString()
val description = pluralStringResource(R.plurals.concord_unread_messages, count, count)
Box(
modifier =
modifier
.semantics { contentDescription = description }
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
// snapping — a small touch that makes new activity feel noticed.
.animateContentSize()
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
ChatUnreadBadge(
count = count,
contentDescription = pluralStringResource(R.plurals.concord_unread_messages, count, count),
modifier = modifier,
)
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
class UserProfileGalleryFeedFilter(
@@ -53,7 +54,10 @@ class UserProfileGalleryFeedFilter(
val addressableNotes =
LocalCache.addressables
.filter(
listOf(VideoVerticalEvent.KIND, VideoVerticalEvent.KIND),
// Both NIP-71 addressable kinds: acceptableEvent() takes any
// AddressableVideoEvent, so listing 34236 twice silently kept every
// 34235 (horizontal) video out of the gallery.
listOf(VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND),
user.pubkeyHex,
) { _, it ->
acceptableEvent(it, params, user)
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.kinds.KindNames
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -106,6 +107,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -144,6 +146,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
@@ -325,6 +328,9 @@ fun kindDisplayName(kind: Int): Int =
TorrentCommentEvent.KIND -> R.string.kind_torrent_comments
TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays
TrustProviderListEvent.KIND -> R.string.kind_trusted_providers
VideoCurationSetEvent.KIND -> R.string.kind_video_list
VideoCollaborationEvent.KIND -> R.string.kind_video_collaboration
TextTrackEvent.KIND -> R.string.kind_video_subtitles
VideoHorizontalEvent.KIND -> R.string.kind_video_repl
VideoVerticalEvent.KIND -> R.string.kind_shorts_repl
VideoNormalEvent.KIND -> R.string.kind_video
@@ -63,6 +63,8 @@ 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.resources.Res
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions_description
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast_description
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_download
@@ -359,6 +361,7 @@ fun videoPlayerActionName(action: VideoPlayerAction): String =
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download)
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip)
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast)
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions)
}
@Composable
@@ -371,4 +374,5 @@ fun videoPlayerActionDescription(action: VideoPlayerAction): String =
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download_description)
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip_description)
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast_description)
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions_description)
}
@@ -1471,4 +1471,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1350,4 +1350,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2611,4 +2611,5 @@
<string name="health_connect_rationale_activity_label">Health Connect a Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2393,4 +2393,5 @@
<string name="health_connect_rationale_activity_label">Health Connect und Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1306,4 +1306,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -108,4 +108,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1341,4 +1341,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1363,4 +1363,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1356,4 +1356,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1356,4 +1356,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1352,4 +1352,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1331,4 +1331,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1283,4 +1283,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1425,4 +1425,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2396,4 +2396,5 @@
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">मेरा स्वास्थ्य</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2396,4 +2396,5 @@
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">Fitnesz</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1274,4 +1274,5 @@ Seharusnya %3$s</string>
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1315,4 +1315,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1308,4 +1308,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1304,4 +1304,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1373,4 +1373,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2299,4 +2299,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2614,4 +2614,5 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">Moja kondycja</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2391,4 +2391,5 @@
<string name="health_connect_rationale_activity_label">Health Connect e Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1318,4 +1318,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -51,6 +51,7 @@
<string name="error_parsing_error_message">Ошибка при анализе сообщения об ошибке</string>
<string name="profile">Профиль</string>
<string name="security_filters">Фильтры безопасности</string>
<string name="show_less">Скрыть</string>
<string name="clink_requesting_invoice">Запрос счёта…</string>
<string name="clink_debit_no_response">Сервис списания не завершил платёж.</string>
<string name="clink_confirm_pay_amount_via_source">Оплатить %1$s через %2$s?</string>
@@ -117,6 +118,10 @@
<string name="password_is_required">Требуется пароль</string>
<string name="key_is_required">Нужно ввести ключ</string>
<string name="name_is_required">Требуется имя</string>
<string name="concord_invite_failed">Не удалось получить приглашение. Возможно, ссылка устарела или недоступна.</string>
<string name="concord_invite_links_title">Приглашения</string>
<string name="concord_invite_revoked_ok">Ссылка для приглашения отозвана.</string>
<string name="concord_channels_empty">Каналов еще нет.</string>
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
<!-- Title of the minichat (thread) screen opened from a chat message. -->
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
@@ -1391,4 +1396,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1386,4 +1386,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2535,4 +2535,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1361,4 +1361,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2391,4 +2391,5 @@
<string name="health_connect_rationale_activity_label">Health Connect och Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1339,4 +1339,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1325,4 +1325,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1177,4 +1177,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1322,4 +1322,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1386,4 +1386,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1341,4 +1341,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1292,4 +1292,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1832,4 +1832,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1326,4 +1326,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1326,4 +1326,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1302,4 +1302,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
+43
View File
@@ -225,6 +225,7 @@
<string name="public_chat">Public Chat</string>
<string name="marmot_group">Marmot Group</string>
<string name="marmot_groups_title">Marmot Groups</string>
<string name="public_chat_title">Public Chat Metadata</string>
<string name="public_chat_explainer">Public chats are visible to everyone on Nostr and anyone
can participate on them. They are great for open communities around specific topics.
@@ -1353,6 +1354,9 @@
<string name="child_safety_search_keywords" translatable="false">legal, safety, abuse, csae, child protection</string>
<string name="danger_zone">Danger Zone</string>
<string name="marmot_invite_device_success">This device now receives your Marmot invites.</string>
<string name="marmot_invite_device_failure">Failed to publish KeyPackage: %1$s</string>
<string name="marmot_invite_device_rejected">No relay accepted the KeyPackage. Check your relay settings and try again.</string>
<string name="reset_marmot_state">Reset Marmot State</string>
<string name="reset_marmot_success">Marmot state reset.</string>
<string name="reset_marmot_failure">Failed to reset Marmot state: %1$s</string>
@@ -2712,6 +2716,10 @@
<string name="nowhere_link_card_forum">Nowhere Forum</string>
<!-- Marmot (MLS) group chats -->
<plurals name="marmot_unread_messages">
<item quantity="one">%1$d new message</item>
<item quantity="other">%1$d new messages</item>
</plurals>
<plurals name="marmot_message_count">
<item quantity="one">%1$d msg</item>
<item quantity="other">%1$d msgs</item>
@@ -2796,4 +2804,39 @@
<!-- 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>
<string name="kind_video_collaboration">Video Collaboration</string>
<string name="kind_video_subtitles">Video Subtitles</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
<string name="video_credit_inspired_by">Inspired by</string>
<string name="video_credit_featuring">Featuring</string>
<string name="video_credit_references">References</string>
<string name="video_credit_audio_from">Audio from</string>
<string name="video_credit_accepted">Credit accepted</string>
<string name="video_collaboration_accepted_on">Accepted a collaboration credit</string>
<string name="video_collaboration_declined_on">Declined a collaboration credit</string>
<string name="video_curation_set_private_items">Private list</string>
<string name="nip51_list_has_private_members">This list has private members only its author can see</string>
<string name="kind_git_authors">Git authors</string>
<string name="kind_media_follows">Media follows</string>
<string name="kind_good_wiki_authors">Trusted wiki authors</string>
<string name="kind_good_wiki_relays">Trusted wiki relays</string>
<string name="kind_mute_set_for_kind">Muted for kind %1$s</string>
<string name="kind_media_starter_pack">Media starter pack</string>
<string name="kind_bookmark_set">Bookmark set</string>
<string name="kind_article_curation_set">Article list</string>
<string name="kind_picture_curation_set">Picture list</string>
<string name="kind_app_curation_set">App list</string>
<string name="kind_git_repositories">Git repositories</string>
<string name="kind_favorite_algo_feeds">Favorite feeds</string>
<string name="kind_interest_set">Interests</string>
<string name="kind_simple_groups">Groups</string>
<plurals name="video_curation_set_video_count">
<item quantity="one">%1$d video</item>
<item quantity="other">%1$d videos</item>
</plurals>
</resources>
@@ -2475,6 +2475,33 @@ class MarmotManager(
*/
suspend fun hasActiveKeyPackages(): Boolean = keyPackageRotationManager.hasActiveKeyPackages()
/**
* True when this install holds the private bundle for [event], a published
* kind:30443 — i.e. whether an invite issued against it could be opened here.
*
* Two installs of the same account each mint their own random d-tag slot
* ([KeyPackageRotationManager.getOrCreateSlotDTag]), so their KeyPackages
* never replace one another and both sit on relays indefinitely. An
* inviter then takes whichever has the highest `created_at`, and only the
* install holding that bundle can open the resulting Welcome.
*
* Matched on the MLS KeyPackage reference, which is a hash over the
* KeyPackage itself, and only on the Nostr event id when an event carries
* no ref tag. The id route alone is not safe to answer this question:
* [KeyPackageRotationManager.findBundleByEventId] resolves an id to a
* *slot* and then returns whatever bundle occupies that slot now, so once
* a slot has been regenerated it reports a stale id as still ours and we
* would hide a warning about invites we can no longer open. The ref cannot
* alias that way, and it is what the Welcome path matches on first.
*/
suspend fun ownsKeyPackage(event: KeyPackageEvent): Boolean {
val refHex = event.keyPackageRef()
if (refHex != null) {
return keyPackageRotationManager.findBundleByRef(refHex.hexToByteArray()) != null
}
return keyPackageRotationManager.findBundleByEventId(event.id) != null
}
/**
* Check if a specific group membership exists.
*/
@@ -182,6 +182,7 @@ import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.experimental.ratings.RelayReviewEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -299,6 +300,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -355,6 +357,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
@@ -3888,6 +3891,9 @@ open class EventCache :
is TrustProviderListEvent,
is VideoHorizontalEvent,
is VideoVerticalEvent,
is TextTrackEvent,
is VideoCollaborationEvent,
is VideoCurationSetEvent,
is WebBookmarkEvent,
is ExerciseTemplateEvent,
is PublicationIndexEvent,
@@ -0,0 +1,104 @@
/*
* 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.nip71Video
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.tags.TextTrackTag
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
/**
* One timed-text track a player can side-load, resolved from a video's `text-track` tag.
*
* [url] is what the player fetches. NIP-71 never says what media type a track is served as, and a
* blossom-hosted one has no file extension either, so [mimeType] is always declared — WebVTT
* unless the referenced [TextTrackEvent] says otherwise.
*/
@Immutable
data class CaptionTrack(
val url: String,
val mimeType: String = TextTrackEvent.DEFAULT_MIME_TYPE,
val language: String? = null,
// NIP-71 calls this the "type of supplementary information": captions, subtitles, chapters or
// metadata. Kept as published so a track picker can label and group by it.
val type: String? = null,
)
/**
* Splits a video's `text-track` tags into the ones a player can load on its own and the ones that
* first need a [TextTrackEvent] fetched.
*
* A `text-track` ref is either a URL (load it) or an addressable coordinate (resolve it). The same
* track is routinely published both ways — divine.video emits the Blossom URL and the
* `39307:<pubkey>:subtitles:<d>` coordinate side by side — so the two lists overlap by design and
* [mergeCaptionTracks] is what de-duplicates them once the events are in hand.
*/
fun VideoEvent.captionTracks(): ResolvedCaptionTracks {
val direct = mutableListOf<CaptionTrack>()
val pending = mutableListOf<PendingCaptionTrack>()
textTrack().forEach { tag ->
val address = Address.parse(tag.ref)
if (address != null) {
pending.add(PendingCaptionTrack(address, tag))
} else if (tag.ref.startsWith("http://", true) || tag.ref.startsWith("https://", true)) {
direct.add(CaptionTrack(url = tag.ref, language = tag.language, type = tag.type))
}
// Anything else — a bare event id, per NIP-71's own example — is not loadable as a file
// and has no address to resolve, so it is dropped rather than handed to the player as a
// URL it would fail to fetch.
}
return ResolvedCaptionTracks(direct, pending)
}
@Immutable
data class ResolvedCaptionTracks(
val direct: List<CaptionTrack>,
val pending: List<PendingCaptionTrack>,
)
@Immutable
data class PendingCaptionTrack(
val address: Address,
val tag: TextTrackTag,
)
/** The track a resolved [TextTrackEvent] contributes, or null when it hosts no fetchable file. */
fun TextTrackEvent.toCaptionTrack(tag: TextTrackTag? = null): CaptionTrack? {
val url = url() ?: return null
return CaptionTrack(
url = url,
mimeType = mimeType() ?: TextTrackEvent.DEFAULT_MIME_TYPE,
// The event is the more specific source: it was written by whoever made the track, while
// the tag on the video is the video author's description of it.
language = language() ?: tag?.language,
type = tag?.type,
)
}
/**
* Collapses tracks that point at the same file. A video that advertises one track twice — once as
* a URL and once as the event that hosts it — would otherwise hand the player two identical
* subtitle configurations, and ExoPlayer shows both as selectable tracks.
*/
fun mergeCaptionTracks(vararg lists: List<CaptionTrack>): List<CaptionTrack> = lists.flatMap { it }.distinctBy { it.url }
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.VideoMeta
import com.vitorpamplona.quartz.nip71Video.tags.HlsImetaTag
/**
* Picks the `imeta` entry a NIP-71 event's player should load.
@@ -58,7 +59,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoMeta
* keeps the placeholder and the layout intact when the master turns out to be the bare one.
*/
fun VideoEvent.selectVideoTrack(): VideoMeta? {
val imetas = imetaTags()
val imetas = imetaTags().flatMap { it.withHlsVariant() }
if (imetas.size <= 1) return imetas.firstOrNull()
val candidates = imetas.filter { it.canBeTheVideo() }
@@ -81,6 +82,20 @@ fun VideoEvent.selectVideoTrack(): VideoMeta? {
return selected.withLadderMetadataFrom(imetas)
}
// A publisher that keeps one imeta per video and hangs its adaptive manifest off the
// non-standard `hls` property (divine.video does; see [HlsImetaTag]) describes a ladder NIP-71
// would have written as a second imeta. Expand it into one, listed first so the HLS preference
// below reaches it, and drop the fields that describe the progressive file specifically: `x` and
// `size` are that blob's digest and byte count, and a manifest is neither.
private fun VideoMeta.withHlsVariant(): List<VideoMeta> {
val manifest = hls?.takeIf { it.isNotBlank() && it != url } ?: return listOf(this)
return listOf(
copy(url = manifest, mimeType = HlsImetaTag.MIME_TYPE, hash = null, size = null, hls = null),
copy(hls = null),
)
}
// A NIP-71 event asserts it is a video, so an imeta is a candidate unless it says otherwise:
// `audio/*` is a separate track under PR #2255, and `image/*` is a poster. Everything else —
// including the HLS playlist MIMEs, which are neither `video/*` nor an image, and a bare URL with
@@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateReplaceableTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -47,6 +49,12 @@ fun Event.hasEncryptedContent(): Boolean =
is SealedRumorEvent -> true
is GiftWrapEvent -> true
is LnZapRequestEvent -> isPrivateZap()
// Every NIP-51 list keeps its private members as a NIP-44 payload in `content`. The two
// base classes cover all ~30 list kinds, present and future, which is the point: naming
// them one at a time is how kind 30005 came to print its own ciphertext on screen.
// A list with no private members carries an empty `content`, and the guard below skips it.
is PrivateTagArrayEvent -> content.isNotEmpty()
is PrivateReplaceableTagArrayEvent -> content.isNotEmpty()
else -> false
}
@@ -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,
)
/**
@@ -28,7 +28,30 @@ import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
val SUPPORTED_VIDEO_FEED_MIME_TYPES = listOf("image/jpeg", "image/gif", "image/png", "image/webp", "video/mp4", "video/mpeg", "video/webm", "audio/aac", "audio/mpeg", "audio/webm", "audio/wav", "image/avif")
// The media types the video/shorts feeds will admit. HLS playlists are in here because a
// blossom-hosted manifest is `https://host/<sha256>` with no extension at all, so the MIME is the
// only thing SupportedContent can match on — without them an adaptive-only video is dropped from
// the feed entirely. All four spellings appear in the wild; RichTextParser.isHlsMimeType and
// MediaItemCache.toExoPlayerMimeType recognise the same set.
val SUPPORTED_VIDEO_FEED_MIME_TYPES =
listOf(
"image/jpeg",
"image/gif",
"image/png",
"image/webp",
"image/avif",
"video/mp4",
"video/mpeg",
"video/webm",
"audio/aac",
"audio/mpeg",
"audio/webm",
"audio/wav",
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/x-mpegurl",
"audio/mpegurl",
)
val SUPPORTED_VIDEO_FEED_MIME_TYPES_SET = SUPPORTED_VIDEO_FEED_MIME_TYPES.toSet()
val PictureAndVideoKinds =
@@ -21,7 +21,10 @@
package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
// URL-based media content models (KMP). The locally-cached variants
// (MediaPreloadedContent, MediaLocalImage, MediaLocalVideo) live in
@@ -115,6 +118,8 @@ open class MediaUrlVideo(
thumbhash: String? = null,
val isLiveStream: Boolean = false,
authorPubKey: String? = null,
// NIP-71 `text-track` captions resolved for this video, for the player to side-load.
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey)
@Immutable
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -176,6 +177,10 @@ object RenderableKinds {
VideoShortEvent.KIND,
VideoHorizontalEvent.KIND,
VideoVerticalEvent.KIND,
// A video list has a card of its own, so by the rule the omission list states — in
// ALL when Amethyst can render it — it belongs in the window. Its title and
// description are what Quartz indexes, which is exactly what a reader would search.
VideoCurationSetEvent.KIND,
AudioHeaderEvent.KIND,
AudioTrackEvent.KIND,
MusicTrackEvent.KIND,
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.model.privateChats
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -69,6 +71,50 @@ class ChatPreviewTest {
assertFalse(nip17Rumor().hasEncryptedContent())
}
// A NIP-51 list keeps its private members as a NIP-44 payload in `content`. This is the real
// wire content of a divine.video kind-30005 video list, which used to render on screen as the
// base64 blob it is.
private val nip44ListContent =
"AqQxhO1d0DzuJQtkWdshs3Fvi/1OXp2sYl9rDAykMBOvD06Tg0DLP69/i03iA1SD/RHVp+TEUoG8k+0FUkr7fiEcSyWx9k37o5URbcGsESdMK9d5vAFm9Yjp5tatUuZa2gJG"
private fun videoList(content: String = nip44ListContent) =
VideoCurationSetEvent(
id = "vl".padEnd(64, '0'),
pubKey = other,
createdAt = 1_000,
tags = arrayOf(arrayOf("d", "my_vine_list"), arrayOf("title", "My List")),
content = content,
sig = someSig,
)
private fun muteList(content: String = nip44ListContent) =
MuteListEvent(
id = "ml".padEnd(64, '0'),
pubKey = other,
createdAt = 1_000,
tags = arrayOf(arrayOf("p", stranger)),
content = content,
sig = someSig,
)
@Test
fun anAddressableListWithPrivateMembersCarriesCiphertext() {
assertTrue(videoList().hasEncryptedContent())
}
@Test
fun aStandardListWithPrivateMembersCarriesCiphertext() {
assertTrue(muteList().hasEncryptedContent())
}
@Test
fun anAllPublicListHasNothingToHide() {
// No private members means no payload, and an empty string is not ciphertext — saying it
// was would hide a perfectly readable list behind a "private" label.
assertFalse(videoList(content = "").hasEncryptedContent())
assertFalse(muteList(content = "").hasEncryptedContent())
}
// ---- chatPreviewOf -------------------------------------------------------
@Test
@@ -147,7 +147,7 @@ class KindRegistryTest {
assertEquals(
// 7516 is a found log: read inside a cache's thread, where `kind:geocache` already
// leads, rather than looked up on its own.
listOf(24, 54, 1018, 1111, 1337, 1808, 7516, 10001, 10003, 30000, 30001, 30053, 30296, 30297, 30817, 31337),
listOf(24, 54, 1018, 1111, 1337, 1808, 7516, 10001, 10003, 30000, 30001, 30005, 30053, 30296, 30297, 30817, 31337),
RenderableKinds.ALL.filter { KindRegistry.nameFor(it) == null }.sorted(),
)
}

Some files were not shown because too many files have changed in this diff Show More