Merge origin/main into claude/kind-keller-itzy63

41 commits, including Marmot work (the invite-device check, the groups screen
in the drawer, KeyPackage slot retention). One conflict, in
`amethyst/src/main/res/values/strings.xml`, and it was purely additive: both
sides appended, and both happened to end mid-`<plurals>` sharing the file's
last `</plurals>`. Kept both, with a closer added for mine.

Checked for a semantic collision rather than trusting a clean auto-merge:
main touched no file under `marmot/protocolCore/` and did not touch
`MarmotGroupPolicy`, so nothing lands near this branch's policy-seam fix.

Marmot's interop suites, which is what this merge is worth running:
**756 Marmot + MLS tests, 0 failures**, including every cross-implementation
one — `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest`,
`AppDataDictionaryInteropTest`, `MlsConformanceTest` (RFC 9420 vectors),
`PassiveClientInteropTest`, plus the TreeKEM, key-schedule, transcript-hash,
secret-tree and welcome vector suites, and `MarmotMipComplianceTest` and
`MarmotConvergenceWiringTest`.

The shell harness `cli/tests/marmot/marmot-interop.sh` was NOT run: it drives
whitenoise's `wn`/`wnd` Rust binaries, which are not in this container.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-22 20:25:28 +00:00
211 changed files with 6658 additions and 405 deletions
@@ -138,6 +138,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
@@ -317,6 +317,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
@@ -1237,6 +1238,12 @@ class Account(
}
}
suspend fun changeCaptionsEnabled(enabled: Boolean) {
if (settings.changeCaptionsEnabled(enabled)) {
sendNewAppSpecificData()
}
}
suspend fun changeVideoPlayerButtonItems(items: List<VideoPlayerButtonItem>) {
if (settings.changeVideoPlayerButtonItems(items)) {
sendNewAppSpecificData()
@@ -2337,7 +2344,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
@@ -330,6 +368,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
}
}
@@ -350,6 +392,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
}
/**
@@ -400,6 +445,98 @@ class AccountMarmotActions(
return manager.hasActiveKeyPackages()
}
/**
* Ask the relays which install currently owns this account's invites.
*
* Deliberately a read, never a self-correcting one. Republishing whenever
* the answer is [LatestKeyPackageOwner.OTHER_DEVICE] would deadlock two
* installs against each other — each device's correction is the other's
* trigger, and neither ever settles — so the decision belongs to the user,
* with this as the evidence.
*
* Queries the same set we publish our own KeyPackages to, which is the
* inviter's view of us minus their own outbox: `fetchRelaysFor` unions our
* NIP-65 write set and our legacy kind:10051 with the inviter's outbox, and
* the first two are exactly [keyPackagePublishRelays].
*/
suspend fun latestKeyPackageOwner(maxAgeSeconds: Long = 0L): LatestKeyPackageOwner {
val manager = account.marmotManager ?: return LatestKeyPackageOwner.NONE
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return LatestKeyPackageOwner.NONE
// A passive caller (the groups-screen banner) may reuse a recent answer.
// Without this, every entry to that screen fanned a REQ out across the
// whole write set — and the thing it asks about only changes when
// another device publishes, which is rare enough to cache.
val cached = lastOwnerCheck
if (maxAgeSeconds > 0 && cached != null) {
val maxAge =
if (cached.second == LatestKeyPackageOwner.NONE) {
minOf(maxAgeSeconds, NONE_MAX_AGE_SECONDS)
} else {
maxAgeSeconds
}
if (TimeUtils.now() - cached.first <= maxAge) return cached.second
}
val latest = KeyPackageFetcher.fetchKeyPackage(account.client, account.signer.pubKey, relays)
val owner =
when {
latest == null -> LatestKeyPackageOwner.NONE
manager.ownsKeyPackage(latest) -> LatestKeyPackageOwner.THIS_DEVICE
else -> LatestKeyPackageOwner.OTHER_DEVICE
}
Log.d("MarmotDbg") {
"latestKeyPackageOwner: newest KeyPackage id=${latest?.id?.take(8)}… " +
"createdAt=${latest?.createdAt} owner=$owner"
}
// Cached even when nothing was found: that answer cost the same fan-out
// as any other, so leaving it uncached would re-run the whole query on
// every entry for exactly the accounts with nothing on their relays.
lastOwnerCheck = TimeUtils.now() to owner
return owner
}
/**
* The user-initiated republish behind the invite-device banner and the
* settings row. Returns whether a relay actually accepted the KeyPackage.
*
* Deliberately not [publishMarmotKeyPackage]. That one is the best-effort
* startup path: it early-returns in silence for a read-only account or an
* empty relay set and then hands the event to a fire-and-forget
* `client.publish`, so a caller reporting the outcome to someone watching
* would call every one of those failures a success.
*/
suspend fun republishKeyPackageConfirmed(): Boolean {
val manager = account.marmotManager ?: return false
if (!account.isWriteable()) return false
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return false
// Minting no longer destroys the displaced bundle — the rotation
// manager retains it, keyed by the event id it was published as — but
// every regeneration still costs a keypair, a relay round trip, and a
// slot in the bounded retention map, where it can evict a bundle
// someone is about to invite us through. Republishing when we already
// own the newest KeyPackage buys none of that back, since the answer
// cannot change, so that case is a no-op reporting success truthfully.
if (latestKeyPackageOwner() == LatestKeyPackageOwner.THIS_DEVICE) {
Log.d("MarmotDbg") { "republishKeyPackageConfirmed: already the newest; not minting" }
return true
}
val event = manager.generateKeyPackageEvent(relays.toList())
account.cache.justConsumeMyOwnEvent(event)
val accepted = account.client.publishAndConfirm(event, relays)
Log.d("MarmotDbg") {
"republishKeyPackageConfirmed: id=${event.id.take(8)}… accepted=$accepted on ${relays.size} relay(s)"
}
// The answer we just changed; a stale cache would keep the banner up.
lastOwnerCheck = if (accepted) TimeUtils.now() to LatestKeyPackageOwner.THIS_DEVICE else null
return accepted
}
/**
* Create a new Marmot MLS group under the CURRENT profile.
*
@@ -410,6 +410,15 @@ class AccountSettings(
return false
}
fun changeCaptionsEnabled(enabled: Boolean): Boolean {
if (syncedSettings.videoPlayer.captionsEnabled.value != enabled) {
syncedSettings.videoPlayer.captionsEnabled.tryEmit(enabled)
saveAccountSettings()
return true
}
return false
}
fun changeAudioVisualizer(style: VisualizerStyle): Boolean {
if (syncedSettings.media.audioVisualizer.value != style) {
syncedSettings.media.audioVisualizer.tryEmit(style)
@@ -70,6 +70,7 @@ class AccountSyncedSettings(
val videoPlayer =
AccountVideoPlayerPreferences(
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
MutableStateFlow(internalSettings.videoPlayer.captionsEnabled),
)
val media =
AccountMediaPreferences(
@@ -118,7 +119,7 @@ class AccountSyncedSettings(
security.sendKind0EventsToLocalRelay.value,
security.addClientTag.value,
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value, videoPlayer.captionsEnabled.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats =
AccountChatPreferencesInternal(
@@ -208,6 +209,10 @@ class AccountSyncedSettings(
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
}
if (videoPlayer.captionsEnabled.value != syncedSettingsInternal.videoPlayer.captionsEnabled) {
videoPlayer.captionsEnabled.tryEmit(syncedSettingsInternal.videoPlayer.captionsEnabled)
}
val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer)
if (media.audioVisualizer.value != newAudioVisualizer) {
media.audioVisualizer.tryEmit(newAudioVisualizer)
@@ -254,6 +259,7 @@ class AccountReactionPreferences(
@Stable
class AccountVideoPlayerPreferences(
var buttonItems: MutableStateFlow<ImmutableList<VideoPlayerButtonItem>>,
val captionsEnabled: MutableStateFlow<Boolean>,
)
@Stable
@@ -107,6 +107,7 @@ enum class VideoPlayerAction {
Download,
PictureInPicture,
Cast,
Captions,
}
@Serializable
@@ -127,6 +128,7 @@ val DefaultVideoPlayerButtonItems =
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Captions, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
@@ -184,6 +186,10 @@ class AccountNavigationPreferencesInternal(
@Serializable
class AccountVideoPlayerPreferencesInternal(
var buttonItems: List<VideoPlayerButtonItem> = DefaultVideoPlayerButtonItems,
// Feed videos autoplay muted, so captions are what make them legible — on by default, and
// remembered once the viewer says otherwise. Defaulted rather than required so a blob written
// by a client that predates the field still decodes.
var captionsEnabled: Boolean = true,
)
@Serializable
@@ -0,0 +1,180 @@
/*
* 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.notifications
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import androidx.core.app.Person
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
* The long-lived shortcut that turns a MessagingStyle notification into an actual
* *conversation* as far as the system is concerned.
*
* MessagingStyle on its own is only half the contract. Since Android 11 the shade sorts a
* notification into the Conversations section — and offers the per-conversation controls
* that come with it (mark a person Priority, silence one room without silencing every DM) —
* only when the notification names a **long-lived shortcut** through `setShortcutId`.
* Without one it is ranked as an ordinary alerting notification, which is what every DM,
* group message and Buzz room got before this existed, despite the KDoc claiming otherwise.
*
* The same shortcut is the prerequisite for two things worth having later: bubbles
* (`setBubbleMetadata`) and Direct Share targets. Neither is wired yet.
*
* ### What gets published
*
* One shortcut per conversation, keyed by [NotificationUtils.Conversation.id] — which
* includes the account, so the same counterparty under two logins does not collapse into one
* launcher entry pointing at the wrong inbox. The intent is the same deep link the
* notification taps through ([NotificationRoutes]), so the launcher entry and the tap agree.
*
* ### Why this is gated on a setting
*
* A dynamic shortcut is visible in the launcher's long-press menu: publishing one puts a
* contact's name and avatar outside the app, where someone holding the phone can read it
* without unlocking anything of ours. That is the disclosure `showMessagesInNotifications`
* exists to control, so callers pass [NotificationUtils.Conversation] only when it is on.
*/
object ConversationShortcuts {
private const val TAG = "ConversationShortcuts"
/**
* Publishes (or refreshes) the conversation's shortcut and returns its id, or null when
* the system would not take it.
*
* Null matters: a `setShortcutId` pointing at a shortcut that does not exist is worse
* than none — the system logs it and still refuses the conversation treatment — so the
* caller must only stamp the notification when this succeeded.
*
* [ShortcutManagerCompat.pushDynamicShortcut] already handles the two things that bite
* here: it evicts the least-recently-used shortcut when the per-activity cap is reached,
* and it is rate-limit aware. It can still throw on a malformed shortcut, so the whole
* call is guarded — a conversation that cannot be published is a notification that
* renders normally, never a crash on the notification path.
*/
fun push(
context: Context,
conversation: NotificationUtils.Conversation,
uri: String,
person: Person,
icon: Bitmap?,
): String? =
try {
val target =
Intent(context, MainActivity::class.java).apply {
// A shortcut intent without an action is rejected outright.
action = Intent.ACTION_VIEW
data = uri.toUri()
}
val shortcut =
ShortcutInfoCompat
.Builder(context, conversation.id)
.setShortLabel(conversation.label)
.setLongLabel(conversation.label)
.setIntent(target)
// Without this the system drops the shortcut as soon as it leaves the
// dynamic list, and the conversation loses its history and its ranking.
.setLongLived(true)
.apply {
// A group is not a person. Attaching the speaker would make the
// launcher entry's face flip to whoever last spoke while the label
// stayed the group, and would rank the chat as that one contact.
if (!conversation.isGroup) setPerson(person)
// Plain, not adaptive: `icon` is already circle-cropped, and the
// adaptive mask would crop that circle a second time — zoomed, with
// the edges clipped off.
icon?.let { setIcon(IconCompat.createWithBitmap(it)) }
}.build()
if (ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)) {
conversation.id
} else {
Log.d(TAG) { "System declined the shortcut for ${conversation.label}" }
null
}
} catch (e: Exception) {
// Builder.build() throws on a malformed shortcut (an empty label, say) and it is
// inside the guard for that reason: this runs from postConversation, whose caller
// swallows exceptions, so a throw escaping here would silently drop the whole
// notification rather than just its conversation status.
Log.d(TAG) { "Could not publish a shortcut for ${conversation.label}: ${e.message}" }
null
}
/**
* Drops every conversation this account published into the launcher.
*
* Logging out has to take these with it. They are the one part of an account that lives
* outside the app's own storage — names and avatars of people it talked to, sitting in the
* launcher's long-press menu — so an account that is gone from Amethyst but still lists its
* contacts on the home screen would be a worse leak than not publishing them at all.
*
* Long-lived shortcuts are also removed from the system's cache, not just the dynamic list:
* the cache is what keeps a conversation alive after it falls out of the top slots, so
* dropping only the dynamic copy would leave it recoverable.
*/
fun removeForAccount(
context: Context,
accountNpub: String,
) {
val scope = ":$accountNpub:"
try {
// Every list the system keeps, not just the dynamic one. `pushDynamicShortcut`
// evicts the least-recently-used shortcut from the dynamic list when the cap is
// reached, and a long-lived shortcut that has been used in a notification is kept
// in the system *cache* rather than deleted — so the conversations most likely to
// have fallen out of the dynamic list are exactly the ones a dynamic-only sweep
// would leave behind, which is the leak this is here to close.
val mine =
ShortcutManagerCompat
.getShortcuts(
context,
ShortcutManagerCompat.FLAG_MATCH_DYNAMIC or
ShortcutManagerCompat.FLAG_MATCH_CACHED or
ShortcutManagerCompat.FLAG_MATCH_PINNED,
).map { it.id }
.filter { it.contains(scope) }
if (mine.isEmpty()) return
// A shortcut the user pinned to their home screen cannot be deleted by us, only
// disabled — so disable first, then remove. Without this a pinned conversation
// would survive the logout as a live tile.
ShortcutManagerCompat.disableShortcuts(
context,
mine,
stringRes(context, R.string.app_notification_shortcut_account_removed),
)
ShortcutManagerCompat.removeLongLivedShortcuts(context, mine)
} catch (e: Exception) {
Log.d(TAG) { "Could not clear shortcuts for $accountNpub: ${e.message}" }
}
}
}
@@ -0,0 +1,171 @@
/*
* 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.notifications
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
* What became of a reply the user typed into the shade.
*
* The three outcomes are not two: a send that has neither returned nor thrown is its own
* thing, and saying so is the only honest option — see [Unconfirmed].
*/
sealed interface ReplyState {
/** On its way. [text] joins the thread so the user can see what they sent. */
data class Sending(
val text: String,
) : ReplyState
/** It went out — drop the marker and leave the thread as it now reads. */
data object Sent : ReplyState
/**
* Long enough that "Sending…" has stopped being true, without an answer either way.
*
* A broadcast receiver is killed around ten seconds in, and a NIP-17 send can exceed that
* on its own: an Amber round trip, a relay publish, proof-of-work mining. Whatever the
* shade is showing when the process dies is what it shows until the conversation is read,
* so it must not be a claim we cannot stand behind.
*
* Deliberately offers no Retry. The message may well have gone out — re-sending would
* deliver it twice, which is worse than leaving the user to open the app and look.
*/
data class Unconfirmed(
val text: String,
) : ReplyState
/**
* It threw. [retry] re-sends the same text on one tap, carrying the text itself because a
* RemoteInput cannot be pre-filled and re-typing it is the thing this prevents.
*/
data class Failed(
val text: String,
val retry: PendingIntent,
) : ReplyState
}
/**
* Re-renders the live notification for [notId] to say what happened to an inline reply.
*
* Until this existed a reply typed in the shade was posted into silence: the send either
* worked, and the notification vanished with no sign the message had gone, or it threw, and
* nothing at all happened — same notification, text gone, user believing it sent. A denied
* signature or a dead socket was indistinguishable from delivery.
*
* The live notification is the only place the reply can be shown, so it is rebuilt from
* itself rather than from scratch: [NotificationCompat.Builder] can recover a builder from a
* posted [Notification], and MessagingStyle can be extracted and extended. Callers that are
* not conversations (BigText notifications carrying an inline reply) have no thread to append
* to and get `setRemoteInputHistory`, which is the same idea in the shape that style supports.
*
* Returns false when nothing is posted under [notId] any more — the user swiped it away while
* the reply was in flight. Nothing is re-posted in that case: they are done with it.
*/
fun NotificationManager.renderReplyState(
applicationContext: Context,
notId: Int,
state: ReplyState,
): Boolean =
try {
renderReplyStateOrThrow(applicationContext, notId, state)
} catch (e: Exception) {
// Feedback must never break the thing it reports on. Reading the live notification
// and posting it back both go through the system, and both can throw (a revoked
// POST_NOTIFICATIONS between check and notify, say) — and this runs either side of
// the send, where an escape would cost the user the reply itself.
Log.w(TAG, "Could not render the reply state", e)
false
}
private const val TAG = "InlineReplyFeedback"
private fun NotificationManager.renderReplyStateOrThrow(
applicationContext: Context,
notId: Int,
state: ReplyState,
): Boolean {
val existing = activeNotifications.firstOrNull { it.id == notId }?.notification ?: return false
val builder =
NotificationCompat
.Builder(applicationContext, existing)
// The thread already alerted when the message arrived; an update about the user's
// own reply must not buzz again.
.setOnlyAlertOnce(true)
val style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(existing)
when (state) {
is ReplyState.Sending -> {
// `null` attributes the message to the MessagingStyle's own user.
style?.addMessage(state.text, System.currentTimeMillis(), null as Person?)
?: builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_sending))
}
ReplyState.Sent -> {
// Sending already put the text in the thread — appending here would double it.
builder.setSubText(null)
}
is ReplyState.Unconfirmed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_unconfirmed))
}
is ReplyState.Failed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_failed))
val retryLabel = stringRes(applicationContext, R.string.app_notification_reply_retry)
// Rebuilt from the posted notification, so its actions come with it — and a bare
// clearActions() would take Reply and Mark Read with them, leaving a failed reply
// with no way to write a different one. Keep them, drop any Retry left by an
// earlier attempt so repeated failures don't stack, then add this one.
val kept =
(0 until NotificationCompat.getActionCount(existing))
.mapNotNull { NotificationCompat.getAction(existing, it) }
.filter { it.title?.toString() != retryLabel }
builder.clearActions()
kept.forEach { builder.addAction(it) }
builder.addAction(
NotificationCompat.Action
.Builder(R.drawable.ic_action_reply, retryLabel, state.retry)
.build(),
)
}
}
style?.let { builder.setStyle(it) }
notify(notId, builder.build())
return true
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -46,11 +47,22 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NotificationReplyReceiver : BroadcastReceiver() {
companion object {
/**
* How long a send may run before the notification stops claiming it is sending. Set
* under the ten seconds a foreground broadcast gets, so the honest state is rendered
* while this process is still alive to render it.
*/
private const val UNCONFIRMED_AFTER_MS = 8_000L
}
override fun onReceive(
context: Context,
intent: Intent,
@@ -88,12 +100,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
}
NotificationUtils.REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
@@ -102,35 +109,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendReply(accountNpub, members, replyText)
}
}
NotificationUtils.PUBLIC_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendPublicReply(accountNpub, targetEventId, replyText)
}
}
NotificationUtils.MARMOT_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
@@ -138,22 +135,51 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
}
}
}
}
/**
* The text of an inline reply: typed into the shade, or carried by a Retry re-sending one
* that failed. A RemoteInput cannot be pre-filled, so a retry has to bring its own copy.
*/
private fun replyTextFrom(intent: Intent): String? =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
?: intent.getStringExtra(NotificationUtils.KEY_REPLY_TEXT)
/**
* Sends [block] and keeps the notification honest about how it went.
*
* The notification stays up rather than being cancelled on success. That is what every
* other messenger does — the reply appears in the thread you replied to — and it is what
* makes a failure visible at all: there is something left on screen to put the error on.
* It clears the usual way, when the conversation is read in the app.
*
* The dismissal guard is recorded on **both** outcomes. On success it stops the enrichment
* window resurrecting the pre-reply version seconds later; on failure it stops that same
* re-render overwriting the error and the Retry that carries the user's text. A nicer
* avatar is not worth losing an unsent message to.
*/
private fun runOnRelay(
context: Context,
notificationManager: NotificationManager,
notificationId: Int,
eventId: String?,
replyText: String,
source: Intent,
block: suspend () -> Unit,
) {
val pendingResult = goAsync()
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val appContext = context.applicationContext
scope.launch {
val collectionJob =
scope.launch {
@@ -161,13 +187,48 @@ class NotificationReplyReceiver : BroadcastReceiver() {
.collect()
}
// Stops "Sending…" becoming a lie the shade keeps telling. A broadcast receiver is
// killed around ten seconds in, and a NIP-17 send can outlive that on its own — an
// Amber round trip, a relay publish, proof-of-work mining — so whatever is on
// screen at that moment is what the user is left with.
//
// A watchdog, deliberately, not a `withTimeout`: timing the send out would *cancel*
// it, and a publish cancelled halfway is a worse outcome than a slow one. This only
// changes what the notification says. If the send does finish afterwards, the Sent
// or Failed render below overwrites it.
val watchdog =
scope.launch {
delay(UNCONFIRMED_AFTER_MS)
notificationManager.renderReplyState(
appContext,
notificationId,
ReplyState.Unconfirmed(replyText),
)
}
try {
// Inside the try: everything from here on must reach the `finally`, which is
// what calls pendingResult.finish() and releases the broadcast.
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sending(replyText))
block()
// Joined, not just cancelled: if the watchdog is already inside its render, the
// two notify() calls would land in an undefined order and the shade could keep
// "Still sending…" over a message that went out.
watchdog.cancelAndJoin()
eventId?.let { NotificationUtils.markDismissed(it) }
notificationManager.cancelAndPrune(notificationId)
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sent)
} catch (e: Exception) {
// Rethrown first: joining is a suspend call, which an already-cancelled
// coroutine cannot make. `scope.cancel()` below takes the watchdog with it.
if (e is CancellationException) throw e
watchdog.cancelAndJoin()
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
eventId?.let { NotificationUtils.markDismissed(it) }
notificationManager.renderReplyState(
appContext,
notificationId,
ReplyState.Failed(replyText, retryIntent(appContext, notificationId, replyText, source)),
)
} finally {
pendingResult.finish()
collectionJob.cancel()
@@ -176,6 +237,37 @@ class NotificationReplyReceiver : BroadcastReceiver() {
}
}
/**
* A one-tap re-send of exactly what the user typed.
*
* A copy of [source] — which already carries the action and the account, room, group or
* target this reply was addressed to — plus the text. Keeping the original action is what
* makes this free: [onReceive] routes it back to the branch it came from with no alias to
* resolve, the dismissal guard already treats it as the reply action it is, and
* [replyTextFrom] already prefers a RemoteInput result and falls back to this extra.
*/
private fun retryIntent(
applicationContext: Context,
notificationId: Int,
replyText: String,
source: Intent,
): PendingIntent {
val intent =
Intent(source).apply {
setClass(applicationContext, NotificationReplyReceiver::class.java)
putExtra(NotificationUtils.KEY_REPLY_TEXT, replyText)
}
return PendingIntent.getBroadcast(
applicationContext,
// The other three request codes for this notification are notId, +1 (mark read)
// and +2 (dismiss).
notificationId + 3,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private suspend fun sendReply(
accountNpub: String,
chatroomMembers: List<String>,
@@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.toNpub
/** Deep-link URIs consumed by `MainActivity.uriToRoute` when a notification is tapped. */
@@ -38,11 +39,88 @@ object NotificationRoutes {
.hexToByteArray()
.toNpub()
/** Opens the note directly (used for replies, mentions, DMs, media, git). */
/**
* Opens the note directly (used for replies, mentions, media, git).
*
* A note delivered inside an envelope (a NIP-17 private note or reply) cannot be
* addressed this way — see [privateNoteUri].
*/
fun noteUri(
note: Note,
accountNpub: String,
): String = note.toNEvent() + ACCOUNT + accountNpub
): String =
if (note.rumorHost != null) {
privateNoteUri(note.idHex, accountNpub)
} else {
note.toNEvent() + ACCOUNT + accountNpub
}
/**
* Opens a NIP-17 private note or reply by the rumor's own id.
*
* [Note.toNEvent] cannot address one: for a rumor it encodes the kind-1059 gift wrap
* that delivered it, because the rumor id must never appear in anything that leaves
* for a relay (`RumorHostCitationTest` pins that, and [Note.isPrivateRumor] guards the
* publish paths). Two things then go wrong on a tap. The wrap is unfetchable — the
* general event finder asks the account's read relays for an id only the DM inbox
* relays ever held. And the wrap note emits exactly once, from `consumeRegularEvent`,
* *before* it is decrypted, so `routeFor` sees a null `innerEventId` and the redirect
* never fires again (the later `event = copyNoContent()` is a plain assignment).
*
* The rumor id has neither problem: its note emits when the rumor is consumed, and the
* always-on one-week gift-wrap tail re-delivers and re-unwraps the envelope on every
* cold start, so a push-recent message always comes back on its own. This URI is read
* only by `MainActivity.uriToRoute`, inside our own process, and the route it produces
* never puts the id in a REQ — see `Route.EventRedirect.isPrivate`.
*/
fun privateNoteUri(
rumorId: String,
accountNpub: String,
): String = "privatenote?id=$rumorId&account=$accountNpub"
/**
* Opens a NIP-17 / NIP-04 private chatroom (DM notifications).
*
* A DM must NOT deep-link through its own note: [Note.toNEvent] cites the kind-1059
* gift wrap that delivered the rumor (it has to — the rumor id is the private event's
* identity and resolves to nothing on a public relay, see `RumorHostCitationTest`).
* Resolving that nevent back to a room needs the wrap *and* seal notes to still be in
* the in-memory `LocalCache`. A push usually wakes a cold process, so by the time the
* user taps, the cache is empty — and no relay will ever serve that wrap again, so the
* tap landed on a `Route.EventRedirect` that could never resolve.
*
* The room key is derived locally and needs no event at all, so this survives the
* process dying. Same idea as [relayGroupUri], which routes a Buzz DM through the
* channel's naddr rather than the message.
*/
fun chatroomUri(
room: ChatroomKey,
accountNpub: String,
): String = "chatroom?id=${room.users.joinToString(",")}&account=$accountNpub"
// ---------------------------------------------------------------------
// Conversation shortcut ids
//
// Stable for the life of the chat and scoped to the account, so the same counterparty
// under two logins does not collapse into one launcher entry pointing at the wrong
// inbox. Members are sorted because a room key is a set — ordering must not create a
// second shortcut for a chat that already has one.
// ---------------------------------------------------------------------
fun chatroomShortcutId(
room: ChatroomKey,
accountNpub: String,
): String = "dm:$accountNpub:" + room.users.sorted().joinToString(",")
fun marmotShortcutId(
nostrGroupId: String,
accountNpub: String,
): String = "marmot:$accountNpub:$nostrGroupId"
fun relayGroupShortcutId(
channelNAddr: String,
accountNpub: String,
): String = "relaygroup:$accountNpub:$channelNAddr"
/** Opens the Notifications tab, scrolled to [scrollToId] (used for zaps, reactions, chess). */
fun notificationsUri(
@@ -50,7 +128,13 @@ object NotificationRoutes {
scrollToId: String,
): String = "notifications$ACCOUNT$accountNpub$SCROLL_TO$scrollToId"
/** Opens a Marmot group chatroom (welcome + group message). */
/**
* Opens a Marmot group chatroom (welcome + group message).
*
* Note the query here rides on an *opaque* URI (a scheme with no `//`), so its
* `?account=` can only be read by [String.findQueryParameterValue] — see the note
* there; `java.net.URI.rawQuery` is null for this shape.
*/
fun marmotUri(
nostrGroupId: String,
accountNpub: String,
@@ -37,6 +37,7 @@ import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
@@ -80,6 +81,7 @@ object NotificationUtils {
* [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to.
*/
const val KEY_EVENT_ID = "key_event_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
@@ -166,6 +168,29 @@ object NotificationUtils {
) : ReplyAction
}
/**
* Identity of the chat a conversation notification belongs to, so the system can treat it
* as one — see [ConversationShortcuts] for what that buys and why it needs a shortcut.
*
* [id] must be stable for the life of the conversation and unique across accounts; [label]
* is what the conversation is called, which is not always the sender (a group message is
* from a person but belongs to the group).
*
* Callers pass this only when the account allows message content in notifications: the
* shortcut it publishes is readable from the launcher.
*/
data class Conversation(
val id: String,
val label: String,
/**
* The chat's own picture, when it has one. Null falls back to the message sender's
* avatar, which is the right face for a one-to-one DM and the wrong one for a group —
* hence [isGroup], which suppresses that fallback along with the sender `Person`.
*/
val iconUrl: String? = null,
val isGroup: Boolean = false,
)
/** A prior message rendered above the main one in a MessagingStyle notification (thread context). */
data class ParentMessage(
val senderName: String,
@@ -281,6 +306,7 @@ object NotificationUtils {
replyAction: ReplyAction? = null,
publicInlineReply: InlineReplyTarget? = null,
addMarkRead: Boolean = true,
conversation: Conversation? = null,
groupKey: String = category.group,
summaryId: Int = category.summaryId,
) {
@@ -330,6 +356,17 @@ object NotificationUtils {
val contentPendingIntent = contentIntent(applicationContext, notId, uri)
// Published before the notification that names it: a shortcutId the system cannot
// resolve is worse than none, so the id is only stamped below when this succeeded.
val shortcutId =
conversation?.let {
val conversationIcon =
it.iconUrl?.let { url -> loadBitmap(url, applicationContext)?.let { bmp -> circleCrop(bmp) } }
?: avatar.takeUnless { _ -> it.isGroup }
ConversationShortcuts.push(applicationContext, it, uri, sender, conversationIcon)
}
val builderPublic =
NotificationCompat
.Builder(applicationContext, channelId)
@@ -360,6 +397,15 @@ object NotificationUtils {
.setOnlyAlertOnce(true)
.setWhen(time * 1000)
// The three halves of the conversation contract: the shortcut the shade resolves, the
// locus that ties this notification to it, and the people it is with. All three have to
// be present or the notification is ranked as an ordinary alert.
if (shortcutId != null) {
builder.setShortcutId(shortcutId)
builder.setLocusId(LocusIdCompat(shortcutId))
}
builder.addPerson(sender)
when (replyAction) {
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction))
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction))
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
@@ -82,10 +83,23 @@ object BuzzDmNotification {
val accountNpub = NotificationRoutes.accountNpub(account)
// The channel's kind-39000 naddr routes straight to the chatroom via the
// existing naddr → Route.RelayGroup path (no message load needed on tap).
val channelNAddr = channel.toNAddr()
val uri =
channel.toNAddr()?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
channelNAddr?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
?: NotificationRoutes.noteUri(note, accountNpub)
// Only the naddr identifies the room durably; without it there is no conversation to
// pin a shortcut to. The message-content gate is the early return at the top.
val conversation =
channelNAddr?.let {
Conversation(
id = NotificationRoutes.relayGroupShortcutId(it, accountNpub),
label = channel.toBestDisplayName(),
iconUrl = channel.profilePicture(),
isGroup = true,
)
}
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
@@ -107,6 +121,7 @@ object BuzzDmNotification {
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = null,
conversation = conversation,
)
}
}
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
@@ -96,7 +98,23 @@ object DirectMessageNotification {
}
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(chatNote, accountNpub)
// The room, not the message: a rumor's nevent cites the gift wrap that delivered it,
// which is gone from LocalCache as soon as the process dies and is unfetchable from
// any relay. See [NotificationRoutes.chatroomUri].
val uri = NotificationRoutes.chatroomUri(chatRoom, accountNpub)
// Names the chat for the shade's Conversations section. Withheld when the account has
// turned message content off, because publishing it puts the counterparty's name and
// avatar in the launcher — see [ConversationShortcuts].
val conversation =
if (account.settings.showMessagesInNotifications.value) {
Conversation(
id = NotificationRoutes.chatroomShortcutId(chatRoom, accountNpub),
label = roomLabel(chatRoom, author),
)
} else {
null
}
val replyAction =
if (decrypt) {
null // NIP-04 is read-only in the tray
@@ -125,7 +143,22 @@ object DirectMessageNotification {
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = replyAction,
conversation = conversation,
)
}
}
/**
* What to call the chat. A one-to-one room is the other person, which is also the sender;
* a group room is everyone in it, so the sender's name alone would mislabel it.
*/
private fun roomLabel(
chatRoom: ChatroomKey,
author: User,
): String =
if (chatRoom.users.size <= 1) {
author.toBestDisplayName()
} else {
chatRoom.users.joinToString(", ") { LocalCache.getOrCreateUser(it).toBestDisplayName() }
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
@@ -61,6 +62,20 @@ object GroupMessageNotification {
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub)
// Withheld when the account has message content turned off — the shortcut it publishes
// names the group in the launcher. See [ConversationShortcuts].
val conversation =
if (account.settings.showMessagesInNotifications.value) {
// No iconUrl: a Marmot group's avatar is an encrypted Blossom blob, not a
// URL an image loader can take.
Conversation(
id = NotificationRoutes.marmotShortcutId(nostrGroupId, accountNpub),
label = groupName,
isGroup = true,
)
} else {
null
}
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
@@ -81,6 +96,7 @@ object GroupMessageNotification {
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
conversation = conversation,
replyAction =
ReplyAction.Marmot(
accountNpub = accountNpub,
@@ -70,6 +70,16 @@ fun GetVideoController(
// warm-pool fast path keeps position and buffered data intact.
val keepAlive = remember { mutableStateOf(true) }
// Captions can resolve *after* playback starts: a `text-track` tag naming a kind-39307
// coordinate is only turned into a SubtitleConfiguration once that event arrives, which
// rebuilds `mediaItem` with the same videoUri and a different subtitle list. The mediaId is
// the video URI either way, so the warm-pool fast path below would read "already loaded" and
// the new tracks would never reach the player. A MediaController doesn't carry the item's
// `localConfiguration` across the service IPC, so the subtitles can't be read back off the
// controller to compare — we remember what we last pushed instead. Deliberately keyless so it
// survives the keyed remember below being rebuilt by the very change it has to detect.
val lastAppliedCaptions = remember { mutableStateOf<Int?>(null) }
val controllerState by remember(mediaItem, keepAlive.value) {
if (keepAlive.value) {
PlaybackServiceClient
@@ -118,6 +128,7 @@ fun GetVideoController(
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val captionsFingerprint = mediaItem.src.captions.hashCode()
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
// Cold load: a fresh decoder/codec instance gets allocated here. If a
@@ -127,6 +138,20 @@ fun GetVideoController(
Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" }
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (lastAppliedCaptions.value != null && lastAppliedCaptions.value != captionsFingerprint) {
// Same video, new caption set, and this composable is the one that loaded
// it: re-set the item so the side-loaded subtitle sources get merged in.
// Restarting from the current position keeps the rebuffer down to whatever
// the cache can't serve. The `!= null` guard is what keeps a warm player
// that arrived already carrying someone else's item from being re-prepared
// on a fingerprint we never pushed — that's the rebuffer the pool exists
// to avoid, and its captions are whatever that mount applied.
val resumeAt = state.controller.currentPosition
Log.d("PlaybackService") { "Captions changed for $targetMediaId - re-applying at $resumeAt" }
state.controller.setMediaItem(mediaItem.item, resumeAt)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.Fullscree
import com.vitorpamplona.amethyst.service.playback.composable.controls.LogVideoQualitySelection
import com.vitorpamplona.amethyst.service.playback.composable.controls.METERED_MAX_SHORT_SIDE_PX
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCaptions
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
@@ -217,6 +218,15 @@ fun RenderVideoPlayer(
shutter = {},
)
// Above the picture but below the controls, so the transport bar and the bottom gradient
// draw over the cues rather than the other way around.
if (mediaItem.src.captions.isNotEmpty()) {
RenderCaptions(
controllerState = controllerState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
RenderPlaybackError(
controllerState = controllerState,
videoUri = mediaItem.src.videoUri,
@@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
@@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
class WaveformData(
@@ -73,6 +76,7 @@ fun VideoView(
thumbhash: String? = null,
isLiveStream: Boolean = false,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val borderModifier =
if (roundedCorner) {
@@ -83,7 +87,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash, captions = captions)
}
@Composable
@@ -107,6 +111,7 @@ fun VideoView(
accountViewModel: AccountViewModel,
thumbhash: String? = null,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
// Reset the manual-show toggle when the video URI changes so a recycled feed slot
@@ -180,6 +185,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -236,6 +242,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -26,10 +26,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val DEFAULT_MUTED_SETTING = mutableStateOf(true)
@@ -57,6 +60,7 @@ fun VideoViewInner(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
accountViewModel: AccountViewModel,
) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -83,6 +87,7 @@ fun VideoViewInner(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -0,0 +1,157 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.Locale
/**
* The text tracks a player has, in the order the media declared them.
*
* A NIP-71 video can carry a `text-track` per language, so "captions" is not one thing to switch
* on — it is a list to choose from. Groups the player reports as unsupported are dropped: offering
* a language the decoder will refuse is worse than not offering it.
*/
@OptIn(UnstableApi::class)
internal fun getTextTrackChoices(tracks: Tracks): ImmutableList<CaptionChoice> {
val choices = mutableListOf<CaptionChoice>()
tracks.groups
.filter { it.type == C.TRACK_TYPE_TEXT }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
choices.add(
CaptionChoice(
group = group,
trackIndex = i,
language = format.language,
label = format.label,
isSelected = group.isTrackSelected(i),
),
)
}
}
return choices.toImmutableList()
}
internal data class CaptionChoice(
val group: Tracks.Group,
val trackIndex: Int,
val language: String?,
val label: String?,
val isSelected: Boolean,
) {
/**
* What to print for this track.
*
* The publisher's own label wins when there is one. Otherwise the language code is expanded
* into the reader's locale — "en" is not a word to most people, "English" is — and only a
* track that names neither falls back to a generic term.
*/
fun displayName(fallback: String): String =
label?.takeIf { it.isNotBlank() }
?: language?.takeIf { it.isNotBlank() }?.let { Locale.forLanguageTag(it).displayLanguage.ifBlank { it } }
?: fallback
}
@OptIn(UnstableApi::class)
internal fun selectTextTrack(
player: Player,
choice: CaptionChoice,
) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
// Clearing the type disable as well: picking a language is a request to see captions,
// so it has to undo an earlier "off" rather than silently select a track nothing draws.
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(TrackSelectionOverride(choice.group.mediaTrackGroup, choice.trackIndex))
.build()
}
/**
* The language menu, shown in place of a plain on/off toggle when a video offers more than one
* caption track.
*/
@Composable
internal fun CaptionLanguagePopup(
choices: ImmutableList<CaptionChoice>,
captionsEnabled: Boolean,
onSelectOff: () -> Unit,
onSelectTrack: (CaptionChoice) -> Unit,
onDismiss: () -> Unit,
) {
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
val baseColors = ButtonDefaults.textButtonColors()
val contentColor = MaterialTheme.colorScheme.onBackground
val colors = remember(baseColors, contentColor) { baseColors.copy(contentColor = contentColor) }
val unnamed = stringRes(R.string.captions_unnamed_track)
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextButton(colors = colors, onClick = onSelectOff) {
Text(
stringRes(R.string.captions_off),
fontWeight = if (!captionsEnabled) FontWeight(1000) else FontWeight(400),
)
}
choices.forEach { choice ->
TextButton(colors = colors, onClick = { onSelectTrack(choice) }) {
Text(
choice.displayName(unnamed),
fontWeight = if (captionsEnabled && choice.isSelected) FontWeight(1000) else FontWeight(400),
)
}
}
}
}
}
@@ -79,6 +79,9 @@ fun OverflowMenuButtonPreview() {
onCastClick = {},
castIcon = MaterialSymbols.Cast,
castContentDescription = "",
onCaptionsClick = {},
captionsIcon = MaterialSymbols.ClosedCaption,
captionsContentDescription = "",
)
}
}
@@ -98,6 +101,9 @@ fun AnimatedOverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@@ -118,6 +124,9 @@ fun AnimatedOverflowMenuButton(
onCastClick = onCastClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
}
@@ -135,6 +144,9 @@ fun OverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
) {
val menuExpanded = remember { mutableStateOf(false) }
@@ -239,6 +251,16 @@ fun OverflowMenuButton(
onCastClick()
}
}
VideoPlayerAction.Captions -> {
M3ActionRow(
icon = captionsIcon,
text = captionsContentDescription,
) {
menuExpanded.value = false
onCaptionsClick()
}
}
}
}
}
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
/**
* Draws the cues of the currently selected text track over the player.
*
* media3's Compose UI has no subtitle view — that lives in the View-based `PlayerView`, which this
* app does not use — so the cues are collected from the player and laid out here. Only the cue
* text is honoured: a WebVTT region/position would need the player's video rect, and every
* NIP-71 caption seen in the wild is plain bottom-centred dialogue.
*
* Nothing renders when no text track is selected, so this composable costs one listener on videos
* without captions.
*/
@Composable
fun RenderCaptions(
controllerState: MediaControllerState,
modifier: Modifier = Modifier,
) {
val player = controllerState.controller
var lines by remember(player) { mutableStateOf<List<String>>(emptyList()) }
DisposableEffect(player) {
lines =
player.currentCues.cues
.mapNotNull { it.text?.toString() }
.filter { it.isNotBlank() }
val listener =
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
lines = cueGroup.cues.mapNotNull { it.text?.toString() }.filter { it.isNotBlank() }
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
if (lines.isEmpty()) return
Column(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
lines.forEach { line ->
Text(
text = line,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
// A scrim behind the glyphs rather than a full-width bar: captions sit over the
// picture, and a translucent box only where there is text keeps the frame visible.
modifier =
Modifier
.background(CaptionScrim, MaterialTheme.shapes.extraSmall)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
private val CaptionScrim = Color.Black.copy(alpha = 0.6f)
@@ -46,6 +46,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -98,6 +99,8 @@ fun RenderTopButtonsPreview() {
onPictureInPictureClick = {},
onZoomClick = {},
onOverflowQualityClick = {},
captionsEnabled = true,
onCaptionsClick = {},
modifier = Modifier,
accountViewModel = mockAccountViewModel(),
)
@@ -143,6 +146,43 @@ fun RenderTopButtons(
val videoGroup = getVideoTrackGroup(tracks)
val hasMultipleQualities = videoGroup != null && videoGroup.length > 1
// Captions are side-loaded from the event's `text-track` tags (MediaItemCache flags the first
// one default). The account setting is the source of truth, not the player: a player comes
// out of the warm pool carrying whatever track selection the previous video left on it, so
// reading the preference off the instance would make the button's state depend on which
// player this video happened to be handed.
val captionsEnabled by accountViewModel.captionsEnabledFlow().collectAsStateWithLifecycle()
// Push the preference onto whichever player is attached, and re-push when either changes.
// Disabling the whole track type rather than deselecting one group: the type-level switch
// survives the player later picking a different track.
LaunchedEffect(player, captionsEnabled) {
val alreadyDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)
if (alreadyDisabled == captionsEnabled) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !captionsEnabled)
.build()
}
}
// A video with one track has nothing to choose between, so the button stays a toggle. Only a
// multi-language video opens a menu — paying a popup for a single "English" row would be a
// worse answer to the same tap.
val captionChoices = remember(tracks) { getTextTrackChoices(tracks) }
val captionsPopupOpen = remember { mutableStateOf(false) }
val onCaptionsClick =
remember(captionsEnabled, captionChoices) {
{
if (captionChoices.size > 1) {
captionsPopupOpen.value = true
} else {
accountViewModel.setCaptionsEnabled(!captionsEnabled)
}
}
}
val overflowQualityOpen = remember { mutableStateOf(false) }
// Pause local playback while this video is casting so audio doesn't
@@ -210,10 +250,31 @@ fun RenderTopButtons(
}
},
onOverflowQualityClick = { overflowQualityOpen.value = true },
captionsEnabled = captionsEnabled,
onCaptionsClick = onCaptionsClick,
modifier = modifier,
accountViewModel = accountViewModel,
)
if (captionsPopupOpen.value) {
CaptionLanguagePopup(
choices = captionChoices,
captionsEnabled = captionsEnabled,
onSelectOff = {
accountViewModel.setCaptionsEnabled(false)
captionsPopupOpen.value = false
},
onSelectTrack = { choice ->
// The override goes straight onto the player (it is per-video), while the
// preference is what persists the fact that captions are wanted at all.
selectTextTrack(player, choice)
accountViewModel.setCaptionsEnabled(true)
captionsPopupOpen.value = false
},
onDismiss = { captionsPopupOpen.value = false },
)
}
if (overflowQualityOpen.value && videoGroup != null) {
VideoQualityPopup(
player = player,
@@ -237,10 +298,15 @@ fun RenderTopButtons(
onPictureInPictureClick: () -> Unit,
onZoomClick: (() -> Unit)?,
onOverflowQualityClick: () -> Unit,
captionsEnabled: Boolean,
onCaptionsClick: () -> Unit,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val captionsIcon = if (captionsEnabled) MaterialSymbols.ClosedCaption else MaterialSymbols.ClosedCaptionDisabled
val captionsContentDescription =
stringRes(if (captionsEnabled) R.string.captions_turn_off else R.string.captions_turn_on)
val shareDialogVisible = remember { mutableStateOf(false) }
val castDialogVisible = remember { mutableStateOf(false) }
val castSessionState by Amethyst.instance.castRegistry.sessionState
@@ -297,21 +363,28 @@ fun RenderTopButtons(
VideoPlayerAction.Cast -> {
BuildConfig.IS_CASTING_AVAILABLE && mediaData.videoUri.startsWith("http", ignoreCase = true)
}
// A video with no `text-track` has nothing to toggle, so the button stays out of the
// row entirely rather than sitting there inert.
VideoPlayerAction.Captions -> {
mediaData.captions.isNotEmpty()
}
}
val canFullscreen = onZoomClick != null
val hasCaptions = mediaData.captions.isNotEmpty()
// ImmutableList so Compose can treat the action lists as stable parameters when they're
// passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the
// overflow tree to recompose whenever any unrelated parent state ticks.
val topBarActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
.map { it.action }
.toImmutableList()
}
val overflowActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }
.map { it.action }
@@ -377,6 +450,15 @@ fun RenderTopButtons(
onClick = onCastButtonClick,
)
}
VideoPlayerAction.Captions -> {
AnimatedTopBarIconButton(
controllerVisible = controllerVisible,
symbol = captionsIcon,
contentDescription = captionsContentDescription,
onClick = onCaptionsClick,
)
}
}
}
@@ -394,6 +476,9 @@ fun RenderTopButtons(
onCastClick = onCastButtonClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
@@ -21,11 +21,16 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val mediaItemCache = MediaItemCache()
@@ -46,10 +51,21 @@ fun GetMediaItem(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val data =
remember(videoUri) {
// Captions join the key. They resolve asynchronously — a `text-track` that names a
// kind-39307 coordinate has to reach LocalCache first — so keying on the URI alone would
// lock in the caption-less item built on the first frame. The list only ever goes empty ->
// resolved, so this rebuilds at most once.
//
// What that rebuild reaches depends on when it lands. A feed video is mounted long before
// it is scrolled into view and played, so the item carrying the tracks is the one the
// controller loads. For a video already playing, GetVideoController's warm-path check sees
// the same mediaId and deliberately does not re-set the item, so the late tracks wait for
// the next mount rather than restarting playback under the viewer.
remember(videoUri, captions) {
MediaItemData(
videoUri = videoUri,
authorName = authorName,
@@ -66,6 +82,7 @@ fun GetMediaItem(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
)
}
@@ -79,7 +96,19 @@ fun GetMediaItem(
) {
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem?.let {
// produceCachedState re-remembers on a new key and starts at null while the replacement is
// built off-thread. Rendering that null would drop the whole player subtree for a frame and
// rebuild it — a visible blink — and for the one case that changes the key mid-playback
// (captions resolving after the first frame) the warm-pool check then declines to re-set an
// item carrying the same mediaId, so the blink would buy nothing. Keep drawing the item we
// already have until its replacement exists.
val previous = remember { mutableStateOf<LoadedMediaItem?>(null) }
val current = mediaItem
if (current != null && previous.value !== current) {
SideEffect { previous.value = current }
}
(current ?: previous.value)?.let {
inner(it)
}
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import android.os.Bundle
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -61,6 +63,25 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
return null
}
// A side-loaded track needs its media type declared: a NIP-71 `text-track` says nothing
// about the format, and a blossom-hosted one is `https://host/<sha256>` with no extension
// for ExoPlayer to infer from either. A track flagged default is the one ExoPlayer
// selects without being asked.
internal fun CaptionTrack.toSubtitleConfiguration(isDefault: Boolean = false): MediaItem.SubtitleConfiguration =
MediaItem.SubtitleConfiguration
.Builder(url.toUri())
.setMimeType(mimeType)
.setLanguage(language)
.setLabel(label())
.setSelectionFlags(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0)
.build()
private fun CaptionTrack.label(): String? =
when {
language != null && type != null -> "$language \u00B7 $type"
else -> language ?: type
}
// Restrict `.m3u8` matching to the path component so a query param or
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
// MP4 URI doesn't misroute to HlsMediaSource.
@@ -83,7 +104,15 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.apply { normalizedMime?.let { setMimeType(it) } }
.setMediaMetadata(
.setSubtitleConfigurations(
// Flagging the first track default is what decides WHICH track plays
// once text is enabled. WHETHER it is enabled is the account's
// captionsEnabled preference, which RenderTopButtons pushes onto the
// player as a track-type switch — the two are independent.
key.captions.mapIndexed { index, track ->
track.toSubtitleConfiguration(isDefault = index == 0)
},
).setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
@@ -22,8 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class MediaItemData(
@@ -48,6 +51,9 @@ data class MediaItemData(
val dim: DimensionTag? = null,
val hash: String? = null,
val thumbhash: String? = null,
// NIP-71 `text-track` captions to side-load. A data class is the cache key, so this has to be
// a value that compares by content — ImmutableList keeps that true and keeps the key @Stable.
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
)
@Immutable
@@ -29,6 +29,8 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
@@ -104,6 +106,7 @@ class CustomMediaSourceFactory(
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
private val plainDataSource: DataSource.Factory = dataSourceFactory
private val cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(cachingDataSource)
@@ -119,9 +122,10 @@ class CustomMediaSourceFactory(
//
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
// be mirrored here.
// live target-offset defaults. Subtitles ARE reachable now (MediaItemCache side-loads NIP-71
// `text-track` captions), so createMediaSource below mirrors that wrap for the HLS path;
// clipping and ad insertion remain unused, and the live setters aren't on the
// MediaSource.Factory interface. Anything else added later must be mirrored here too.
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
@@ -137,7 +141,13 @@ class CustomMediaSourceFactory(
return this
}
// Retained as well as forwarded: the subtitle sources in withSideLoadedSubtitles are built per
// call rather than living in allFactories, so this is the only way they can be given the same
// retry/backoff behaviour the player configured for everything else.
private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
return this
}
@@ -161,7 +171,16 @@ class CustomMediaSourceFactory(
} else {
if (bypassCache) nonCachingFactory else cachingFactory
}
val source = factory.createMediaSource(mediaItem)
// DefaultMediaSourceFactory is what merges side-loaded subtitle tracks into the playback
// source. The explicit HLS factory above does not, so on that path a caption track would
// load into nothing; mirror the wrap here. Progressive items already got it from the
// Default factory, so they must NOT be wrapped again.
val source =
if (hls) {
withSideLoadedSubtitles(factory.createMediaSource(mediaItem), mediaItem, bypassCache)
} else {
factory.createMediaSource(mediaItem)
}
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
// shouldBypassCache.
@@ -172,6 +191,38 @@ class CustomMediaSourceFactory(
return source
}
/**
* Rebuilds what [DefaultMediaSourceFactory] does for `subtitleConfigurations`: each side-loaded
* track becomes its own single-sample source, merged alongside the video.
*
* Subtitle files are small, immutable documents, so they follow the video's cache decision —
* a track fetched beside a cached VOD playlist is worth keeping too, and one beside a live
* stream is not worth a cache entry for a stream that is never replayed.
*/
private fun withSideLoadedSubtitles(
source: MediaSource,
mediaItem: MediaItem,
bypassCache: Boolean,
): MediaSource {
val subtitles = mediaItem.localConfiguration?.subtitleConfigurations.orEmpty()
if (subtitles.isEmpty()) return source
val dataSource = if (bypassCache) plainDataSource else cachingDataSource
val sources =
Array(subtitles.size + 1) { index ->
if (index == 0) {
source
} else {
SingleSampleMediaSource
.Factory(dataSource)
.apply { loadErrorHandlingPolicy?.let { setLoadErrorHandlingPolicy(it) } }
.createMediaSource(subtitles[index - 1], C.TIME_UNSET)
}
}
return MergingMediaSource(*sources)
}
// Only the explicit event-kind flag (kind:30311 live activities). Returns false when the flag
// is absent or false; the HLS URL check in createMediaSource covers everything else.
private fun isFlaggedLive(mediaItem: MediaItem): Boolean {
@@ -59,6 +59,21 @@ fun observeNote(
return flow.collectAsStateWithLifecycle()
}
/**
* [observeNote] without the relay half: watches LocalCache and asks no relay for the note.
*
* For a NIP-17 rumor, which has no fetchable id — putting one in a REQ would tell relays the
* private event's identity, the leak [com.vitorpamplona.amethyst.commons.model.Note.isPrivateRumor]
* guards everywhere else. Nothing is lost by not asking: a rumor only ever reaches the cache by
* unwrapping the envelope that carried it, and the always-on gift-wrap tail re-fetches a week of
* those on every cold start, so this flow fires on its own once the envelope lands.
*/
@Composable
fun observeNoteLocally(note: Note): State<NoteState> {
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
inline fun <reified T : Event> observeNoteEvent(
@@ -34,16 +34,20 @@ import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.NotificationRelayService
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
@@ -172,6 +176,56 @@ fun fragmentHashtagOrNull(uri: String): String? {
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
/**
* A private chatroom, addressed by its participants rather than by a message.
* Posted by DM notifications — see [NotificationRoutes.chatroomUri] for why a DM
* cannot deep-link through its own note.
*/
fun isChatroomRoute(uri: String) = uri.startsWith("chatroom?id=") || uri.startsWith("nostr:chatroom?id=")
/**
* Note what this deliberately does *not* do: register the room on the account.
*
* `routeToMessage` would, and that is right for the in-app callers, but wrong here twice over.
* `uriToRoute` runs against whichever account is current, *before* `?account=` is read and the
* switch happens (`AppNavigation.NavigateIfIntentRequested`), so a DM notification for account B
* tapped while A is on screen would insert an empty conversation into **A**'s message list. And
* `nostr:` is an exported, browsable scheme, so any web page could post
* `nostr:chatroom?id=<hex>&account=…` and inject a room of its choosing.
*
* Nothing needs it: `ChatroomFeedFilter.chatroom()` calls `getOrCreatePrivateChatroom` when the
* screen actually opens, by which point the switch has happened and the room lands on the right
* account.
*
* The ids are still checked here — a room key is a set of pubkeys, so anything that isn't one is
* not a room, and a bad deep link should resolve to nothing rather than to an unopenable screen.
*/
fun chatroomRoute(uri: String): Route? {
val users =
uri
.findQueryParameterValue("id")
?.split(',')
?.map { it.trim() }
?.takeIf { it.isNotEmpty() && it.all(::isPubKeyHex) }
?.toSet() ?: return null
return Route.Room(ChatroomKey(users))
}
/** A bare 32-byte lowercase-hex pubkey, the only thing a chatroom key is made of. */
private fun isPubKeyHex(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
/**
* A NIP-17 private note or reply, addressed by the rumor's own id because its `nevent`
* names the undeliverable gift wrap instead — see [NotificationRoutes.privateNoteUri].
*/
fun isPrivateNoteRoute(uri: String) = uri.startsWith("privatenote?id=") || uri.startsWith("nostr:privatenote?id=")
fun privateNoteRoute(uri: String): Route? {
val id = uri.findQueryParameterValue("id") ?: return null
return Route.EventRedirect(id, isPrivate = true)
}
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
/**
@@ -232,7 +286,7 @@ fun uriToRoute(
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull()
val scrollTo = uri.findQueryParameterValue("scrollTo")
return Route.Notification(scrollToEventId = scrollTo)
}
if (isActiveSubscriptionsRoute(uri)) {
@@ -250,6 +304,12 @@ fun uriToRoute(
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
if (isChatroomRoute(uri)) {
return chatroomRoute(uri)
}
if (isPrivateNoteRoute(uri)) {
return privateNoteRoute(uri)
}
if (isConnectedAppRoute(uri)) {
return connectedAppRoute(uri)
}
@@ -284,10 +344,15 @@ fun uriToRoute(
}
is NEvent -> {
routeFor(
note = LocalCache.getOrCreateNote(nip19.hex),
loggedIn = account,
) ?: Route.EventRedirect(nip19.hex)
val note = LocalCache.getOrCreateNote(nip19.hex)
// Only fall back to the pointer's own kind while the body is missing: once the
// event is cached it may belong somewhere the kind alone can't name (a channel,
// a chatroom), and routeFor knows that.
if (note.event == null) {
routeForPointer(nip19.kind, nip19.hex) ?: Route.EventRedirect(nip19.hex)
} else {
routeFor(note = note, loggedIn = account) ?: Route.EventRedirect(nip19.hex)
}
}
is NAddress -> {
@@ -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,
)
}
}
@@ -907,7 +907,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, accountViewModel, nav) }
composableArgs<Route.EventRedirect> { LoadRedirectScreen(it.id, it.isPrivate, accountViewModel, nav) }
composableFromBottomArgs<Route.GeoPost> {
GeoHashPostScreen(
@@ -1188,7 +1188,7 @@ private fun NavigateIfIntentRequested(
LaunchedEffect(intentNextPage) {
if (actionableNextPage != null) {
actionableNextPage?.let { nextRoute ->
val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
val npub = intentNextPage.findQueryParameterValue("account")
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub) { account ->
uriToRoute(intentNextPage, account)
@@ -1275,7 +1275,7 @@ private fun NavigateIfIntentRequested(
if (newPage != null) {
scope.launch {
val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull()
val npub = uri.findQueryParameterValue("account")
if (npub != null && accountSessionManager.currentAccountNPub() != npub) {
accountSessionManager.checkAndSwitchUserSync(npub) { newAccount ->
uriToRoute(uri, newAccount)
@@ -1325,3 +1325,34 @@ fun URI.findParameterValue(parameterName: String): String? =
Pair(name, value)
}?.firstOrNull { it.first == parameterName }
?.second
/**
* The value of [parameterName] in this URI's query string, without going through
* [java.net.URI].
*
* Needed because `java.net.URI` only exposes `rawQuery` for *hierarchical* URIs. A URI
* with a scheme and no `//` is **opaque** — everything after the colon is one
* scheme-specific part — so `URI("marmot:<hex>?account=npub1…").rawQuery` is null. That
* is the shape [com.vitorpamplona.amethyst.service.notifications.NotificationRoutes.marmotUri]
* produces, so every Marmot group notification silently lost its `?account=` and opened
* the group under whichever account happened to be current instead of switching first.
*
* Splitting on the first `?` gets the same answer for both shapes, and returns null for a
* bare `nevent1…` with no query at all.
*
* [com.vitorpamplona.quartz.utils.UriParser] reads an opaque query correctly too, and is the
* right tool when a URI is already known to be well-formed. It is not this one: it builds a
* [java.net.URI], which *throws* on anything that is not a legal URI. What arrives here comes
* from an exported, browsable scheme, so it can be any string at all, and every caller on the
* deep-link path treats an unreadable uri as "no route" rather than as a crash.
*/
fun String.findQueryParameterValue(parameterName: String): String? {
val query = substringAfter('?', "")
if (query.isEmpty()) return null
return query
.split('&')
.firstOrNull { it.substringBefore('=') == parameterName }
?.substringAfter('=', "")
?.ifEmpty { null }
}
@@ -73,6 +73,7 @@ enum class NavBarItem {
PUBLIC_CHATS,
RELAY_GROUPS,
CONCORD,
MARMOT_GROUPS,
GEOHASH_CHATS,
FOLLOW_PACKS,
LIVE_STREAMS,
@@ -387,6 +388,22 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.Group,
resolveRoute = { Route.Concords },
),
NavBarItem.MARMOT_GROUPS to
NavBarItemDef(
id = NavBarItem.MARMOT_GROUPS,
// Duplicates the already-translated `marmot_groups_title` in
// commonsUI's composeResources, which the screen's own title uses.
// Unavoidable here: `labelRes` is an Android @StringRes and every
// other catalog entry is one, so a Compose resource cannot be
// referenced without changing the type for all ~60 of them. Crowdin
// manages both resource sets, so this one gets translated too.
labelRes = R.string.marmot_groups_title,
// Lock, not Group: the rooms list already labels a Marmot room
// with this symbol, so it is the signifier users have learned
// for these, and it keeps the row distinct from Concord's.
icon = MaterialSymbols.Lock,
resolveRoute = { Route.MarmotGroupList },
),
NavBarItem.GEOHASH_CHATS to
NavBarItemDef(
id = NavBarItem.GEOHASH_CHATS,
@@ -522,6 +539,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.GEOHASH_CHATS,
),
),
@@ -144,6 +144,7 @@ private val DrawerFeedsItems: List<NavBarItem> =
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.MARMOT_GROUPS,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
@@ -51,6 +51,10 @@ import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
@@ -60,9 +64,13 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip73ExternalIds.location.isGeohashedScoped
import com.vitorpamplona.quartz.nip73ExternalIds.topics.isHashtagScoped
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
@@ -98,6 +106,62 @@ fun minichatRouteFor(note: Note): Route? {
private fun Note.isInChatGatherer(): Boolean = inGatherers?.any { it is ConcordChannel || it is RelayGroupChannel || it is PublicChatChannel } == true
/**
* Kinds whose destination is `Route.Note(id)` — the generic thread view — no matter what the
* event body turns out to say. Mirrors the `else ->` branch of [routeForInner]: every kind here
* is a plain note that carries nothing (no channel id, no `a` tag, no chatroom key) that could
* send it somewhere more specific.
*
* **An addressable kind can never be listed here**, however plain it looks. [routeForInner]
* sends those to `Route.Note(addressTag())` through its `is AddressableEvent` branch, and the
* difference is not cosmetic: relays do not serve a replaceable by the id of one of its
* versions, so routing by id is a dead end. This bit the NIP-71 video kinds — 21/22 are regular
* but 34235/34236 extend `AddressableVideoEvent` — which is why the pair is split below and why
* `RouteForPointerTest` walks this list against [routeForInner] itself rather than trusting it.
*
* `internal` so that test can iterate the real list: a kind added here is covered by
* construction, not by someone remembering to add a case.
*/
internal val THREAD_VIEW_KINDS =
intArrayOf(
TextNoteEvent.KIND,
CommentEvent.KIND,
PollEvent.KIND,
PictureEvent.KIND,
// NIP-71 regular video only (21/22). The addressable pair, 34235/34236, is cited by
// `naddr` and routed by address — see the warning above.
VideoNormalEvent.KIND,
VideoShortEvent.KIND,
HighlightEvent.KIND,
GitIssueEvent.KIND,
GitPatchEvent.KIND,
GitPullRequestEvent.KIND,
GitPullRequestUpdateEvent.KIND,
)
/**
* Route for an event we hold only a NIP-19 pointer to — its id plus, when the pointer carries
* one, its [kind] — because the body has not reached [LocalCache] yet.
*
* Notification deep links are the reason this exists. A push normally wakes a *cold* process,
* so by the time the user taps the tray the cache is empty and [routeFor] can say nothing
* better than [Route.EventRedirect] — a bare "looking for event" screen the user sits on until
* the event is re-fetched. An `nevent` already states the kind, which for an ordinary note is
* the whole answer, so a reply or a mention can open its thread immediately and let the screen
* fill itself in.
*
* Returns null when the kind is absent or needs the body to place the event, leaving the
* caller's redirect in charge.
*/
fun routeForPointer(
kind: Int?,
id: HexKey,
): Route? {
if (kind == null) return null
if (kind !in THREAD_VIEW_KINDS) return null
return Route.Note(id)
}
fun routeFor(
note: Note,
loggedIn: Account,
@@ -1110,6 +1110,12 @@ sealed class Route {
@Serializable data class EventRedirect(
val id: String,
/**
* The event is a NIP-17 rumor, so [id] is a private event id that must never reach
* a relay: the screen watches LocalCache for it and issues no REQ. The envelope that
* carries it comes back on its own through the always-on gift-wrap tail.
*/
val isPrivate: Boolean = false,
) : Route()
@Serializable
@@ -221,6 +221,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderThread
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent
import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
import com.vitorpamplona.amethyst.ui.note.types.RenderVideoCollaboration
import com.vitorpamplona.amethyst.ui.note.types.RenderVideoCurationSet
import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiMergeAcceptance
@@ -229,6 +231,25 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderWikiRedirect
import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderAppCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderArticleCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderFavoriteAlgoFeedsList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGeohashList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitAuthorList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGitRepositoryList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiAuthorList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderGoodWikiRelayList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderHashtagList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderInterestSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderKindMuteSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderLabeledBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaFollowList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMediaStarterPack
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderMuteList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderOldBookmarkList
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderPictureCurationSet
import com.vitorpamplona.amethyst.ui.note.types.lists.RenderSimpleGroupList
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
@@ -291,6 +312,7 @@ import com.vitorpamplona.quartz.experimental.ratings.EntityRatingEvent
import com.vitorpamplona.quartz.experimental.ratings.RelayReviewEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -328,8 +350,26 @@ import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent
@@ -338,6 +378,8 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.releaseArtifactSet.ReleaseArtifactSetEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -1579,6 +1621,14 @@ private fun RenderNoteRow(
VideoDisplay(baseNote, makeItShort, canPreview, backgroundColor, ContentScale.FillWidth, accountViewModel, nav)
}
is VideoCollaborationEvent -> {
RenderVideoCollaboration(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is VideoCurationSetEvent -> {
RenderVideoCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is PictureEvent -> {
PictureDisplay(baseNote, true, ContentScale.FillWidth, PaddingValues(vertical = 5.dp), backgroundColor, accountViewModel, nav)
}
@@ -1788,6 +1838,82 @@ private fun RenderNoteRow(
)
}
is MuteListEvent -> {
RenderMuteList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is BookmarkListEvent -> {
RenderBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is OldBookmarkListEvent -> {
RenderOldBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is LabeledBookmarkListEvent -> {
RenderLabeledBookmarkList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is ArticleCurationSetEvent -> {
RenderArticleCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is PictureCurationSetEvent -> {
RenderPictureCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is AppCurationSetEvent -> {
RenderAppCurationSet(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is GitRepositoryListEvent -> {
RenderGitRepositoryList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is FavoriteAlgoFeedsListEvent -> {
RenderFavoriteAlgoFeedsList(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
}
is GitAuthorListEvent -> {
RenderGitAuthorList(baseNote, backgroundColor, accountViewModel, nav)
}
is MediaFollowListEvent -> {
RenderMediaFollowList(baseNote, backgroundColor, accountViewModel, nav)
}
is GoodWikiAuthorListEvent -> {
RenderGoodWikiAuthorList(baseNote, backgroundColor, accountViewModel, nav)
}
is KindMuteSetEvent -> {
RenderKindMuteSet(baseNote, backgroundColor, accountViewModel, nav)
}
is MediaStarterPackEvent -> {
RenderMediaStarterPack(baseNote, backgroundColor, accountViewModel, nav)
}
is HashtagListEvent -> {
RenderHashtagList(baseNote, backgroundColor, accountViewModel, nav)
}
is InterestSetEvent -> {
RenderInterestSet(baseNote, backgroundColor, accountViewModel, nav)
}
is GeohashListEvent -> {
RenderGeohashList(baseNote, backgroundColor, accountViewModel)
}
is SimpleGroupListEvent -> {
RenderSimpleGroupList(baseNote, backgroundColor, accountViewModel)
}
is GoodWikiRelayListEvent -> {
RenderGoodWikiRelayList(baseNote, backgroundColor, accountViewModel)
}
else -> {
RenderTextEvent(
baseNote,
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.model.nip71Video.captionTracks
import com.vitorpamplona.amethyst.commons.model.nip71Video.mergeCaptionTracks
import com.vitorpamplona.amethyst.commons.model.nip71Video.toCaptionTrack
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
/**
* Resolves the caption tracks a NIP-71 video advertises, fetching the addressable ones.
*
* A `text-track` that names a URL is usable immediately. One that names a
* `39307:<pubkey>:<d>` coordinate is not, so it gets an [observeNoteEvent] of its own — that both
* reads the event out of LocalCache and puts it on an EventFinder subscription, so a track the
* device has never seen is requested from relays and appears when it lands.
*
* The same track is routinely advertised both ways at once (divine.video publishes the Blossom URL
* and its wrapping event side by side), so the two are merged by URL: the player is handed one
* configuration per distinct file, never a duplicate pair it would offer twice in its track menu.
*/
@Composable
fun rememberCaptionTracks(
videoEvent: VideoEvent,
accountViewModel: AccountViewModel,
): ImmutableList<CaptionTrack> {
val refs = remember(videoEvent) { videoEvent.captionTracks() }
// No early return: the pending list changes size when a recycled feed slot switches videos,
// and `key` is what lets Compose add and drop those child subscriptions in place.
val resolved =
refs.pending.map { pending ->
key(pending.address.toValue()) {
val note = remember(pending.address) { accountViewModel.getOrCreateAddressableNote(pending.address) }
val trackEvent by observeNoteEvent<TextTrackEvent>(note, accountViewModel)
trackEvent?.toCaptionTrack(pending.tag)
}
}
return remember(refs, resolved) {
if (refs.direct.isEmpty() && resolved.all { it == null }) {
persistentListOf()
} else {
mergeCaptionTracks(refs.direct, resolved.filterNotNull()).toImmutableList()
}
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
/**
* A collaborator's acceptance (or refusal) of a credit, rendered on its own.
*
* The event carries no content — everything it says is in the video it points at — so the video is
* embedded and the event itself is reduced to one line of context above it: the role the
* collaborator was given, and whether they took it.
*/
@Composable
fun RenderVideoCollaboration(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? VideoCollaborationEvent ?: return
val video = remember(noteEvent) { noteEvent.video() }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val headline =
if (noteEvent.isAccepted()) {
stringRes(R.string.video_collaboration_accepted_on)
} else {
stringRes(R.string.video_collaboration_declined_on)
}
val role = remember(noteEvent) { noteEvent.role() }
Text(
text = if (role != null) "$headline · $role" else headline,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// quotesLeft guards the recursion: the embedded video can itself credit other work, and
// NoteCompose is what decrements it on the way down.
if (video != null && quotesLeft > 0) {
LoadAddressableNote(video, accountViewModel) { videoNote ->
videoNote?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
@@ -0,0 +1,248 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip71Video.credits.CreditTarget
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit
/**
* The line of credits under a video: who it features, who inspired it, whose audio it reuses.
*
* Every entry is a link, because the credit is only worth printing if it takes you to the person
* or the work. A credited person who has published a [VideoCollaborationEvent] for this video gets
* a check beside their name — they are not merely tagged, they took the credit.
*
* A `p` tag marked `mention` is kept even though the description may name the same person: on
* divine.video the description writes a plain `@handle`, which no client can turn into a link, so
* the tag is the only thing that resolves to an actual profile. The cost is a chip that repeats a
* `nostr:` mention already rendered inline, which is redundancy rather than a wrong credit.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RenderVideoCredits(
videoEvent: VideoEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
val credits = remember(videoEvent) { videoEvent.credits() }
if (credits.isEmpty()) return
// Only an addressable video has a coordinate for a collaborator to answer, so only there can
// an acceptance be looked up at all.
val videoAddress = remember(videoEvent) { (videoEvent as? AddressableEvent)?.address() }
FlowRow(modifier = HalfTopPadding) {
credits.forEach { credit ->
key(credit.target) {
RenderVideoCredit(credit, videoAddress, accountViewModel, nav)
}
}
}
}
@Composable
private fun RenderVideoCredit(
credit: VideoCredit,
videoAddress: Address?,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (val target = credit.target) {
is CreditTarget.Person ->
LoadUser(target.pubKey, accountViewModel) { user ->
// Until the profile arrives there is no name to print, and a hex key in the
// credits reads as noise rather than as a person.
user?.let {
CreditRow(
label = personLabel(credit.label),
// toBestDisplayName() is read inside the row so a profile that lands later
// still renames the credit.
name = "@" + it.toBestDisplayName(),
confirmed = {
// Only a role-marked credit is an invite someone can accept, and each
// lookup registers a cache observer per credit per rendered card.
// Watching every `mention` and `inspired-by` too would spend one per
// feed item on an event that cannot exist.
if (credit.label.isCollaborationRole()) {
AcceptedCollaboration(target.pubKey, videoAddress, accountViewModel)
}
},
onClick = { nav.nav(Route.Profile(target.pubKey)) },
)
}
}
is CreditTarget.Video ->
CreditRow(
label = workLabel(credit.label),
name = target.address.dTag.take(SHORT_REF_LENGTH),
onClick = {
nav.nav(Route.Note(target.address.toTag()))
},
)
is CreditTarget.Event ->
CreditRow(
label = workLabel(credit.label),
name = target.eventId.take(SHORT_REF_LENGTH),
onClick = { nav.nav(Route.Note(target.eventId)) },
)
}
}
/**
* Draws a check when [pubKey] has published an accepted [VideoCollaborationEvent] for this video.
*
* The response is matched by what both shapes in the wild agree on — the `a` tag naming the video
* — rather than by the response's own coordinate. divine-mobile keys the event by the video
* coordinate (so `34238:<collaborator>:<video coordinate>` would resolve it) but divine-web writes
* a random `d`, and asking for a coordinate nobody can predict found nothing for the client that
* publishes most of these. The relay side is covered by the `a` engagement filter every rendered
* addressable note already opens (see PostsAndChatMessagesToAddresses), so this reads the cache
* and costs no subscription of its own.
*
* Newest-first, so a collaborator who accepted and later withdrew reads as withdrawn.
*/
@Composable
private fun AcceptedCollaboration(
pubKey: String,
videoAddress: Address?,
accountViewModel: AccountViewModel,
) {
if (videoAddress == null) return
val responseFlow =
remember(pubKey, videoAddress) {
accountViewModel.account.cache.observeLatestEvent<VideoCollaborationEvent>(
Filter(
kinds = listOf(VideoCollaborationEvent.KIND),
authors = listOf(pubKey),
tags = mapOf("a" to listOf(videoAddress.toValue())),
limit = 1,
),
)
}
val response by responseFlow.collectAsStateWithLifecycle(null)
if (response?.isAccepted() == true) {
Icon(
symbol = MaterialSymbols.CheckCircle,
contentDescription = stringRes(R.string.video_credit_accepted),
modifier = Modifier.padding(start = 2.dp),
tint = MaterialTheme.colorScheme.lessImportantLink,
)
}
}
@Composable
private fun CreditRow(
label: String,
name: String,
confirmed: @Composable () -> Unit = {},
onClick: () -> Unit,
) {
Row(
modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (label.isNotBlank()) {
Text(
text = "$label ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
ClickableTextColor(
text = name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
linkColor = MaterialTheme.colorScheme.lessImportantLink,
onClick = onClick,
)
confirmed()
}
}
// A person tagged without a stated capacity is still in the video — that is what NIP-71's `p`
// means — so the neutral credit, not a blank one, is the honest default.
@Composable
private fun personLabel(marker: String?): String =
when (marker) {
INSPIRED_BY_MARKER -> stringRes(R.string.video_credit_inspired_by)
MENTION_MARKER, null -> stringRes(R.string.video_credit_featuring)
// Anything else is a role the video's author typed ("Collaborator", "Director"): print it.
else -> marker
}
// The same treatment for a credited work. Both reference kinds route through here so an `e` tag
// and an `a` tag carrying the same marker read identically — the `e` path used to print the raw
// protocol token, so a `mention` showed up in the UI as the literal word "mention".
@Composable
private fun workLabel(marker: String?): String =
when (marker) {
AUDIO_MARKER -> stringRes(R.string.video_credit_audio_from)
MENTION_MARKER, null -> stringRes(R.string.video_credit_references)
else -> marker
}
/** True for a marker that names a role rather than describing a reference. */
private fun String?.isCollaborationRole(): Boolean = this != null && this != MENTION_MARKER && this != INSPIRED_BY_MARKER && this != AUDIO_MARKER
private const val INSPIRED_BY_MARKER = "inspired-by"
private const val MENTION_MARKER = "mention"
private const val AUDIO_MARKER = "audio"
// Enough of a hex id or `d` tag to tell two references apart without wrapping the row.
private const val SHORT_REF_LENGTH = 8
@@ -0,0 +1,217 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.utils.Log
/**
* A NIP-51 kind-30005 video list: its name, what it is for, and a strip of its videos.
*
* Members may be public tags or NIP-51 private ones inside the encrypted content — divine.video
* puts every member there, so for anyone but the list's owner the strip is legitimately empty and
* the card says so rather than looking broken.
*/
@Composable
fun RenderVideoCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? VideoCurationSetEvent ?: return
val publicItems = remember(noteEvent) { noteEvent.publicItems() }
// Decryption needs the signer and only succeeds for the list's owner, so it runs off the
// composition and simply yields nothing for everyone else.
//
// Null means "we did not read the content": not the owner, or the decrypt failed. An empty
// list means we read it and it holds nothing. The two have to stay apart, because a list whose
// encrypted content decrypts to `[]` is an empty list, not a private one — divine.video
// publishes exactly that for a list a user created and never added to.
val privateItems by
produceState<List<BookmarkIdTag>?>(initialValue = null, noteEvent) {
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
value =
try {
noteEvent.privateItems(accountViewModel.account.signer)
} catch (e: Exception) {
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
Log.w("RenderVideoCurationSet", "Cannot decrypt private items of ${noteEvent.id}", e)
null
}
}
val items = remember(publicItems, privateItems) { publicItems + (privateItems ?: emptyList()) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
(noteEvent.title() ?: noteEvent.dTag()).takeIf { it.isNotBlank() }?.let {
Text(
text = it,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
noteEvent.description()?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
// An empty strip has two very different causes. Encrypted content we could not read means
// the members may be there and private; content we did read, or no content at all, means
// the list is simply empty. Calling that "Private list" would be a lie — to its own author
// most of all, who just decrypted it and found nothing in it.
val membersAreHidden = items.isEmpty() && noteEvent.content.isNotBlank() && privateItems == null
Text(
text =
if (membersAreHidden) {
stringRes(R.string.video_curation_set_private_items)
} else {
pluralStringResource(R.plurals.video_curation_set_video_count, items.size, items.size)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (items.isNotEmpty() && quotesLeft > 0) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items.take(MAX_POSTERS).forEach { item ->
key(item.toTagIdOnly().joinToString(":")) {
VideoListMemberPoster(item, accountViewModel, nav)
}
}
}
}
}
}
/** One video in the strip: its poster, or its blurhash until that loads. */
@Composable
private fun VideoListMemberPoster(
item: BookmarkIdTag,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (item) {
is AddressBookmark ->
LoadAddressableNote(item.address, accountViewModel) { note ->
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(addressTag(item.address))) } }
}
is EventBookmark ->
LoadNote(item.eventId, accountViewModel) { note ->
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(item.eventId)) } }
}
}
}
@Composable
private fun VideoPoster(
note: Note,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val videoEvent = note.event as? VideoEvent ?: return
// The poster, not the video: a strip of simultaneously-playing players is unusable, and every
// NIP-71 imeta that has a thumbnail carries it as `image`.
val imeta = remember(videoEvent) { videoEvent.imetaTags().firstOrNull { it.image.isNotEmpty() } ?: videoEvent.imetaTags().firstOrNull() }
val poster = imeta?.image?.firstOrNull()
val tileModifier = Modifier.width(POSTER_WIDTH).height(POSTER_HEIGHT).clickable(onClick = onClick)
if (poster == null) {
imeta?.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) }
return
}
MyAsyncImage(
imageUrl = poster,
contentDescription = videoEvent.title(),
contentScale = ContentScale.Crop,
mainImageModifier = tileModifier,
loadedImageModifier = MaterialTheme.colorScheme.imageModifier,
accountViewModel = accountViewModel,
onLoadingBackground = { imeta.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) } },
onError = null,
)
}
private fun addressTag(address: Address) = Address.assemble(address.kind, address.pubKeyHex, address.dTag)
// Enough to show what kind of list this is without turning a feed card into a gallery.
private const val MAX_POSTERS = 8
private val POSTER_WIDTH = 96.dp
private val POSTER_HEIGHT = 128.dp
@@ -86,9 +86,12 @@ fun VideoDisplay(
val image = remember(imeta) { imeta.image.firstOrNull() }
val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") }
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val captions = rememberCaptionTracks(videoEvent, accountViewModel)
val content: BaseMediaContent =
remember(note) {
// Keyed on the captions too: an addressable `text-track` resolves after the first frame,
// and the player only picks up a track that is in the MediaItemData it was handed.
remember(note, captions) {
val description = videoEvent.content.ifBlank { null } ?: event.alt()
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
@@ -118,6 +121,7 @@ fun VideoDisplay(
mimeType = imeta.mimeType,
blurhash = imeta.blurhash,
thumbhash = imeta.thumbhash,
captions = captions,
)
}
}
@@ -200,6 +204,11 @@ fun VideoDisplay(
}
}
}
// Outside the summary block on purpose: divine.video shorts routinely credit a
// soundtrack or a collaborator while leaving the description empty, and those credits
// are the only attribution the reused work gets.
RenderVideoCredits(videoEvent, accountViewModel, nav)
}
}
}
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
/** NIP-51 kind 10018: the git repositories this user follows. */
@Composable
fun RenderGitRepositoryList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GitRepositoryListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicRepositories() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRepositories(it) }
ListCard(
title = stringRes(R.string.kind_git_repositories),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { repo ->
key(repo.address.toValue()) {
AddressMemberRow(repo.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/** NIP-51 kind 10090: the DVM content feeds this user keeps to hand. */
@Composable
fun RenderFavoriteAlgoFeedsList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? FavoriteAlgoFeedsListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicFavoriteAlgoFeeds() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFavoriteAlgoFeeds(it) }
ListCard(
title = stringRes(R.string.kind_favorite_algo_feeds),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { feed ->
key(feed.address.toValue()) {
AddressMemberRow(feed.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/** NIP-51 kind 30267: "references to multiple software applications". */
@Composable
fun RenderAppCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? AppCurationSetEvent ?: return
val apps = remember(noteEvent) { noteEvent.apps() }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_app_curation_set),
description = noteEvent.description(),
items = apps,
// Not a PrivateTagArrayEvent: an app set has no encrypted half to be locked out of.
hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor,
) { app ->
key(app.address.toValue()) {
AddressMemberRow(app.address, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
/**
* NIP-51 kind 10009: the simple groups this user belongs to.
*
* A group is named by an id on a relay rather than by a coordinate, so it gets its own row: the
* group's name when it carries one, and the relay that hosts it underneath.
*/
@Composable
fun RenderSimpleGroupList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? SimpleGroupListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicGroups() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateGroups(it) }
ListCard(
title = stringRes(R.string.kind_simple_groups),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { group ->
key(group.groupId + group.relayUrl) {
LabelMemberRow(group.name?.takeIf { it.isNotBlank() } ?: group.groupId)
}
}
}
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent
/** NIP-51 kind 10003: the user's bookmarks — notes, articles, hashtags and links. */
@Composable
fun RenderBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? BookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), null, R.string.kind_bookmark_list),
description = null,
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* NIP-51 kind 30001: the deprecated bookmark set. Still rendered, because the events are on relays
* whether or not the spec wants them to be.
*/
@Suppress("DEPRECATION")
@Composable
fun RenderOldBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? OldBookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_bookmark_list),
description = null,
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30003: "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups". */
@Composable
fun RenderLabeledBookmarkList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? LabeledBookmarkListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
BookmarkCard(
title = listTitle(noteEvent.titleOrName(), noteEvent.dTag(), R.string.kind_bookmark_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30004: "groups of articles picked by users as interesting". */
@Composable
fun RenderArticleCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? ArticleCurationSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicItems() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_article_curation_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/** NIP-51 kind 30006: "groups of pictures picked by users as interesting". */
@Composable
fun RenderPictureCurationSet(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? PictureCurationSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicItems() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
BookmarkCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_picture_curation_set),
description = noteEvent.description(),
public = public,
private = private,
hidesPrivate = noteEvent.hidesPrivateMembers(private),
quotesLeft = quotesLeft,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* The five bookmark-shaped kinds differ only in where their name comes from; what they hold is the
* same `e`/`a` reference, so the body they draw is the same one.
*/
@Composable
private fun BookmarkCard(
title: String,
description: String?,
public: List<BookmarkIdTag>,
private: List<BookmarkIdTag>?,
hidesPrivate: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
ListCard(
title = title,
description = description,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = hidesPrivate,
backgroundColor = backgroundColor,
) { item ->
key(item.toTagIdOnly().joinToString(":")) {
BookmarkMemberRow(item, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent
/** NIP-51 kind 10102: the relays this user trusts for wiki articles. */
@Composable
fun RenderGoodWikiRelayList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? GoodWikiRelayListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicRelays() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRelays(it) }
ListCard(
title = stringRes(R.string.kind_good_wiki_relays),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { relay ->
key(relay.url) { LabelMemberRow(relay.displayUrl()) }
}
}
@@ -0,0 +1,185 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
/** How many members a list card shows before it asks to be expanded. Matches DisplayPeopleList. */
private const val COLLAPSED_MEMBERS = 3
/**
* The shared shape of every NIP-51 list card: a name, an optional description, the members, and —
* when the viewer is not the list's author — an honest line about the ones they cannot see.
*
* Each list kind supplies its own title, its own member type and its own row, because a mute list
* of pubkeys and a bookmark list of notes are not the same thing and should not pretend to be.
* What they share is this frame.
*/
@Composable
fun <T> ListCard(
title: String,
description: String?,
items: List<T>,
/** True when the event carries private members this viewer has no key for. */
hasUnreadablePrivateItems: Boolean,
backgroundColor: MutableState<Color>,
itemContent: @Composable (T) -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val shown = if (expanded) items else items.take(COLLAPSED_MEMBERS)
Text(
text = title,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(5.dp),
textAlign = TextAlign.Center,
)
description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Only when there is nothing else to show. A card with visible members already reads as a list,
// and most lists keep something private, so saying so on every one of them is noise. The line
// exists for the case that would otherwise be a title and blank space — an all-private list,
// which is what divine.video publishes — so that it reads as closed rather than as broken.
if (hasUnreadablePrivateItems && items.isEmpty()) {
Text(
text = stringRes(R.string.nip51_list_has_private_members),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 2.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
shown.forEach { itemContent(it) }
}
if (items.size > COLLAPSED_MEMBERS && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(getGradient(backgroundColor)),
) {
ShowMoreButton { expanded = true }
}
}
}
}
/**
* Decrypts a list's private members, and keeps "could not read them" apart from "there are none".
*
* `null` means we never got the plaintext — the viewer is not the author, or the decrypt failed.
* An empty list means we read the content and it holds nothing, which is an empty list and must
* not be labelled private: its author can see straight into it.
*/
@Composable
fun <T> loadPrivateItems(
noteEvent: Event,
accountViewModel: AccountViewModel,
load: suspend (NostrSigner) -> List<T>?,
): State<List<T>?> =
produceState<List<T>?>(initialValue = null, noteEvent) {
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
value =
try {
load(accountViewModel.account.signer)
} catch (e: Exception) {
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
Log.w("NIP51ListCard", "Cannot decrypt private members of ${noteEvent.id}", e)
null
}
}
/**
* True when this event is withholding members from the viewer.
*
* Both halves matter: an empty `content` is a list with no private members at all, and a non-null
* [privateItems] means we already read them, so neither is hidden.
*/
fun Event.hidesPrivateMembers(privateItems: List<*>?): Boolean = content.isNotEmpty() && privateItems == null
/** A list's own name if it has one, else the `d` tag it is addressed by, else the kind's name. */
@Composable
fun listTitle(
explicitTitle: String?,
dTag: String?,
fallback: Int,
): String =
explicitTitle?.takeIf { it.isNotBlank() }
?: dTag?.takeIf { it.isNotBlank() }
?: stringRes(fallback)
/** Remembers the concatenation of a list's public and (once decrypted) private members. */
@Composable
fun <T> rememberAllMembers(
public: List<T>,
private: List<T>?,
): List<T> = remember(public, private) { if (private == null) public else public + private }
@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
/**
* A member that is a person. Loaded lazily and skipped until the profile arrives, because a bare
* hex key in a list of people reads as noise rather than as a member.
*/
@Composable
fun UserMemberRow(
pubKey: HexKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadUser(pubKey, accountViewModel) { user ->
user?.let {
Column(modifier = Modifier.fillMaxWidth()) {
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
/**
* A member that is a note or an addressable event — a bookmark, a curated article, a repository.
*
* [quotesLeft] guards the recursion: a bookmarked note can itself quote things, and NoteCompose
* decrements it on the way down.
*/
@Composable
fun BookmarkMemberRow(
item: BookmarkIdTag,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
when (item) {
is AddressBookmark -> AddressMemberRow(item.address, quotesLeft, backgroundColor, accountViewModel, nav)
is EventBookmark ->
LoadNote(item.eventId, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
/** A member named by coordinate: an article, a repository, an app, a DVM feed. */
@Composable
fun AddressMemberRow(
address: Address,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
LoadAddressableNote(address, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
/**
* A member that is just a word: a hashtag, a geohash, a muted word.
*
* [onClick] is optional because only some of these lead anywhere — a hashtag opens its feed, a
* muted word opens nothing.
*/
@Composable
fun LabelMemberRow(
label: String,
onClick: (() -> Unit)? = null,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier =
Modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 10.dp, vertical = 6.dp),
)
HorizontalDivider(thickness = DividerThickness)
}
}
/** A hashtag member, which opens its feed. */
@Composable
fun HashtagMemberRow(
hashtag: String,
nav: INav,
) = LabelMemberRow("#$hashtag") { nav.nav(Route.Hashtag(hashtag)) }
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
/**
* NIP-51 kind 10000: the mute list.
*
* The only list whose members are of four different kinds at once — people, hashtags, words and
* threads — so it is the one kind that cannot borrow another card's body. Each member is drawn as
* what it actually is: a profile row for a person, a hashtag that opens its feed, a word as
* itself, and a muted thread as the note it silences.
*
* Most mute lists are mostly private, and for anyone but their author this renders as a title and
* an honest line saying so — which is the entire point, given that it used to render as the
* NIP-44 ciphertext.
*/
@Composable
fun RenderMuteList(
baseNote: Note,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MuteListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicMutes() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutes(it) }
ListCard(
title = stringRes(R.string.kind_mute_list),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { mute ->
when (mute) {
is UserTag -> key(mute.pubKey) { UserMemberRow(mute.pubKey, accountViewModel, nav) }
is HashtagTag -> key(mute.hashtag) { HashtagMemberRow(mute.hashtag, nav) }
is WordTag -> key(mute.word) { LabelMemberRow(mute.word) }
is EventTag ->
key(mute.eventId) {
MutedThreadRow(mute.eventId, quotesLeft, backgroundColor, accountViewModel, nav)
}
}
}
}
/** A muted conversation, shown as the note it silences. */
@Composable
private fun MutedThreadRow(
eventId: String,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (quotesLeft <= 0) return
LoadNote(eventId, accountViewModel) { note ->
note?.let {
NoteCompose(
baseNote = it,
isQuotedNote = true,
quotesLeft = quotesLeft - 1,
parentBackgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
/** NIP-51 kind 10015: the hashtags this user follows. */
@Composable
fun RenderHashtagList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? HashtagListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicHashtags() }
// HashtagListEvent exposes no private accessor of its own, but it is a PrivateTagArrayEvent
// like the rest, so its private half is read the same way InterestSetEvent reads its own.
val private by loadPrivateItems(noteEvent, accountViewModel) { signer ->
noteEvent.privateTags(signer)?.mapNotNull(HashtagTag::parse)
}
ListCard(
title = stringRes(R.string.kind_hashtag_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { tag ->
key(tag) { HashtagMemberRow(tag, nav) }
}
}
/** NIP-51 kind 30015: "interest topics represented by a bunch of hashtags". */
@Composable
fun RenderInterestSet(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? InterestSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicHashtags() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateHashtags(it) }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_interest_set),
description = noteEvent.description(),
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { tag ->
key(tag) { HashtagMemberRow(tag, nav) }
}
}
/**
* NIP-51 kind 10081: the places this user follows.
*
* A geohash is not a hashtag — it is a location, and it gets no `#`. Shown verbatim because a
* geohash is what the list stores; turning it into a place name would need a geocoder we don't
* have on this path.
*/
@Composable
fun RenderGeohashList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
) {
val noteEvent = baseNote.event as? GeohashListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicGeohashes() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.decryptPrivateGeohashes(it) }
ListCard(
title = stringRes(R.string.kind_geohash_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { geohash ->
key(geohash) { LabelMemberRow(geohash) }
}
}
@@ -0,0 +1,166 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types.lists
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent
import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent
/** NIP-51 kind 10017: the git authors whose patches this user wants to see. */
@Composable
fun RenderGitAuthorList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GitAuthorListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicAuthors() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
ListCard(
title = stringRes(R.string.kind_git_authors),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { author ->
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
}
}
/** NIP-51 kind 10020: the people whose media this user follows. */
@Composable
fun RenderMediaFollowList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MediaFollowListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicFollows() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFollows(it) }
ListCard(
title = stringRes(R.string.kind_media_follows),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { follow ->
key(follow.pubKey) { UserMemberRow(follow.pubKey, accountViewModel, nav) }
}
}
/** NIP-51 kind 10101: the authors whose wiki articles this user trusts. */
@Composable
fun RenderGoodWikiAuthorList(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? GoodWikiAuthorListEvent ?: return
val public = remember(noteEvent) { noteEvent.publicAuthors() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
ListCard(
title = stringRes(R.string.kind_good_wiki_authors),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { author ->
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
}
}
/**
* NIP-51 kind 30007: people muted for one kind only.
*
* "`d` tag MUST be the kind string" — so the `d` tag is not a name here, it is the kind being
* muted, and printing it as a title the way other sets do would be wrong.
*/
@Composable
fun RenderKindMuteSet(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? KindMuteSetEvent ?: return
val public = remember(noteEvent) { noteEvent.publicMutedUsers() }
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutedUsers(it) }
val mutedKind = remember(noteEvent) { noteEvent.dTag() }
ListCard(
title = stringRes(R.string.kind_mute_set_for_kind, mutedKind),
description = null,
items = rememberAllMembers(public, private),
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
backgroundColor = backgroundColor,
) { muted ->
key(muted.pubKey) { UserMemberRow(muted.pubKey, accountViewModel, nav) }
}
}
/**
* NIP-51 kind 39092: a media starter pack — "a named set of profiles to be shared around with the
* goal of being followed together". Public by construction: a pack exists to be handed out.
*/
@Composable
fun RenderMediaStarterPack(
baseNote: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? MediaStarterPackEvent ?: return
val members = remember(noteEvent) { noteEvent.followIds() }
ListCard(
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_media_starter_pack),
description = noteEvent.description(),
items = members,
hasUnreadablePrivateItems = false,
backgroundColor = backgroundColor,
) { pubKey ->
key(pubKey) { UserMemberRow(pubKey, accountViewModel, nav) }
}
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.notifications.ConversationShortcuts
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -398,6 +399,11 @@ class AccountSessionManager(
// `:napplet` call ProfileStore.deleteProfile(name) — and that must refuse a profile still in
// use by a live WebView. Not wired for this release; there is no existing hook that reaches
// the sandbox on account deletion.
// The launcher is the one place an account's contacts live outside our own
// storage, so the conversation shortcuts go before anything else — whether or not
// this is the account currently on screen. See [ConversationShortcuts].
ConversationShortcuts.removeForAccount(Amethyst.instance.appContext, accountInfo.npub)
if (accountInfo.npub == currentAccountNPub()) {
// Drop the Nest bridge ref before tearing down the
// current account so the audio-room activity can't
@@ -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)
@@ -2504,6 +2511,20 @@ class AccountViewModel(
suspend fun hasPublishedKeyPackage(): Boolean = account.marmot.hasPublishedKeyPackage()
/**
* Which install currently owns this account's Marmot invites. See [LatestKeyPackageOwner].
*
* [maxAgeSeconds] lets a passive caller reuse a recent answer instead of
* fanning a REQ across the write set; 0 always asks the relays.
*/
suspend fun latestKeyPackageOwner(maxAgeSeconds: Long = 0L): LatestKeyPackageOwner = account.marmot.latestKeyPackageOwner(maxAgeSeconds)
/** Republishes this device's KeyPackage; true only when a relay accepted it. */
suspend fun republishKeyPackage(): Boolean = account.marmot.republishKeyPackageConfirmed()
/** False for a read-only (pubkey-only) login, which cannot publish at all. */
fun canPublish(): Boolean = account.isWriteable()
/**
* Whether this account has a kind:10051 KeyPackage Relay List (MIP-00)
* advertising where it publishes KeyPackages.
@@ -154,6 +154,12 @@ private fun PreloadFor(
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
// The Marmot list renders from local state alone — marmotGroupList.rooms plus
// marmotManager.activeGroupIds() — and MarmotManager already owns a per-group
// subscription for every group it restores or joins. There is no list-level REQ
// to warm up, and adding one would duplicate those.
NavBarItem.MARMOT_GROUPS -> Unit
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
@@ -360,6 +360,18 @@ class GiftWrapEventHandler(
eventProcessor.consumeEvent(innerGift, innerGiftNote, publicNote)
}
}
// The wrap note already emitted once — from `consumeRegularEvent`, before any of this
// ran, when `innerEventId` was still null and the note said nothing about where it
// leads. Everything above is plain assignment, so without this the wrap's last word on
// itself is that first, useless emission: anything waiting on it (a `nostr:nevent…` for
// a wrap, a notification from a build that addressed DMs that way) waits forever while
// the message it wanted sits decrypted in the cache. Emitted last so an observer that
// wakes here can walk wrap → seal → rumor and find the whole chain linked.
//
// `flowSet?`, not `flow()`: a wrap nobody is watching — which is nearly all of them,
// a week's worth on every cold start — must not be given a flow set to hold.
eventNote.flowSet?.metadata?.invalidateData()
}
private suspend fun processExistingGiftWrap(
@@ -505,6 +517,11 @@ class SealedRumorEventHandler(
} else {
eventProcessor.consumeEvent(innerRumor, innerRumorNote, publicNote)
}
// Same as the wrap above: the seal's only emission came before it was opened, so
// re-emit now that it points at its rumor. This runs inside the wrap's own unwrap, so
// the wrap's emission lands after it and sees a fully linked chain.
eventNote.flowSet?.metadata?.invalidateData()
}
private suspend fun processExistingSealedRumor(
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/** Counts above this render as "N+" so a very busy room doesn't blow out the row. */
const val CHAT_UNREAD_CAP = 99
/**
* The unread pill every chat list shares: Concord channels, Marmot groups.
*
* Sizing is `sizeIn` + padding rather than a fixed `size`, so "99+" widens the
* pill instead of being clipped by it, and the label rides `labelSmall` rather
* than a hardcoded sp so it still tracks the reader's font scale.
*
* [contentDescription] is the caller's, because the plural that reads well out
* loud is per-surface ("3 unread messages" vs "3 unread invites"); the pill
* itself is the same object everywhere.
*/
@Composable
fun ChatUnreadBadge(
count: Int,
contentDescription: String,
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val label = if (count > CHAT_UNREAD_CAP) "$CHAT_UNREAD_CAP+" else count.toString()
Box(
modifier =
modifier
.semantics { this.contentDescription = contentDescription }
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
// snapping — a small touch that makes new activity feel noticed.
.animateContentSize()
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
}
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -166,6 +168,11 @@ fun CreateGroupScreen(
.padding(padding)
.consumeWindowInsets(padding)
.imePaddingSafe()
// The form is taller than the window once the IME is up, so
// `imePaddingSafe` alone just clips the retention picker and
// the footer off the bottom. Scrolling is what makes them
// reachable while the keyboard covers half the screen.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Text(
@@ -219,7 +226,7 @@ fun CreateGroupScreen(
Text(
stringRes(Res.string.marmot_create_group_footer),
modifier = Modifier.padding(top = 12.dp),
modifier = Modifier.padding(top = 12.dp, bottom = 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -157,6 +159,11 @@ fun EditGroupInfoScreen(
.padding(padding)
.consumeWindowInsets(padding)
.imePaddingSafe()
// Same reason as CreateGroupScreen: name + description +
// avatar-url + footer is taller than what is left once the
// IME is up, so the padding alone would clip the lower
// fields with no way to reach them.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(8.dp))
@@ -241,6 +248,7 @@ fun EditGroupInfoScreen(
text = stringRes(Res.string.marmot_edit_info_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -29,10 +28,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -52,13 +51,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -66,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_create_group
import com.vitorpamplona.amethyst.commons.resources.marmot_empty_create_action
import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name
import com.vitorpamplona.amethyst.commons.resources.marmot_groups_title
import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups
@@ -73,17 +71,29 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups_desc
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations_desc
import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests_count
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3Api::class)
@@ -142,6 +152,11 @@ fun MarmotGroupListScreen(
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
// Above the tabs, because the state it reports is the reason both
// tabs can be empty: invites landing on another install look
// exactly like no invites at all.
MarmotInviteDeviceBanner(accountViewModel)
PrimaryTabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
@@ -204,13 +219,25 @@ fun MarmotGroupListScreen(
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 4.dp),
)
// Only the Known tab gets a button. There is no action
// that fills the New Requests tab — you cannot make
// someone invite you — so offering one there would be a
// dead end dressed up as a next step.
if (selectedTab == 0) {
Button(
onClick = { nav.nav(Route.CreateMarmotGroup) },
modifier = Modifier.padding(top = 20.dp),
) {
Text(stringRes(Res.string.marmot_empty_create_action))
}
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(visibleGroups, key = { it.first }) { (groupId, chatroom) ->
itemsIndexed(visibleGroups, key = { _, it -> it.first }) { index, (groupId, chatroom) ->
MarmotGroupListItem(
groupId = groupId,
chatroom = chatroom,
@@ -219,7 +246,11 @@ fun MarmotGroupListScreen(
nav.nav(Route.MarmotGroupChat(groupId))
},
)
HorizontalDivider()
// Between rows only: a divider under the last one draws a
// line across empty space with nothing beneath it.
if (index < visibleGroups.lastIndex) {
HorizontalDivider()
}
}
}
}
@@ -264,6 +295,54 @@ fun MarmotGroupListItem(
// to ~100 entries, so counting per recomposition is cheap.
val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime }
// A preview has to survive the messages that carry no text: a system row
// keeps its state in tags and MIP-04 media keeps its in imeta, so both used
// to render as a blank second line under the group name.
val myPubKey = accountViewModel.account.signer.pubKey
val previewEvent = newestMessage?.event
val previewBody =
when {
newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet)
previewEvent == null -> stringRes(Res.string.marmot_preview_no_text)
previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated)
// Text first: an attachment usually carries a caption, and showing
// "Attachment" over the words the sender actually wrote would be a
// step back from the raw `content` this replaced.
previewEvent.content.isNotBlank() -> previewEvent.content
hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media)
else -> stringRes(Res.string.marmot_preview_no_text)
}
// Only a genuinely known name earns the prefix: `bestName` returns null
// without metadata, and "a1b2c3d4: hi" is noise, not attribution.
//
// Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a
// plain StateFlow.value read, so a kind:0 arriving after the row composed
// would never reach it.
//
// Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only
// subscribes for a sender the cache already knows, and a sender it does not
// simply goes unprefixed. Creating a User per unknown sender would put a
// metadata REQ behind every row of a list that is mostly strangers' names
// the reader never asked for — a preview line is not worth that.
val senderUser =
previewEvent
?.pubKey
?.takeIf { it != myPubKey }
?.let { accountViewModel.getUserIfExists(it) }
val senderName =
if (senderUser != null) {
observeUserInfo(senderUser, accountViewModel).value?.info?.bestName()
} else {
null
}
val previewText =
// A system caption already names its actor, so prefixing one would say it twice.
if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) {
stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody)
} else {
previewBody
}
Row(
modifier =
Modifier
@@ -276,7 +355,7 @@ fun MarmotGroupListItem(
if (memberPubkeys.isNotEmpty()) {
NonClickableUserPictures(
userHexList = memberPubkeys,
size = 55.dp,
size = Size55dp,
accountViewModel = accountViewModel,
)
}
@@ -304,47 +383,36 @@ fun MarmotGroupListItem(
overflow = TextOverflow.Ellipsis,
)
}
if (newestMessage != null) {
Text(
text = newestMessage.event?.content ?: "",
style = MaterialTheme.typography.bodySmall,
Text(
text = previewText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
// The list is sorted by newestMessage.createdAt(), so showing that time
// is what makes the ordering legible; the message count it replaced was
// a number no reader was asking for.
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
newestMessage?.createdAt()?.let { createdAt ->
ToggleableTimeAgoText(
timestamp = createdAt,
style = TimeAgoStyle.Short,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
} else {
Text(
text = stringRes(Res.string.marmot_no_messages_yet),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
// The row is the tap target. A toggleable timestamp would
// swallow taps meant to open the group.
toggleable = false,
)
}
}
Column(horizontalAlignment = Alignment.End) {
if (unread > 0) {
Box(
modifier =
Modifier
.size(22.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center,
) {
Text(
text = if (unread > 99) "99+" else unread.toString(),
color = MaterialTheme.colorScheme.onPrimary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
}
} else {
Text(
text = pluralStringResource(R.plurals.marmot_message_count, chatroom.messages.size, chatroom.messages.size),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
ChatUnreadBadge(
count = unread,
contentDescription = pluralStringResource(R.plurals.marmot_unread_messages, unread, unread),
)
}
}
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_banner
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_banner_dismiss
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_publish
import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/**
* How long a passive owner check may reuse its previous answer. What it reports
* only changes when another device publishes, which no user does often.
*/
private const val OWNER_CHECK_MAX_AGE_SECONDS = 15L * 60L
/**
* Warns, on the Marmot groups screen, that another install of this account is
* the one receiving its invites.
*
* This is the symptom's own screen. A user whose invites are landing on a
* second device sees an empty group list and no new requests — exactly what
* "nobody has invited me" looks like — so without a banner here the failure is
* indistinguishable from nothing happening, and the diagnosis sits behind a
* settings row nobody has a reason to open.
*
* Publishing stays a tap, never automatic: an inviter takes the newest
* KeyPackage, so a banner that republished itself on sight would deadlock the
* two installs against each other, each device's correction being the other's
* trigger.
*/
@Composable
fun MarmotInviteDeviceBanner(
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
var owner by remember { mutableStateOf<LatestKeyPackageOwner?>(null) }
// Saveable: a rotation is not a decision to un-dismiss a warning the user
// has already read and waved away.
var dismissed by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
// A read-only login cannot publish at all, so the only action this banner
// offers is impossible for it. Better to say nothing than to offer a button
// that silently does nothing.
val canPublish = accountViewModel.canPublish()
LaunchedEffect(Unit) {
if (!canPublish) return@LaunchedEffect
try {
owner =
withContext(Dispatchers.IO) {
// Passive check: reuse a recent answer rather than fanning a
// REQ across the whole write set on every entry to this screen.
accountViewModel.latestKeyPackageOwner(maxAgeSeconds = OWNER_CHECK_MAX_AGE_SECONDS)
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// A relay we cannot reach is not evidence of anything, so it stays
// silent rather than accusing a device that may well be this one.
owner = null
}
}
AnimatedVisibility(visible = owner == LatestKeyPackageOwner.OTHER_DEVICE && !dismissed) {
// Message on its own line, actions under it. All three in one Row fit
// only on a wide screen with default font scale; at phone width with
// large fonts the label and the button fought for the same space.
//
// `surfaceVariant`, not `errorContainer`: this is an explanation, and
// most users will never see it. The error palette would make it the
// loudest thing on a screen whose actual subject is the group list.
Column(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 4.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Text(
text = stringRes(Res.string.marmot_invite_device_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp, end = 4.dp).weight(1f),
)
IconButton(onClick = { dismissed = true }, modifier = Modifier.size(24.dp)) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(Res.string.marmot_invite_device_banner_dismiss),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(
onClick = {
// Hidden optimistically, but only *stays* hidden once a
// relay has accepted: `republishKeyPackage` waits for the
// OK, so a rejection, a read-only account or an empty relay
// set come back false instead of being reported as the
// success they are not.
owner = LatestKeyPackageOwner.THIS_DEVICE
scope.launch(Dispatchers.IO) {
val successMessage = stringRes(context, R.string.marmot_invite_device_success)
val rejectedMessage = stringRes(context, R.string.marmot_invite_device_rejected)
try {
val accepted = accountViewModel.republishKeyPackage()
launch(Dispatchers.Main) {
if (accepted) {
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, rejectedMessage, Toast.LENGTH_LONG).show()
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
}
} catch (e: CancellationException) {
// Leaving the screen mid-publish is not a failure to
// report, and swallowing it here would break the
// scope's cancellation as well as pop a toast for a
// banner that is already gone.
throw e
} catch (e: Exception) {
val failureMessage =
stringRes(context, R.string.marmot_invite_device_failure, e.message ?: "")
launch(Dispatchers.Main) {
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
// The warning was right after all, so put it back.
owner = LatestKeyPackageOwner.OTHER_DEVICE
}
}
}
},
) {
Text(
text = stringRes(Res.string.marmot_invite_device_publish),
style = MaterialTheme.typography.labelMedium,
)
}
}
}
}
}
@@ -20,32 +20,21 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */
private const val CONCORD_UNREAD_CAP = 99
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.CHAT_UNREAD_CAP
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
/**
* A small pill showing an unread-message [count] (new messages since this account last read).
* Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at
* [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers.
* [CHAT_UNREAD_CAP]+ and carries a plural content description for screen readers.
*
* The pill itself is [ChatUnreadBadge], shared with the other chat lists; this
* wrapper only supplies Concord's own plural so the two surfaces cannot drift
* apart visually.
*/
@Composable
fun ConcordUnreadBadge(
@@ -53,26 +42,9 @@ fun ConcordUnreadBadge(
modifier: Modifier = Modifier,
) {
if (count <= 0) return
val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString()
val description = pluralStringResource(R.plurals.concord_unread_messages, count, count)
Box(
modifier =
modifier
.semantics { contentDescription = description }
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
// snapping — a small touch that makes new activity feel noticed.
.animateContentSize()
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary,
)
}
ChatUnreadBadge(
count = count,
contentDescription = pluralStringResource(R.plurals.concord_unread_messages, count, count),
modifier = modifier,
)
}
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
class UserProfileGalleryFeedFilter(
@@ -53,7 +54,10 @@ class UserProfileGalleryFeedFilter(
val addressableNotes =
LocalCache.addressables
.filter(
listOf(VideoVerticalEvent.KIND, VideoVerticalEvent.KIND),
// Both NIP-71 addressable kinds: acceptableEvent() takes any
// AddressableVideoEvent, so listing 34236 twice silently kept every
// 34235 (horizontal) video out of the gallery.
listOf(VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND),
user.pubkeyHex,
) { _, it ->
acceptableEvent(it, params, user)
@@ -22,56 +22,118 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.looking_for_event
import com.vitorpamplona.amethyst.commons.resources.looking_for_event_title
import com.vitorpamplona.amethyst.commons.resources.looking_for_private_event
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteLocally
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* The waiting room for an event we can only name: hold here until it arrives, then replace
* this entry with wherever it actually belongs.
*
* It is a real screen, not a bare label. A notification tap on a cold process lands here
* (LocalCache is empty until the event is re-fetched), and with nothing but a line of text
* on `colorScheme.background` — black on the dark theme — the wait read as the app opening
* to a black screen, with no top bar and no way back other than the system gesture.
*/
@Composable
fun LoadRedirectScreen(
eventId: String?,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
nav: Nav,
) {
if (eventId == null) return
LoadNote(eventId, accountViewModel) { note ->
note?.let {
LoadRedirectScreen(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarExtensibleWithBackButton(
title = { Text(stringRes(Res.string.looking_for_event_title)) },
popBack = nav::popBack,
)
},
accountViewModel = accountViewModel,
) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(horizontal = 50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
LoadNote(eventId, accountViewModel) { note ->
Text(
// A private id is not something to show a user, and it must not be
// copyable off the screen either.
text =
if (isPrivate) {
stringRes(Res.string.looking_for_private_event)
} else {
stringRes(Res.string.looking_for_event, note?.idHex ?: eventId)
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 20.dp),
)
note?.let {
WatchAndRedirect(
baseNote = it,
isPrivate = isPrivate,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
}
/**
* Renders nothing: it only watches [baseNote] and pops this entry for the real destination
* the moment the event lands.
*
* A public note is watched through [observeNote], which also holds a relay subscription open
* so the note is actually fetched. A private one is watched through [observeNoteLocally],
* which asks no relay: a rumor id must never appear in a REQ, and it does not need to — the
* envelope carrying it is re-fetched by the always-on gift-wrap tail, and unwrapping it fires
* this flow.
*/
@Composable
fun LoadRedirectScreen(
private fun WatchAndRedirect(
baseNote: Note,
isPrivate: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteState by observeNote(baseNote, accountViewModel)
val noteState by if (isPrivate) observeNoteLocally(baseNote) else observeNote(baseNote, accountViewModel)
LaunchedEffect(key1 = noteState) {
val event = noteState.note.event
@@ -83,12 +145,4 @@ fun LoadRedirectScreen(
}
}
}
Column(
Modifier.fillMaxHeight().fillMaxWidth().padding(horizontal = 50.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(stringRes(Res.string.looking_for_event, baseNote.idHex))
}
}
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.kinds.KindNames
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
@@ -106,6 +107,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
@@ -144,6 +146,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
@@ -325,6 +328,9 @@ fun kindDisplayName(kind: Int): Int =
TorrentCommentEvent.KIND -> R.string.kind_torrent_comments
TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays
TrustProviderListEvent.KIND -> R.string.kind_trusted_providers
VideoCurationSetEvent.KIND -> R.string.kind_video_list
VideoCollaborationEvent.KIND -> R.string.kind_video_collaboration
TextTrackEvent.KIND -> R.string.kind_video_subtitles
VideoHorizontalEvent.KIND -> R.string.kind_video_repl
VideoVerticalEvent.KIND -> R.string.kind_shorts_repl
VideoNormalEvent.KIND -> R.string.kind_video
@@ -63,6 +63,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions_description
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast_description
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_download
@@ -359,6 +361,7 @@ fun videoPlayerActionName(action: VideoPlayerAction): String =
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download)
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip)
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast)
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions)
}
@Composable
@@ -371,4 +374,5 @@ fun videoPlayerActionDescription(action: VideoPlayerAction): String =
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download_description)
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip_description)
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast_description)
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions_description)
}
@@ -1471,4 +1471,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1350,4 +1350,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2611,4 +2611,5 @@
<string name="health_connect_rationale_activity_label">Health Connect a Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2393,4 +2393,5 @@
<string name="health_connect_rationale_activity_label">Health Connect und Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1306,4 +1306,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -108,4 +108,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1341,4 +1341,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1363,4 +1363,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1356,4 +1356,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1356,4 +1356,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1352,4 +1352,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1331,4 +1331,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1283,4 +1283,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1425,4 +1425,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2396,4 +2396,5 @@
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">मेरा स्वास्थ्य</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2396,4 +2396,5 @@
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">Fitnesz</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1274,4 +1274,5 @@ Seharusnya %3$s</string>
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1315,4 +1315,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1308,4 +1308,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1304,4 +1304,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1373,4 +1373,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2299,4 +2299,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2614,4 +2614,5 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<string name="my_fitness">Moja kondycja</string>
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2391,4 +2391,5 @@
<string name="health_connect_rationale_activity_label">Health Connect e Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1318,4 +1318,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -51,6 +51,7 @@
<string name="error_parsing_error_message">Ошибка при анализе сообщения об ошибке</string>
<string name="profile">Профиль</string>
<string name="security_filters">Фильтры безопасности</string>
<string name="show_less">Скрыть</string>
<string name="clink_requesting_invoice">Запрос счёта…</string>
<string name="clink_debit_no_response">Сервис списания не завершил платёж.</string>
<string name="clink_confirm_pay_amount_via_source">Оплатить %1$s через %2$s?</string>
@@ -117,6 +118,10 @@
<string name="password_is_required">Требуется пароль</string>
<string name="key_is_required">Нужно ввести ключ</string>
<string name="name_is_required">Требуется имя</string>
<string name="concord_invite_failed">Не удалось получить приглашение. Возможно, ссылка устарела или недоступна.</string>
<string name="concord_invite_links_title">Приглашения</string>
<string name="concord_invite_revoked_ok">Ссылка для приглашения отозвана.</string>
<string name="concord_channels_empty">Каналов еще нет.</string>
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
<!-- Title of the minichat (thread) screen opened from a chat message. -->
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
@@ -1391,4 +1396,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1386,4 +1386,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2535,4 +2535,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1361,4 +1361,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -2391,4 +2391,5 @@
<string name="health_connect_rationale_activity_label">Health Connect och Amethyst</string>
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1339,4 +1339,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1325,4 +1325,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1177,4 +1177,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1322,4 +1322,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1386,4 +1386,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1341,4 +1341,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>
@@ -1292,4 +1292,5 @@
because android:label on the manifest entry can only reference @string/. -->
<!-- My Fitness: the signed-in user's own training summary. Android resource because
NavBarItemDef labels are R.string ids. -->
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
</resources>

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