mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge remote-tracking branch 'origin/main' into claude/clever-brown-ln4hj2
This commit is contained in:
@@ -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
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+25
@@ -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()
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+8
-1
@@ -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,
|
||||
)
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+157
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -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)
|
||||
+87
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+31
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -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 })
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+55
-4
@@ -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 {
|
||||
|
||||
+1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
@@ -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
|
||||
+217
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+148
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -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 }
|
||||
+163
@@ -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) }
|
||||
}
|
||||
}
|
||||
+21
@@ -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.
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+82
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -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,
|
||||
)
|
||||
|
||||
+8
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+113
-45
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+210
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-40
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+27
@@ -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.
|
||||
*/
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+104
@@ -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 }
|
||||
+16
-1
@@ -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
|
||||
|
||||
+8
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+24
-1
@@ -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 =
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+46
@@ -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
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user