mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
@@ -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
|
||||
|
||||
+6
@@ -107,6 +107,7 @@ enum class VideoPlayerAction {
|
||||
Download,
|
||||
PictureInPicture,
|
||||
Cast,
|
||||
Captions,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -127,6 +128,7 @@ val DefaultVideoPlayerButtonItems =
|
||||
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
|
||||
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
|
||||
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
|
||||
VideoPlayerButtonItem(VideoPlayerAction.Captions, VideoButtonLocation.TopBar),
|
||||
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
|
||||
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
|
||||
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
|
||||
@@ -184,6 +186,10 @@ class AccountNavigationPreferencesInternal(
|
||||
@Serializable
|
||||
class AccountVideoPlayerPreferencesInternal(
|
||||
var buttonItems: List<VideoPlayerButtonItem> = DefaultVideoPlayerButtonItems,
|
||||
// Feed videos autoplay muted, so captions are what make them legible — on by default, and
|
||||
// remembered once the viewer says otherwise. Defaulted rather than required so a blob written
|
||||
// by a client that predates the field still decodes.
|
||||
var captionsEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
+180
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+171
@@ -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
|
||||
}
|
||||
+114
-22
@@ -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>,
|
||||
|
||||
+87
-3
@@ -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,
|
||||
|
||||
+46
@@ -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))
|
||||
|
||||
+16
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -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,
|
||||
|
||||
+25
@@ -70,6 +70,16 @@ fun GetVideoController(
|
||||
// warm-pool fast path keeps position and buffered data intact.
|
||||
val keepAlive = remember { mutableStateOf(true) }
|
||||
|
||||
// Captions can resolve *after* playback starts: a `text-track` tag naming a kind-39307
|
||||
// coordinate is only turned into a SubtitleConfiguration once that event arrives, which
|
||||
// rebuilds `mediaItem` with the same videoUri and a different subtitle list. The mediaId is
|
||||
// the video URI either way, so the warm-pool fast path below would read "already loaded" and
|
||||
// the new tracks would never reach the player. A MediaController doesn't carry the item's
|
||||
// `localConfiguration` across the service IPC, so the subtitles can't be read back off the
|
||||
// controller to compare — we remember what we last pushed instead. Deliberately keyless so it
|
||||
// survives the keyed remember below being rebuilt by the very change it has to detect.
|
||||
val lastAppliedCaptions = remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
val controllerState by remember(mediaItem, keepAlive.value) {
|
||||
if (keepAlive.value) {
|
||||
PlaybackServiceClient
|
||||
@@ -118,6 +128,7 @@ fun GetVideoController(
|
||||
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
|
||||
// to cold and resurfaced, or hit an error before we attached).
|
||||
val targetMediaId = mediaItem.item.mediaId
|
||||
val captionsFingerprint = mediaItem.src.captions.hashCode()
|
||||
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
|
||||
if (needsLoad) {
|
||||
// Cold load: a fresh decoder/codec instance gets allocated here. If a
|
||||
@@ -127,6 +138,20 @@ fun GetVideoController(
|
||||
Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" }
|
||||
state.controller.setMediaItem(mediaItem.item)
|
||||
state.controller.prepare()
|
||||
lastAppliedCaptions.value = captionsFingerprint
|
||||
} else if (lastAppliedCaptions.value != null && lastAppliedCaptions.value != captionsFingerprint) {
|
||||
// Same video, new caption set, and this composable is the one that loaded
|
||||
// it: re-set the item so the side-loaded subtitle sources get merged in.
|
||||
// Restarting from the current position keeps the rebuffer down to whatever
|
||||
// the cache can't serve. The `!= null` guard is what keeps a warm player
|
||||
// that arrived already carrying someone else's item from being re-prepared
|
||||
// on a fingerprint we never pushed — that's the rebuffer the pool exists
|
||||
// to avoid, and its captions are whatever that mount applied.
|
||||
val resumeAt = state.controller.currentPosition
|
||||
Log.d("PlaybackService") { "Captions changed for $targetMediaId - re-applying at $resumeAt" }
|
||||
state.controller.setMediaItem(mediaItem.item, resumeAt)
|
||||
state.controller.prepare()
|
||||
lastAppliedCaptions.value = captionsFingerprint
|
||||
} else if (state.controller.playbackState == Player.STATE_IDLE) {
|
||||
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
|
||||
state.controller.prepare()
|
||||
|
||||
+10
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.Fullscree
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.LogVideoQualitySelection
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.METERED_MAX_SHORT_SIDE_PX
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCaptions
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
|
||||
@@ -217,6 +218,15 @@ fun RenderVideoPlayer(
|
||||
shutter = {},
|
||||
)
|
||||
|
||||
// Above the picture but below the controls, so the transport bar and the bottom gradient
|
||||
// draw over the cues rather than the other way around.
|
||||
if (mediaItem.src.captions.isNotEmpty()) {
|
||||
RenderCaptions(
|
||||
controllerState = controllerState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
|
||||
RenderPlaybackError(
|
||||
controllerState = controllerState,
|
||||
videoUri = mediaItem.src.videoUri,
|
||||
|
||||
+8
-1
@@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
|
||||
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
|
||||
@@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Immutable
|
||||
class WaveformData(
|
||||
@@ -73,6 +76,7 @@ fun VideoView(
|
||||
thumbhash: String? = null,
|
||||
isLiveStream: Boolean = false,
|
||||
hash: String? = null,
|
||||
captions: ImmutableList<CaptionTrack> = persistentListOf(),
|
||||
) {
|
||||
val borderModifier =
|
||||
if (roundedCorner) {
|
||||
@@ -83,7 +87,7 @@ fun VideoView(
|
||||
Modifier
|
||||
}
|
||||
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash)
|
||||
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash, captions = captions)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -107,6 +111,7 @@ fun VideoView(
|
||||
accountViewModel: AccountViewModel,
|
||||
thumbhash: String? = null,
|
||||
hash: String? = null,
|
||||
captions: ImmutableList<CaptionTrack> = persistentListOf(),
|
||||
) {
|
||||
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
|
||||
// Reset the manual-show toggle when the video URI changes so a recycled feed slot
|
||||
@@ -180,6 +185,7 @@ fun VideoView(
|
||||
dim = dimensions,
|
||||
hash = hash,
|
||||
thumbhash = thumbhash,
|
||||
captions = captions,
|
||||
accountViewModel = accountViewModel,
|
||||
showControls = showControls,
|
||||
)
|
||||
@@ -236,6 +242,7 @@ fun VideoView(
|
||||
dim = dimensions,
|
||||
hash = hash,
|
||||
thumbhash = thumbhash,
|
||||
captions = captions,
|
||||
accountViewModel = accountViewModel,
|
||||
showControls = showControls,
|
||||
)
|
||||
|
||||
+5
@@ -26,10 +26,13 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
val DEFAULT_MUTED_SETTING = mutableStateOf(true)
|
||||
|
||||
@@ -57,6 +60,7 @@ fun VideoViewInner(
|
||||
dim: DimensionTag? = null,
|
||||
hash: String? = null,
|
||||
thumbhash: String? = null,
|
||||
captions: ImmutableList<CaptionTrack> = persistentListOf(),
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
|
||||
@@ -83,6 +87,7 @@ fun VideoViewInner(
|
||||
dim = dim,
|
||||
hash = hash,
|
||||
thumbhash = thumbhash,
|
||||
captions = captions,
|
||||
) { mediaItem ->
|
||||
GetVideoController(
|
||||
mediaItem = mediaItem,
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The text tracks a player has, in the order the media declared them.
|
||||
*
|
||||
* A NIP-71 video can carry a `text-track` per language, so "captions" is not one thing to switch
|
||||
* on — it is a list to choose from. Groups the player reports as unsupported are dropped: offering
|
||||
* a language the decoder will refuse is worse than not offering it.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun getTextTrackChoices(tracks: Tracks): ImmutableList<CaptionChoice> {
|
||||
val choices = mutableListOf<CaptionChoice>()
|
||||
tracks.groups
|
||||
.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
.forEach { group ->
|
||||
for (i in 0 until group.length) {
|
||||
if (!group.isTrackSupported(i)) continue
|
||||
val format = group.getTrackFormat(i)
|
||||
choices.add(
|
||||
CaptionChoice(
|
||||
group = group,
|
||||
trackIndex = i,
|
||||
language = format.language,
|
||||
label = format.label,
|
||||
isSelected = group.isTrackSelected(i),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return choices.toImmutableList()
|
||||
}
|
||||
|
||||
internal data class CaptionChoice(
|
||||
val group: Tracks.Group,
|
||||
val trackIndex: Int,
|
||||
val language: String?,
|
||||
val label: String?,
|
||||
val isSelected: Boolean,
|
||||
) {
|
||||
/**
|
||||
* What to print for this track.
|
||||
*
|
||||
* The publisher's own label wins when there is one. Otherwise the language code is expanded
|
||||
* into the reader's locale — "en" is not a word to most people, "English" is — and only a
|
||||
* track that names neither falls back to a generic term.
|
||||
*/
|
||||
fun displayName(fallback: String): String =
|
||||
label?.takeIf { it.isNotBlank() }
|
||||
?: language?.takeIf { it.isNotBlank() }?.let { Locale.forLanguageTag(it).displayLanguage.ifBlank { it } }
|
||||
?: fallback
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun selectTextTrack(
|
||||
player: Player,
|
||||
choice: CaptionChoice,
|
||||
) {
|
||||
player.trackSelectionParameters =
|
||||
player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
// Clearing the type disable as well: picking a language is a request to see captions,
|
||||
// so it has to undo an earlier "off" rather than silently select a track nothing draws.
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
.setOverrideForType(TrackSelectionOverride(choice.group.mediaTrackGroup, choice.trackIndex))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* The language menu, shown in place of a plain on/off toggle when a video offers more than one
|
||||
* caption track.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CaptionLanguagePopup(
|
||||
choices: ImmutableList<CaptionChoice>,
|
||||
captionsEnabled: Boolean,
|
||||
onSelectOff: () -> Unit,
|
||||
onSelectTrack: (CaptionChoice) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
val baseColors = ButtonDefaults.textButtonColors()
|
||||
val contentColor = MaterialTheme.colorScheme.onBackground
|
||||
val colors = remember(baseColors, contentColor) { baseColors.copy(contentColor = contentColor) }
|
||||
val unnamed = stringRes(R.string.captions_unnamed_track)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
TextButton(colors = colors, onClick = onSelectOff) {
|
||||
Text(
|
||||
stringRes(R.string.captions_off),
|
||||
fontWeight = if (!captionsEnabled) FontWeight(1000) else FontWeight(400),
|
||||
)
|
||||
}
|
||||
|
||||
choices.forEach { choice ->
|
||||
TextButton(colors = colors, onClick = { onSelectTrack(choice) }) {
|
||||
Text(
|
||||
choice.displayName(unnamed),
|
||||
fontWeight = if (captionsEnabled && choice.isSelected) FontWeight(1000) else FontWeight(400),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -79,6 +79,9 @@ fun OverflowMenuButtonPreview() {
|
||||
onCastClick = {},
|
||||
castIcon = MaterialSymbols.Cast,
|
||||
castContentDescription = "",
|
||||
onCaptionsClick = {},
|
||||
captionsIcon = MaterialSymbols.ClosedCaption,
|
||||
captionsContentDescription = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +101,9 @@ fun AnimatedOverflowMenuButton(
|
||||
onCastClick: () -> Unit,
|
||||
castIcon: MaterialSymbol,
|
||||
castContentDescription: String,
|
||||
onCaptionsClick: () -> Unit,
|
||||
captionsIcon: MaterialSymbol,
|
||||
captionsContentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
@@ -118,6 +124,9 @@ fun AnimatedOverflowMenuButton(
|
||||
onCastClick = onCastClick,
|
||||
castIcon = castIcon,
|
||||
castContentDescription = castContentDescription,
|
||||
onCaptionsClick = onCaptionsClick,
|
||||
captionsIcon = captionsIcon,
|
||||
captionsContentDescription = captionsContentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,6 +144,9 @@ fun OverflowMenuButton(
|
||||
onCastClick: () -> Unit,
|
||||
castIcon: MaterialSymbol,
|
||||
castContentDescription: String,
|
||||
onCaptionsClick: () -> Unit,
|
||||
captionsIcon: MaterialSymbol,
|
||||
captionsContentDescription: String,
|
||||
) {
|
||||
val menuExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
@@ -239,6 +251,16 @@ fun OverflowMenuButton(
|
||||
onCastClick()
|
||||
}
|
||||
}
|
||||
|
||||
VideoPlayerAction.Captions -> {
|
||||
M3ActionRow(
|
||||
icon = captionsIcon,
|
||||
text = captionsContentDescription,
|
||||
) {
|
||||
menuExpanded.value = false
|
||||
onCaptionsClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.controls
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
|
||||
|
||||
/**
|
||||
* Draws the cues of the currently selected text track over the player.
|
||||
*
|
||||
* media3's Compose UI has no subtitle view — that lives in the View-based `PlayerView`, which this
|
||||
* app does not use — so the cues are collected from the player and laid out here. Only the cue
|
||||
* text is honoured: a WebVTT region/position would need the player's video rect, and every
|
||||
* NIP-71 caption seen in the wild is plain bottom-centred dialogue.
|
||||
*
|
||||
* Nothing renders when no text track is selected, so this composable costs one listener on videos
|
||||
* without captions.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderCaptions(
|
||||
controllerState: MediaControllerState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val player = controllerState.controller
|
||||
var lines by remember(player) { mutableStateOf<List<String>>(emptyList()) }
|
||||
|
||||
DisposableEffect(player) {
|
||||
lines =
|
||||
player.currentCues.cues
|
||||
.mapNotNull { it.text?.toString() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
val listener =
|
||||
object : Player.Listener {
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
lines = cueGroup.cues.mapNotNull { it.text?.toString() }.filter { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
onDispose { player.removeListener(listener) }
|
||||
}
|
||||
|
||||
if (lines.isEmpty()) return
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
lines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
// A scrim behind the glyphs rather than a full-width bar: captions sit over the
|
||||
// picture, and a translucent box only where there is text keeps the frame visible.
|
||||
modifier =
|
||||
Modifier
|
||||
.background(CaptionScrim, MaterialTheme.shapes.extraSmall)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val CaptionScrim = Color.Black.copy(alpha = 0.6f)
|
||||
+87
-2
@@ -46,6 +46,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Tracks
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
@@ -98,6 +99,8 @@ fun RenderTopButtonsPreview() {
|
||||
onPictureInPictureClick = {},
|
||||
onZoomClick = {},
|
||||
onOverflowQualityClick = {},
|
||||
captionsEnabled = true,
|
||||
onCaptionsClick = {},
|
||||
modifier = Modifier,
|
||||
accountViewModel = mockAccountViewModel(),
|
||||
)
|
||||
@@ -143,6 +146,43 @@ fun RenderTopButtons(
|
||||
val videoGroup = getVideoTrackGroup(tracks)
|
||||
val hasMultipleQualities = videoGroup != null && videoGroup.length > 1
|
||||
|
||||
// Captions are side-loaded from the event's `text-track` tags (MediaItemCache flags the first
|
||||
// one default). The account setting is the source of truth, not the player: a player comes
|
||||
// out of the warm pool carrying whatever track selection the previous video left on it, so
|
||||
// reading the preference off the instance would make the button's state depend on which
|
||||
// player this video happened to be handed.
|
||||
val captionsEnabled by accountViewModel.captionsEnabledFlow().collectAsStateWithLifecycle()
|
||||
|
||||
// Push the preference onto whichever player is attached, and re-push when either changes.
|
||||
// Disabling the whole track type rather than deselecting one group: the type-level switch
|
||||
// survives the player later picking a different track.
|
||||
LaunchedEffect(player, captionsEnabled) {
|
||||
val alreadyDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)
|
||||
if (alreadyDisabled == captionsEnabled) {
|
||||
player.trackSelectionParameters =
|
||||
player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !captionsEnabled)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
// A video with one track has nothing to choose between, so the button stays a toggle. Only a
|
||||
// multi-language video opens a menu — paying a popup for a single "English" row would be a
|
||||
// worse answer to the same tap.
|
||||
val captionChoices = remember(tracks) { getTextTrackChoices(tracks) }
|
||||
val captionsPopupOpen = remember { mutableStateOf(false) }
|
||||
val onCaptionsClick =
|
||||
remember(captionsEnabled, captionChoices) {
|
||||
{
|
||||
if (captionChoices.size > 1) {
|
||||
captionsPopupOpen.value = true
|
||||
} else {
|
||||
accountViewModel.setCaptionsEnabled(!captionsEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val overflowQualityOpen = remember { mutableStateOf(false) }
|
||||
|
||||
// Pause local playback while this video is casting so audio doesn't
|
||||
@@ -210,10 +250,31 @@ fun RenderTopButtons(
|
||||
}
|
||||
},
|
||||
onOverflowQualityClick = { overflowQualityOpen.value = true },
|
||||
captionsEnabled = captionsEnabled,
|
||||
onCaptionsClick = onCaptionsClick,
|
||||
modifier = modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
|
||||
if (captionsPopupOpen.value) {
|
||||
CaptionLanguagePopup(
|
||||
choices = captionChoices,
|
||||
captionsEnabled = captionsEnabled,
|
||||
onSelectOff = {
|
||||
accountViewModel.setCaptionsEnabled(false)
|
||||
captionsPopupOpen.value = false
|
||||
},
|
||||
onSelectTrack = { choice ->
|
||||
// The override goes straight onto the player (it is per-video), while the
|
||||
// preference is what persists the fact that captions are wanted at all.
|
||||
selectTextTrack(player, choice)
|
||||
accountViewModel.setCaptionsEnabled(true)
|
||||
captionsPopupOpen.value = false
|
||||
},
|
||||
onDismiss = { captionsPopupOpen.value = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (overflowQualityOpen.value && videoGroup != null) {
|
||||
VideoQualityPopup(
|
||||
player = player,
|
||||
@@ -237,10 +298,15 @@ fun RenderTopButtons(
|
||||
onPictureInPictureClick: () -> Unit,
|
||||
onZoomClick: (() -> Unit)?,
|
||||
onOverflowQualityClick: () -> Unit,
|
||||
captionsEnabled: Boolean,
|
||||
onCaptionsClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
|
||||
val captionsIcon = if (captionsEnabled) MaterialSymbols.ClosedCaption else MaterialSymbols.ClosedCaptionDisabled
|
||||
val captionsContentDescription =
|
||||
stringRes(if (captionsEnabled) R.string.captions_turn_off else R.string.captions_turn_on)
|
||||
val shareDialogVisible = remember { mutableStateOf(false) }
|
||||
val castDialogVisible = remember { mutableStateOf(false) }
|
||||
val castSessionState by Amethyst.instance.castRegistry.sessionState
|
||||
@@ -297,21 +363,28 @@ fun RenderTopButtons(
|
||||
VideoPlayerAction.Cast -> {
|
||||
BuildConfig.IS_CASTING_AVAILABLE && mediaData.videoUri.startsWith("http", ignoreCase = true)
|
||||
}
|
||||
|
||||
// A video with no `text-track` has nothing to toggle, so the button stays out of the
|
||||
// row entirely rather than sitting there inert.
|
||||
VideoPlayerAction.Captions -> {
|
||||
mediaData.captions.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
val canFullscreen = onZoomClick != null
|
||||
val hasCaptions = mediaData.captions.isNotEmpty()
|
||||
// ImmutableList so Compose can treat the action lists as stable parameters when they're
|
||||
// passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the
|
||||
// overflow tree to recompose whenever any unrelated parent state ticks.
|
||||
val topBarActions =
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
|
||||
buttonItems
|
||||
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
|
||||
.map { it.action }
|
||||
.toImmutableList()
|
||||
}
|
||||
val overflowActions =
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
|
||||
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
|
||||
buttonItems
|
||||
.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }
|
||||
.map { it.action }
|
||||
@@ -377,6 +450,15 @@ fun RenderTopButtons(
|
||||
onClick = onCastButtonClick,
|
||||
)
|
||||
}
|
||||
|
||||
VideoPlayerAction.Captions -> {
|
||||
AnimatedTopBarIconButton(
|
||||
controllerVisible = controllerVisible,
|
||||
symbol = captionsIcon,
|
||||
contentDescription = captionsContentDescription,
|
||||
onClick = onCaptionsClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +476,9 @@ fun RenderTopButtons(
|
||||
onCastClick = onCastButtonClick,
|
||||
castIcon = castIcon,
|
||||
castContentDescription = castContentDescription,
|
||||
onCaptionsClick = onCaptionsClick,
|
||||
captionsIcon = captionsIcon,
|
||||
captionsContentDescription = captionsContentDescription,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+31
-2
@@ -21,11 +21,16 @@
|
||||
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
|
||||
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
val mediaItemCache = MediaItemCache()
|
||||
|
||||
@@ -46,10 +51,21 @@ fun GetMediaItem(
|
||||
dim: DimensionTag? = null,
|
||||
hash: String? = null,
|
||||
thumbhash: String? = null,
|
||||
captions: ImmutableList<CaptionTrack> = persistentListOf(),
|
||||
inner: @Composable (LoadedMediaItem) -> Unit,
|
||||
) {
|
||||
val data =
|
||||
remember(videoUri) {
|
||||
// Captions join the key. They resolve asynchronously — a `text-track` that names a
|
||||
// kind-39307 coordinate has to reach LocalCache first — so keying on the URI alone would
|
||||
// lock in the caption-less item built on the first frame. The list only ever goes empty ->
|
||||
// resolved, so this rebuilds at most once.
|
||||
//
|
||||
// What that rebuild reaches depends on when it lands. A feed video is mounted long before
|
||||
// it is scrolled into view and played, so the item carrying the tracks is the one the
|
||||
// controller loads. For a video already playing, GetVideoController's warm-path check sees
|
||||
// the same mediaId and deliberately does not re-set the item, so the late tracks wait for
|
||||
// the next mount rather than restarting playback under the viewer.
|
||||
remember(videoUri, captions) {
|
||||
MediaItemData(
|
||||
videoUri = videoUri,
|
||||
authorName = authorName,
|
||||
@@ -66,6 +82,7 @@ fun GetMediaItem(
|
||||
dim = dim,
|
||||
hash = hash,
|
||||
thumbhash = thumbhash,
|
||||
captions = captions,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,7 +96,19 @@ fun GetMediaItem(
|
||||
) {
|
||||
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
|
||||
|
||||
mediaItem?.let {
|
||||
// produceCachedState re-remembers on a new key and starts at null while the replacement is
|
||||
// built off-thread. Rendering that null would drop the whole player subtree for a frame and
|
||||
// rebuild it — a visible blink — and for the one case that changes the key mid-playback
|
||||
// (captions resolving after the first frame) the warm-pool check then declines to re-set an
|
||||
// item carrying the same mediaId, so the blink would buy nothing. Keep drawing the item we
|
||||
// already have until its replacement exists.
|
||||
val previous = remember { mutableStateOf<LoadedMediaItem?>(null) }
|
||||
val current = mediaItem
|
||||
if (current != null && previous.value !== current) {
|
||||
SideEffect { previous.value = current }
|
||||
}
|
||||
|
||||
(current ?: previous.value)?.let {
|
||||
inner(it)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.MimeTypes
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
|
||||
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -61,6 +63,25 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
|
||||
return null
|
||||
}
|
||||
|
||||
// A side-loaded track needs its media type declared: a NIP-71 `text-track` says nothing
|
||||
// about the format, and a blossom-hosted one is `https://host/<sha256>` with no extension
|
||||
// for ExoPlayer to infer from either. A track flagged default is the one ExoPlayer
|
||||
// selects without being asked.
|
||||
internal fun CaptionTrack.toSubtitleConfiguration(isDefault: Boolean = false): MediaItem.SubtitleConfiguration =
|
||||
MediaItem.SubtitleConfiguration
|
||||
.Builder(url.toUri())
|
||||
.setMimeType(mimeType)
|
||||
.setLanguage(language)
|
||||
.setLabel(label())
|
||||
.setSelectionFlags(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0)
|
||||
.build()
|
||||
|
||||
private fun CaptionTrack.label(): String? =
|
||||
when {
|
||||
language != null && type != null -> "$language \u00B7 $type"
|
||||
else -> language ?: type
|
||||
}
|
||||
|
||||
// Restrict `.m3u8` matching to the path component so a query param or
|
||||
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
|
||||
// MP4 URI doesn't misroute to HlsMediaSource.
|
||||
@@ -83,7 +104,15 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
|
||||
.setMediaId(key.videoUri)
|
||||
.setUri(key.videoUri)
|
||||
.apply { normalizedMime?.let { setMimeType(it) } }
|
||||
.setMediaMetadata(
|
||||
.setSubtitleConfigurations(
|
||||
// Flagging the first track default is what decides WHICH track plays
|
||||
// once text is enabled. WHETHER it is enabled is the account's
|
||||
// captionsEnabled preference, which RenderTopButtons pushes onto the
|
||||
// player as a track-type switch — the two are independent.
|
||||
key.captions.mapIndexed { index, track ->
|
||||
track.toSubtitleConfiguration(isDefault = index == 0)
|
||||
},
|
||||
).setMediaMetadata(
|
||||
MediaMetadata
|
||||
.Builder()
|
||||
.setArtist(key.authorName?.ifBlank { null })
|
||||
|
||||
+6
@@ -22,8 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.media3.common.MediaItem
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Immutable
|
||||
data class MediaItemData(
|
||||
@@ -48,6 +51,9 @@ data class MediaItemData(
|
||||
val dim: DimensionTag? = null,
|
||||
val hash: String? = null,
|
||||
val thumbhash: String? = null,
|
||||
// NIP-71 `text-track` captions to side-load. A data class is the cache key, so this has to be
|
||||
// a value that compares by content — ImmutableList keeps that true and keeps the key @Stable.
|
||||
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
||||
+55
-4
@@ -29,6 +29,8 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.hls.HlsMediaSource
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.MergingMediaSource
|
||||
import androidx.media3.exoplayer.source.SingleSampleMediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
|
||||
@@ -104,6 +106,7 @@ class CustomMediaSourceFactory(
|
||||
dataSourceFactory: DataSource.Factory,
|
||||
) : MediaSource.Factory {
|
||||
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
|
||||
private val plainDataSource: DataSource.Factory = dataSourceFactory
|
||||
|
||||
private val cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(cachingDataSource)
|
||||
@@ -119,9 +122,10 @@ class CustomMediaSourceFactory(
|
||||
//
|
||||
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
|
||||
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
|
||||
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
|
||||
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
|
||||
// be mirrored here.
|
||||
// live target-offset defaults. Subtitles ARE reachable now (MediaItemCache side-loads NIP-71
|
||||
// `text-track` captions), so createMediaSource below mirrors that wrap for the HLS path;
|
||||
// clipping and ad insertion remain unused, and the live setters aren't on the
|
||||
// MediaSource.Factory interface. Anything else added later must be mirrored here too.
|
||||
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
|
||||
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
|
||||
|
||||
@@ -137,7 +141,13 @@ class CustomMediaSourceFactory(
|
||||
return this
|
||||
}
|
||||
|
||||
// Retained as well as forwarded: the subtitle sources in withSideLoadedSubtitles are built per
|
||||
// call rather than living in allFactories, so this is the only way they can be given the same
|
||||
// retry/backoff behaviour the player configured for everything else.
|
||||
private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null
|
||||
|
||||
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
|
||||
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy
|
||||
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
|
||||
return this
|
||||
}
|
||||
@@ -161,7 +171,16 @@ class CustomMediaSourceFactory(
|
||||
} else {
|
||||
if (bypassCache) nonCachingFactory else cachingFactory
|
||||
}
|
||||
val source = factory.createMediaSource(mediaItem)
|
||||
// DefaultMediaSourceFactory is what merges side-loaded subtitle tracks into the playback
|
||||
// source. The explicit HLS factory above does not, so on that path a caption track would
|
||||
// load into nothing; mirror the wrap here. Progressive items already got it from the
|
||||
// Default factory, so they must NOT be wrapped again.
|
||||
val source =
|
||||
if (hls) {
|
||||
withSideLoadedSubtitles(factory.createMediaSource(mediaItem), mediaItem, bypassCache)
|
||||
} else {
|
||||
factory.createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
|
||||
// shouldBypassCache.
|
||||
@@ -172,6 +191,38 @@ class CustomMediaSourceFactory(
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds what [DefaultMediaSourceFactory] does for `subtitleConfigurations`: each side-loaded
|
||||
* track becomes its own single-sample source, merged alongside the video.
|
||||
*
|
||||
* Subtitle files are small, immutable documents, so they follow the video's cache decision —
|
||||
* a track fetched beside a cached VOD playlist is worth keeping too, and one beside a live
|
||||
* stream is not worth a cache entry for a stream that is never replayed.
|
||||
*/
|
||||
private fun withSideLoadedSubtitles(
|
||||
source: MediaSource,
|
||||
mediaItem: MediaItem,
|
||||
bypassCache: Boolean,
|
||||
): MediaSource {
|
||||
val subtitles = mediaItem.localConfiguration?.subtitleConfigurations.orEmpty()
|
||||
if (subtitles.isEmpty()) return source
|
||||
|
||||
val dataSource = if (bypassCache) plainDataSource else cachingDataSource
|
||||
val sources =
|
||||
Array(subtitles.size + 1) { index ->
|
||||
if (index == 0) {
|
||||
source
|
||||
} else {
|
||||
SingleSampleMediaSource
|
||||
.Factory(dataSource)
|
||||
.apply { loadErrorHandlingPolicy?.let { setLoadErrorHandlingPolicy(it) } }
|
||||
.createMediaSource(subtitles[index - 1], C.TIME_UNSET)
|
||||
}
|
||||
}
|
||||
|
||||
return MergingMediaSource(*sources)
|
||||
}
|
||||
|
||||
// Only the explicit event-kind flag (kind:30311 live activities). Returns false when the flag
|
||||
// is absent or false; the HLS URL check in createMediaSource covers everything else.
|
||||
private fun isFlaggedLive(mediaItem: MediaItem): Boolean {
|
||||
|
||||
+15
@@ -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 -> {
|
||||
|
||||
+1
@@ -523,6 +523,7 @@ private fun RenderImageOrVideo(
|
||||
controllerVisible = controllerVisible,
|
||||
hasBlurhash = content.blurhash != null,
|
||||
isFullscreen = true,
|
||||
captions = content.captions,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -277,6 +277,7 @@ fun ZoomableContentView(
|
||||
thumbhash = content.thumbhash,
|
||||
isLiveStream = content.isLiveStream,
|
||||
hash = content.hash,
|
||||
captions = content.captions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
+18
@@ -73,6 +73,7 @@ enum class NavBarItem {
|
||||
PUBLIC_CHATS,
|
||||
RELAY_GROUPS,
|
||||
CONCORD,
|
||||
MARMOT_GROUPS,
|
||||
GEOHASH_CHATS,
|
||||
FOLLOW_PACKS,
|
||||
LIVE_STREAMS,
|
||||
@@ -387,6 +388,22 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
|
||||
icon = MaterialSymbols.Group,
|
||||
resolveRoute = { Route.Concords },
|
||||
),
|
||||
NavBarItem.MARMOT_GROUPS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.MARMOT_GROUPS,
|
||||
// Duplicates the already-translated `marmot_groups_title` in
|
||||
// commonsUI's composeResources, which the screen's own title uses.
|
||||
// Unavoidable here: `labelRes` is an Android @StringRes and every
|
||||
// other catalog entry is one, so a Compose resource cannot be
|
||||
// referenced without changing the type for all ~60 of them. Crowdin
|
||||
// manages both resource sets, so this one gets translated too.
|
||||
labelRes = R.string.marmot_groups_title,
|
||||
// Lock, not Group: the rooms list already labels a Marmot room
|
||||
// with this symbol, so it is the signifier users have learned
|
||||
// for these, and it keeps the row distinct from Concord's.
|
||||
icon = MaterialSymbols.Lock,
|
||||
resolveRoute = { Route.MarmotGroupList },
|
||||
),
|
||||
NavBarItem.GEOHASH_CHATS to
|
||||
NavBarItemDef(
|
||||
id = NavBarItem.GEOHASH_CHATS,
|
||||
@@ -522,6 +539,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.MARMOT_GROUPS,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
),
|
||||
),
|
||||
|
||||
+1
@@ -144,6 +144,7 @@ private val DrawerFeedsItems: List<NavBarItem> =
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.MARMOT_GROUPS,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.captionTracks
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.mergeCaptionTracks
|
||||
import com.vitorpamplona.amethyst.commons.model.nip71Video.toCaptionTrack
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Resolves the caption tracks a NIP-71 video advertises, fetching the addressable ones.
|
||||
*
|
||||
* A `text-track` that names a URL is usable immediately. One that names a
|
||||
* `39307:<pubkey>:<d>` coordinate is not, so it gets an [observeNoteEvent] of its own — that both
|
||||
* reads the event out of LocalCache and puts it on an EventFinder subscription, so a track the
|
||||
* device has never seen is requested from relays and appears when it lands.
|
||||
*
|
||||
* The same track is routinely advertised both ways at once (divine.video publishes the Blossom URL
|
||||
* and its wrapping event side by side), so the two are merged by URL: the player is handed one
|
||||
* configuration per distinct file, never a duplicate pair it would offer twice in its track menu.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberCaptionTracks(
|
||||
videoEvent: VideoEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
): ImmutableList<CaptionTrack> {
|
||||
val refs = remember(videoEvent) { videoEvent.captionTracks() }
|
||||
|
||||
// No early return: the pending list changes size when a recycled feed slot switches videos,
|
||||
// and `key` is what lets Compose add and drop those child subscriptions in place.
|
||||
val resolved =
|
||||
refs.pending.map { pending ->
|
||||
key(pending.address.toValue()) {
|
||||
val note = remember(pending.address) { accountViewModel.getOrCreateAddressableNote(pending.address) }
|
||||
val trackEvent by observeNoteEvent<TextTrackEvent>(note, accountViewModel)
|
||||
trackEvent?.toCaptionTrack(pending.tag)
|
||||
}
|
||||
}
|
||||
|
||||
return remember(refs, resolved) {
|
||||
if (refs.direct.isEmpty() && resolved.all { it == null }) {
|
||||
persistentListOf()
|
||||
} else {
|
||||
mergeCaptionTracks(refs.direct, resolved.filterNotNull()).toImmutableList()
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
|
||||
|
||||
/**
|
||||
* A collaborator's acceptance (or refusal) of a credit, rendered on its own.
|
||||
*
|
||||
* The event carries no content — everything it says is in the video it points at — so the video is
|
||||
* embedded and the event itself is reduced to one line of context above it: the role the
|
||||
* collaborator was given, and whether they took it.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderVideoCollaboration(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? VideoCollaborationEvent ?: return
|
||||
val video = remember(noteEvent) { noteEvent.video() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val headline =
|
||||
if (noteEvent.isAccepted()) {
|
||||
stringRes(R.string.video_collaboration_accepted_on)
|
||||
} else {
|
||||
stringRes(R.string.video_collaboration_declined_on)
|
||||
}
|
||||
val role = remember(noteEvent) { noteEvent.role() }
|
||||
|
||||
Text(
|
||||
text = if (role != null) "$headline · $role" else headline,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// quotesLeft guards the recursion: the embedded video can itself credit other work, and
|
||||
// NoteCompose is what decrements it on the way down.
|
||||
if (video != null && quotesLeft > 0) {
|
||||
LoadAddressableNote(video, accountViewModel) { videoNote ->
|
||||
videoNote?.let {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
|
||||
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.credits.CreditTarget
|
||||
import com.vitorpamplona.quartz.nip71Video.credits.VideoCredit
|
||||
|
||||
/**
|
||||
* The line of credits under a video: who it features, who inspired it, whose audio it reuses.
|
||||
*
|
||||
* Every entry is a link, because the credit is only worth printing if it takes you to the person
|
||||
* or the work. A credited person who has published a [VideoCollaborationEvent] for this video gets
|
||||
* a check beside their name — they are not merely tagged, they took the credit.
|
||||
*
|
||||
* A `p` tag marked `mention` is kept even though the description may name the same person: on
|
||||
* divine.video the description writes a plain `@handle`, which no client can turn into a link, so
|
||||
* the tag is the only thing that resolves to an actual profile. The cost is a chip that repeats a
|
||||
* `nostr:` mention already rendered inline, which is redundancy rather than a wrong credit.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun RenderVideoCredits(
|
||||
videoEvent: VideoEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val credits = remember(videoEvent) { videoEvent.credits() }
|
||||
if (credits.isEmpty()) return
|
||||
|
||||
// Only an addressable video has a coordinate for a collaborator to answer, so only there can
|
||||
// an acceptance be looked up at all.
|
||||
val videoAddress = remember(videoEvent) { (videoEvent as? AddressableEvent)?.address() }
|
||||
|
||||
FlowRow(modifier = HalfTopPadding) {
|
||||
credits.forEach { credit ->
|
||||
key(credit.target) {
|
||||
RenderVideoCredit(credit, videoAddress, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderVideoCredit(
|
||||
credit: VideoCredit,
|
||||
videoAddress: Address?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
when (val target = credit.target) {
|
||||
is CreditTarget.Person ->
|
||||
LoadUser(target.pubKey, accountViewModel) { user ->
|
||||
// Until the profile arrives there is no name to print, and a hex key in the
|
||||
// credits reads as noise rather than as a person.
|
||||
user?.let {
|
||||
CreditRow(
|
||||
label = personLabel(credit.label),
|
||||
// toBestDisplayName() is read inside the row so a profile that lands later
|
||||
// still renames the credit.
|
||||
name = "@" + it.toBestDisplayName(),
|
||||
confirmed = {
|
||||
// Only a role-marked credit is an invite someone can accept, and each
|
||||
// lookup registers a cache observer per credit per rendered card.
|
||||
// Watching every `mention` and `inspired-by` too would spend one per
|
||||
// feed item on an event that cannot exist.
|
||||
if (credit.label.isCollaborationRole()) {
|
||||
AcceptedCollaboration(target.pubKey, videoAddress, accountViewModel)
|
||||
}
|
||||
},
|
||||
onClick = { nav.nav(Route.Profile(target.pubKey)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is CreditTarget.Video ->
|
||||
CreditRow(
|
||||
label = workLabel(credit.label),
|
||||
name = target.address.dTag.take(SHORT_REF_LENGTH),
|
||||
onClick = {
|
||||
nav.nav(Route.Note(target.address.toTag()))
|
||||
},
|
||||
)
|
||||
|
||||
is CreditTarget.Event ->
|
||||
CreditRow(
|
||||
label = workLabel(credit.label),
|
||||
name = target.eventId.take(SHORT_REF_LENGTH),
|
||||
onClick = { nav.nav(Route.Note(target.eventId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a check when [pubKey] has published an accepted [VideoCollaborationEvent] for this video.
|
||||
*
|
||||
* The response is matched by what both shapes in the wild agree on — the `a` tag naming the video
|
||||
* — rather than by the response's own coordinate. divine-mobile keys the event by the video
|
||||
* coordinate (so `34238:<collaborator>:<video coordinate>` would resolve it) but divine-web writes
|
||||
* a random `d`, and asking for a coordinate nobody can predict found nothing for the client that
|
||||
* publishes most of these. The relay side is covered by the `a` engagement filter every rendered
|
||||
* addressable note already opens (see PostsAndChatMessagesToAddresses), so this reads the cache
|
||||
* and costs no subscription of its own.
|
||||
*
|
||||
* Newest-first, so a collaborator who accepted and later withdrew reads as withdrawn.
|
||||
*/
|
||||
@Composable
|
||||
private fun AcceptedCollaboration(
|
||||
pubKey: String,
|
||||
videoAddress: Address?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (videoAddress == null) return
|
||||
|
||||
val responseFlow =
|
||||
remember(pubKey, videoAddress) {
|
||||
accountViewModel.account.cache.observeLatestEvent<VideoCollaborationEvent>(
|
||||
Filter(
|
||||
kinds = listOf(VideoCollaborationEvent.KIND),
|
||||
authors = listOf(pubKey),
|
||||
tags = mapOf("a" to listOf(videoAddress.toValue())),
|
||||
limit = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val response by responseFlow.collectAsStateWithLifecycle(null)
|
||||
|
||||
if (response?.isAccepted() == true) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.CheckCircle,
|
||||
contentDescription = stringRes(R.string.video_credit_accepted),
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
tint = MaterialTheme.colorScheme.lessImportantLink,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreditRow(
|
||||
label: String,
|
||||
name: String,
|
||||
confirmed: @Composable () -> Unit = {},
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (label.isNotBlank()) {
|
||||
Text(
|
||||
text = "$label ",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
ClickableTextColor(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
linkColor = MaterialTheme.colorScheme.lessImportantLink,
|
||||
onClick = onClick,
|
||||
)
|
||||
confirmed()
|
||||
}
|
||||
}
|
||||
|
||||
// A person tagged without a stated capacity is still in the video — that is what NIP-71's `p`
|
||||
// means — so the neutral credit, not a blank one, is the honest default.
|
||||
@Composable
|
||||
private fun personLabel(marker: String?): String =
|
||||
when (marker) {
|
||||
INSPIRED_BY_MARKER -> stringRes(R.string.video_credit_inspired_by)
|
||||
MENTION_MARKER, null -> stringRes(R.string.video_credit_featuring)
|
||||
// Anything else is a role the video's author typed ("Collaborator", "Director"): print it.
|
||||
else -> marker
|
||||
}
|
||||
|
||||
// The same treatment for a credited work. Both reference kinds route through here so an `e` tag
|
||||
// and an `a` tag carrying the same marker read identically — the `e` path used to print the raw
|
||||
// protocol token, so a `mention` showed up in the UI as the literal word "mention".
|
||||
@Composable
|
||||
private fun workLabel(marker: String?): String =
|
||||
when (marker) {
|
||||
AUDIO_MARKER -> stringRes(R.string.video_credit_audio_from)
|
||||
MENTION_MARKER, null -> stringRes(R.string.video_credit_references)
|
||||
else -> marker
|
||||
}
|
||||
|
||||
/** True for a marker that names a role rather than describing a reference. */
|
||||
private fun String?.isCollaborationRole(): Boolean = this != null && this != MENTION_MARKER && this != INSPIRED_BY_MARKER && this != AUDIO_MARKER
|
||||
|
||||
private const val INSPIRED_BY_MARKER = "inspired-by"
|
||||
private const val MENTION_MARKER = "mention"
|
||||
private const val AUDIO_MARKER = "audio"
|
||||
|
||||
// Enough of a hex id or `d` tag to tell two references apart without wrapping the row.
|
||||
private const val SHORT_REF_LENGTH = 8
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* A NIP-51 kind-30005 video list: its name, what it is for, and a strip of its videos.
|
||||
*
|
||||
* Members may be public tags or NIP-51 private ones inside the encrypted content — divine.video
|
||||
* puts every member there, so for anyone but the list's owner the strip is legitimately empty and
|
||||
* the card says so rather than looking broken.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderVideoCurationSet(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? VideoCurationSetEvent ?: return
|
||||
|
||||
val publicItems = remember(noteEvent) { noteEvent.publicItems() }
|
||||
|
||||
// Decryption needs the signer and only succeeds for the list's owner, so it runs off the
|
||||
// composition and simply yields nothing for everyone else.
|
||||
//
|
||||
// Null means "we did not read the content": not the owner, or the decrypt failed. An empty
|
||||
// list means we read it and it holds nothing. The two have to stay apart, because a list whose
|
||||
// encrypted content decrypts to `[]` is an empty list, not a private one — divine.video
|
||||
// publishes exactly that for a list a user created and never added to.
|
||||
val privateItems by
|
||||
produceState<List<BookmarkIdTag>?>(initialValue = null, noteEvent) {
|
||||
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
|
||||
value =
|
||||
try {
|
||||
noteEvent.privateItems(accountViewModel.account.signer)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
|
||||
Log.w("RenderVideoCurationSet", "Cannot decrypt private items of ${noteEvent.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val items = remember(publicItems, privateItems) { publicItems + (privateItems ?: emptyList()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
(noteEvent.title() ?: noteEvent.dTag()).takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
noteEvent.description()?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// An empty strip has two very different causes. Encrypted content we could not read means
|
||||
// the members may be there and private; content we did read, or no content at all, means
|
||||
// the list is simply empty. Calling that "Private list" would be a lie — to its own author
|
||||
// most of all, who just decrypted it and found nothing in it.
|
||||
val membersAreHidden = items.isEmpty() && noteEvent.content.isNotBlank() && privateItems == null
|
||||
|
||||
Text(
|
||||
text =
|
||||
if (membersAreHidden) {
|
||||
stringRes(R.string.video_curation_set_private_items)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.video_curation_set_video_count, items.size, items.size)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (items.isNotEmpty() && quotesLeft > 0) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items.take(MAX_POSTERS).forEach { item ->
|
||||
key(item.toTagIdOnly().joinToString(":")) {
|
||||
VideoListMemberPoster(item, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One video in the strip: its poster, or its blurhash until that loads. */
|
||||
@Composable
|
||||
private fun VideoListMemberPoster(
|
||||
item: BookmarkIdTag,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
when (item) {
|
||||
is AddressBookmark ->
|
||||
LoadAddressableNote(item.address, accountViewModel) { note ->
|
||||
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(addressTag(item.address))) } }
|
||||
}
|
||||
|
||||
is EventBookmark ->
|
||||
LoadNote(item.eventId, accountViewModel) { note ->
|
||||
note?.let { VideoPoster(it, accountViewModel) { nav.nav(Route.Note(item.eventId)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoPoster(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val videoEvent = note.event as? VideoEvent ?: return
|
||||
// The poster, not the video: a strip of simultaneously-playing players is unusable, and every
|
||||
// NIP-71 imeta that has a thumbnail carries it as `image`.
|
||||
val imeta = remember(videoEvent) { videoEvent.imetaTags().firstOrNull { it.image.isNotEmpty() } ?: videoEvent.imetaTags().firstOrNull() }
|
||||
val poster = imeta?.image?.firstOrNull()
|
||||
|
||||
val tileModifier = Modifier.width(POSTER_WIDTH).height(POSTER_HEIGHT).clickable(onClick = onClick)
|
||||
|
||||
if (poster == null) {
|
||||
imeta?.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) }
|
||||
return
|
||||
}
|
||||
|
||||
MyAsyncImage(
|
||||
imageUrl = poster,
|
||||
contentDescription = videoEvent.title(),
|
||||
contentScale = ContentScale.Crop,
|
||||
mainImageModifier = tileModifier,
|
||||
loadedImageModifier = MaterialTheme.colorScheme.imageModifier,
|
||||
accountViewModel = accountViewModel,
|
||||
onLoadingBackground = { imeta.blurhash?.let { DisplayBlurHash(it, videoEvent.title(), ContentScale.Crop, tileModifier) } },
|
||||
onError = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun addressTag(address: Address) = Address.assemble(address.kind, address.pubKeyHex, address.dTag)
|
||||
|
||||
// Enough to show what kind of list this is without turning a feed card into a gallery.
|
||||
private const val MAX_POSTERS = 8
|
||||
private val POSTER_WIDTH = 96.dp
|
||||
private val POSTER_HEIGHT = 128.dp
|
||||
@@ -86,9 +86,12 @@ fun VideoDisplay(
|
||||
val image = remember(imeta) { imeta.image.firstOrNull() }
|
||||
val isYouTube = remember(imeta) { imeta.url.contains("youtube.com") || imeta.url.contains("youtu.be") }
|
||||
val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
|
||||
val captions = rememberCaptionTracks(videoEvent, accountViewModel)
|
||||
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
// Keyed on the captions too: an addressable `text-track` resolves after the first frame,
|
||||
// and the player only picks up a track that is in the MediaItemData it was handed.
|
||||
remember(note, captions) {
|
||||
val description = videoEvent.content.ifBlank { null } ?: event.alt()
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
@@ -118,6 +121,7 @@ fun VideoDisplay(
|
||||
mimeType = imeta.mimeType,
|
||||
blurhash = imeta.blurhash,
|
||||
thumbhash = imeta.thumbhash,
|
||||
captions = captions,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -200,6 +204,11 @@ fun VideoDisplay(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outside the summary block on purpose: divine.video shorts routinely credit a
|
||||
// soundtrack or a collaborator while leaving the description empty, and those credits
|
||||
// are the only attribution the reused work gets.
|
||||
RenderVideoCredits(videoEvent, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.SimpleGroupListEvent
|
||||
|
||||
/** NIP-51 kind 10018: the git repositories this user follows. */
|
||||
@Composable
|
||||
fun RenderGitRepositoryList(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? GitRepositoryListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicRepositories() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRepositories(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_git_repositories),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { repo ->
|
||||
key(repo.address.toValue()) {
|
||||
AddressMemberRow(repo.address, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** NIP-51 kind 10090: the DVM content feeds this user keeps to hand. */
|
||||
@Composable
|
||||
fun RenderFavoriteAlgoFeedsList(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? FavoriteAlgoFeedsListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicFavoriteAlgoFeeds() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFavoriteAlgoFeeds(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_favorite_algo_feeds),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { feed ->
|
||||
key(feed.address.toValue()) {
|
||||
AddressMemberRow(feed.address, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** NIP-51 kind 30267: "references to multiple software applications". */
|
||||
@Composable
|
||||
fun RenderAppCurationSet(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? AppCurationSetEvent ?: return
|
||||
|
||||
val apps = remember(noteEvent) { noteEvent.apps() }
|
||||
|
||||
ListCard(
|
||||
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_app_curation_set),
|
||||
description = noteEvent.description(),
|
||||
items = apps,
|
||||
// Not a PrivateTagArrayEvent: an app set has no encrypted half to be locked out of.
|
||||
hasUnreadablePrivateItems = false,
|
||||
backgroundColor = backgroundColor,
|
||||
) { app ->
|
||||
key(app.address.toValue()) {
|
||||
AddressMemberRow(app.address, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-51 kind 10009: the simple groups this user belongs to.
|
||||
*
|
||||
* A group is named by an id on a relay rather than by a coordinate, so it gets its own row: the
|
||||
* group's name when it carries one, and the relay that hosts it underneath.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderSimpleGroupList(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? SimpleGroupListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicGroups() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateGroups(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_simple_groups),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { group ->
|
||||
key(group.groupId + group.relayUrl) {
|
||||
LabelMemberRow(group.name?.takeIf { it.isNotBlank() } ?: group.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.pictureCurationSet.PictureCurationSetEvent
|
||||
|
||||
/** NIP-51 kind 10003: the user's bookmarks — notes, articles, hashtags and links. */
|
||||
@Composable
|
||||
fun RenderBookmarkList(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? BookmarkListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
|
||||
|
||||
BookmarkCard(
|
||||
title = listTitle(noteEvent.title(), null, R.string.kind_bookmark_list),
|
||||
description = null,
|
||||
public = public,
|
||||
private = private,
|
||||
hidesPrivate = noteEvent.hidesPrivateMembers(private),
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-51 kind 30001: the deprecated bookmark set. Still rendered, because the events are on relays
|
||||
* whether or not the spec wants them to be.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
@Composable
|
||||
fun RenderOldBookmarkList(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? OldBookmarkListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
|
||||
|
||||
BookmarkCard(
|
||||
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_bookmark_list),
|
||||
description = null,
|
||||
public = public,
|
||||
private = private,
|
||||
hidesPrivate = noteEvent.hidesPrivateMembers(private),
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/** NIP-51 kind 30003: "user-defined bookmarks categories, for when bookmarks must be in labeled separate groups". */
|
||||
@Composable
|
||||
fun RenderLabeledBookmarkList(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? LabeledBookmarkListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicBookmarks() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateBookmarks(it) }
|
||||
|
||||
BookmarkCard(
|
||||
title = listTitle(noteEvent.titleOrName(), noteEvent.dTag(), R.string.kind_bookmark_set),
|
||||
description = noteEvent.description(),
|
||||
public = public,
|
||||
private = private,
|
||||
hidesPrivate = noteEvent.hidesPrivateMembers(private),
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/** NIP-51 kind 30004: "groups of articles picked by users as interesting". */
|
||||
@Composable
|
||||
fun RenderArticleCurationSet(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? ArticleCurationSetEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicItems() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
|
||||
|
||||
BookmarkCard(
|
||||
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_article_curation_set),
|
||||
description = noteEvent.description(),
|
||||
public = public,
|
||||
private = private,
|
||||
hidesPrivate = noteEvent.hidesPrivateMembers(private),
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/** NIP-51 kind 30006: "groups of pictures picked by users as interesting". */
|
||||
@Composable
|
||||
fun RenderPictureCurationSet(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? PictureCurationSetEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicItems() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateItems(it) }
|
||||
|
||||
BookmarkCard(
|
||||
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_picture_curation_set),
|
||||
description = noteEvent.description(),
|
||||
public = public,
|
||||
private = private,
|
||||
hidesPrivate = noteEvent.hidesPrivateMembers(private),
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The five bookmark-shaped kinds differ only in where their name comes from; what they hold is the
|
||||
* same `e`/`a` reference, so the body they draw is the same one.
|
||||
*/
|
||||
@Composable
|
||||
private fun BookmarkCard(
|
||||
title: String,
|
||||
description: String?,
|
||||
public: List<BookmarkIdTag>,
|
||||
private: List<BookmarkIdTag>?,
|
||||
hidesPrivate: Boolean,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
ListCard(
|
||||
title = title,
|
||||
description = description,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = hidesPrivate,
|
||||
backgroundColor = backgroundColor,
|
||||
) { item ->
|
||||
key(item.toTagIdOnly().joinToString(":")) {
|
||||
BookmarkMemberRow(item, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip51Lists.goodWikiRelayList.GoodWikiRelayListEvent
|
||||
|
||||
/** NIP-51 kind 10102: the relays this user trusts for wiki articles. */
|
||||
@Composable
|
||||
fun RenderGoodWikiRelayList(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? GoodWikiRelayListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicRelays() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateRelays(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_good_wiki_relays),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { relay ->
|
||||
key(relay.url) { LabelMemberRow(relay.displayUrl()) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
|
||||
import com.vitorpamplona.amethyst.ui.note.getGradient
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/** How many members a list card shows before it asks to be expanded. Matches DisplayPeopleList. */
|
||||
private const val COLLAPSED_MEMBERS = 3
|
||||
|
||||
/**
|
||||
* The shared shape of every NIP-51 list card: a name, an optional description, the members, and —
|
||||
* when the viewer is not the list's author — an honest line about the ones they cannot see.
|
||||
*
|
||||
* Each list kind supplies its own title, its own member type and its own row, because a mute list
|
||||
* of pubkeys and a bookmark list of notes are not the same thing and should not pretend to be.
|
||||
* What they share is this frame.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ListCard(
|
||||
title: String,
|
||||
description: String?,
|
||||
items: List<T>,
|
||||
/** True when the event carries private members this viewer has no key for. */
|
||||
hasUnreadablePrivateItems: Boolean,
|
||||
backgroundColor: MutableState<Color>,
|
||||
itemContent: @Composable (T) -> Unit,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val shown = if (expanded) items else items.take(COLLAPSED_MEMBERS)
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
description?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// Only when there is nothing else to show. A card with visible members already reads as a list,
|
||||
// and most lists keep something private, so saying so on every one of them is noise. The line
|
||||
// exists for the case that would otherwise be a title and blank space — an all-private list,
|
||||
// which is what divine.video publishes — so that it reads as closed rather than as broken.
|
||||
if (hasUnreadablePrivateItems && items.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.nip51_list_has_private_members),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
|
||||
shown.forEach { itemContent(it) }
|
||||
}
|
||||
|
||||
if (items.size > COLLAPSED_MEMBERS && !expanded) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(getGradient(backgroundColor)),
|
||||
) {
|
||||
ShowMoreButton { expanded = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a list's private members, and keeps "could not read them" apart from "there are none".
|
||||
*
|
||||
* `null` means we never got the plaintext — the viewer is not the author, or the decrypt failed.
|
||||
* An empty list means we read the content and it holds nothing, which is an empty list and must
|
||||
* not be labelled private: its author can see straight into it.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> loadPrivateItems(
|
||||
noteEvent: Event,
|
||||
accountViewModel: AccountViewModel,
|
||||
load: suspend (NostrSigner) -> List<T>?,
|
||||
): State<List<T>?> =
|
||||
produceState<List<T>?>(initialValue = null, noteEvent) {
|
||||
if (noteEvent.pubKey != accountViewModel.account.signer.pubKey) return@produceState
|
||||
value =
|
||||
try {
|
||||
load(accountViewModel.account.signer)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlin.coroutines.cancellation.CancellationException) throw e
|
||||
Log.w("NIP51ListCard", "Cannot decrypt private members of ${noteEvent.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this event is withholding members from the viewer.
|
||||
*
|
||||
* Both halves matter: an empty `content` is a list with no private members at all, and a non-null
|
||||
* [privateItems] means we already read them, so neither is hidden.
|
||||
*/
|
||||
fun Event.hidesPrivateMembers(privateItems: List<*>?): Boolean = content.isNotEmpty() && privateItems == null
|
||||
|
||||
/** A list's own name if it has one, else the `d` tag it is addressed by, else the kind's name. */
|
||||
@Composable
|
||||
fun listTitle(
|
||||
explicitTitle: String?,
|
||||
dTag: String?,
|
||||
fallback: Int,
|
||||
): String =
|
||||
explicitTitle?.takeIf { it.isNotBlank() }
|
||||
?: dTag?.takeIf { it.isNotBlank() }
|
||||
?: stringRes(fallback)
|
||||
|
||||
/** Remembers the concatenation of a list's public and (once decrypted) private members. */
|
||||
@Composable
|
||||
fun <T> rememberAllMembers(
|
||||
public: List<T>,
|
||||
private: List<T>?,
|
||||
): List<T> = remember(public, private) { if (private == null) public else public + private }
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
|
||||
/**
|
||||
* A member that is a person. Loaded lazily and skipped until the profile arrives, because a bare
|
||||
* hex key in a list of people reads as noise rather than as a member.
|
||||
*/
|
||||
@Composable
|
||||
fun UserMemberRow(
|
||||
pubKey: HexKey,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadUser(pubKey, accountViewModel) { user ->
|
||||
user?.let {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
UserCompose(it, accountViewModel = accountViewModel, nav = nav)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A member that is a note or an addressable event — a bookmark, a curated article, a repository.
|
||||
*
|
||||
* [quotesLeft] guards the recursion: a bookmarked note can itself quote things, and NoteCompose
|
||||
* decrements it on the way down.
|
||||
*/
|
||||
@Composable
|
||||
fun BookmarkMemberRow(
|
||||
item: BookmarkIdTag,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (quotesLeft <= 0) return
|
||||
|
||||
when (item) {
|
||||
is AddressBookmark -> AddressMemberRow(item.address, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
|
||||
is EventBookmark ->
|
||||
LoadNote(item.eventId, accountViewModel) { note ->
|
||||
note?.let {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A member named by coordinate: an article, a repository, an app, a DVM feed. */
|
||||
@Composable
|
||||
fun AddressMemberRow(
|
||||
address: Address,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (quotesLeft <= 0) return
|
||||
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A member that is just a word: a hashtag, a geohash, a muted word.
|
||||
*
|
||||
* [onClick] is optional because only some of these lead anywhere — a hashtag opens its feed, a
|
||||
* muted word opens nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun LabelMemberRow(
|
||||
label: String,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
|
||||
/** A hashtag member, which opens its feed. */
|
||||
@Composable
|
||||
fun HashtagMemberRow(
|
||||
hashtag: String,
|
||||
nav: INav,
|
||||
) = LabelMemberRow("#$hashtag") { nav.nav(Route.Hashtag(hashtag)) }
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.EventTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
|
||||
|
||||
/**
|
||||
* NIP-51 kind 10000: the mute list.
|
||||
*
|
||||
* The only list whose members are of four different kinds at once — people, hashtags, words and
|
||||
* threads — so it is the one kind that cannot borrow another card's body. Each member is drawn as
|
||||
* what it actually is: a profile row for a person, a hashtag that opens its feed, a word as
|
||||
* itself, and a muted thread as the note it silences.
|
||||
*
|
||||
* Most mute lists are mostly private, and for anyone but their author this renders as a title and
|
||||
* an honest line saying so — which is the entire point, given that it used to render as the
|
||||
* NIP-44 ciphertext.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderMuteList(
|
||||
baseNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? MuteListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicMutes() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutes(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_mute_list),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { mute ->
|
||||
when (mute) {
|
||||
is UserTag -> key(mute.pubKey) { UserMemberRow(mute.pubKey, accountViewModel, nav) }
|
||||
is HashtagTag -> key(mute.hashtag) { HashtagMemberRow(mute.hashtag, nav) }
|
||||
is WordTag -> key(mute.word) { LabelMemberRow(mute.word) }
|
||||
is EventTag ->
|
||||
key(mute.eventId) {
|
||||
MutedThreadRow(mute.eventId, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A muted conversation, shown as the note it silences. */
|
||||
@Composable
|
||||
private fun MutedThreadRow(
|
||||
eventId: String,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (quotesLeft <= 0) return
|
||||
|
||||
LoadNote(eventId, accountViewModel) { note ->
|
||||
note?.let {
|
||||
NoteCompose(
|
||||
baseNote = it,
|
||||
isQuotedNote = true,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent
|
||||
|
||||
/** NIP-51 kind 10015: the hashtags this user follows. */
|
||||
@Composable
|
||||
fun RenderHashtagList(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? HashtagListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicHashtags() }
|
||||
// HashtagListEvent exposes no private accessor of its own, but it is a PrivateTagArrayEvent
|
||||
// like the rest, so its private half is read the same way InterestSetEvent reads its own.
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { signer ->
|
||||
noteEvent.privateTags(signer)?.mapNotNull(HashtagTag::parse)
|
||||
}
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_hashtag_follows),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { tag ->
|
||||
key(tag) { HashtagMemberRow(tag, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/** NIP-51 kind 30015: "interest topics represented by a bunch of hashtags". */
|
||||
@Composable
|
||||
fun RenderInterestSet(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? InterestSetEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicHashtags() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateHashtags(it) }
|
||||
|
||||
ListCard(
|
||||
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_interest_set),
|
||||
description = noteEvent.description(),
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { tag ->
|
||||
key(tag) { HashtagMemberRow(tag, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-51 kind 10081: the places this user follows.
|
||||
*
|
||||
* A geohash is not a hashtag — it is a location, and it gets no `#`. Shown verbatim because a
|
||||
* geohash is what the list stores; turning it into a place name would need a geocoder we don't
|
||||
* have on this path.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderGeohashList(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? GeohashListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicGeohashes() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.decryptPrivateGeohashes(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_geohash_follows),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { geohash ->
|
||||
key(geohash) { LabelMemberRow(geohash) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types.lists
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.goodWikiAuthorList.GoodWikiAuthorListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.kindMuteSet.KindMuteSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.mediaFollowList.MediaFollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.mediaStarterPack.MediaStarterPackEvent
|
||||
|
||||
/** NIP-51 kind 10017: the git authors whose patches this user wants to see. */
|
||||
@Composable
|
||||
fun RenderGitAuthorList(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? GitAuthorListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicAuthors() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_git_authors),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { author ->
|
||||
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/** NIP-51 kind 10020: the people whose media this user follows. */
|
||||
@Composable
|
||||
fun RenderMediaFollowList(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? MediaFollowListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicFollows() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateFollows(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_media_follows),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { follow ->
|
||||
key(follow.pubKey) { UserMemberRow(follow.pubKey, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/** NIP-51 kind 10101: the authors whose wiki articles this user trusts. */
|
||||
@Composable
|
||||
fun RenderGoodWikiAuthorList(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? GoodWikiAuthorListEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicAuthors() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateAuthors(it) }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_good_wiki_authors),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { author ->
|
||||
key(author.pubKey) { UserMemberRow(author.pubKey, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-51 kind 30007: people muted for one kind only.
|
||||
*
|
||||
* "`d` tag MUST be the kind string" — so the `d` tag is not a name here, it is the kind being
|
||||
* muted, and printing it as a title the way other sets do would be wrong.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderKindMuteSet(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? KindMuteSetEvent ?: return
|
||||
|
||||
val public = remember(noteEvent) { noteEvent.publicMutedUsers() }
|
||||
val private by loadPrivateItems(noteEvent, accountViewModel) { noteEvent.privateMutedUsers(it) }
|
||||
val mutedKind = remember(noteEvent) { noteEvent.dTag() }
|
||||
|
||||
ListCard(
|
||||
title = stringRes(R.string.kind_mute_set_for_kind, mutedKind),
|
||||
description = null,
|
||||
items = rememberAllMembers(public, private),
|
||||
hasUnreadablePrivateItems = noteEvent.hidesPrivateMembers(private),
|
||||
backgroundColor = backgroundColor,
|
||||
) { muted ->
|
||||
key(muted.pubKey) { UserMemberRow(muted.pubKey, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-51 kind 39092: a media starter pack — "a named set of profiles to be shared around with the
|
||||
* goal of being followed together". Public by construction: a pack exists to be handed out.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderMediaStarterPack(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? MediaStarterPackEvent ?: return
|
||||
|
||||
val members = remember(noteEvent) { noteEvent.followIds() }
|
||||
|
||||
ListCard(
|
||||
title = listTitle(noteEvent.title(), noteEvent.dTag(), R.string.kind_media_starter_pack),
|
||||
description = noteEvent.description(),
|
||||
items = members,
|
||||
hasUnreadablePrivateItems = false,
|
||||
backgroundColor = backgroundColor,
|
||||
) { pubKey ->
|
||||
key(pubKey) { UserMemberRow(pubKey, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+21
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCacheAsync
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
|
||||
import com.vitorpamplona.amethyst.model.UiSettingsFlow
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
@@ -2012,6 +2013,12 @@ class AccountViewModel(
|
||||
|
||||
fun videoPlayerButtonItemsFlow() = account.settings.syncedSettings.videoPlayer.buttonItems
|
||||
|
||||
fun captionsEnabledFlow() = account.settings.syncedSettings.videoPlayer.captionsEnabled
|
||||
|
||||
fun setCaptionsEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { account.changeCaptionsEnabled(enabled) }
|
||||
}
|
||||
|
||||
fun changeVideoPlayerButtonItems(items: List<com.vitorpamplona.amethyst.model.VideoPlayerButtonItem>) =
|
||||
launchSigner {
|
||||
account.changeVideoPlayerButtonItems(items)
|
||||
@@ -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.
|
||||
|
||||
+6
@@ -154,6 +154,12 @@ private fun PreloadFor(
|
||||
|
||||
NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
|
||||
|
||||
// The Marmot list renders from local state alone — marmotGroupList.rooms plus
|
||||
// marmotManager.activeGroupIds() — and MarmotManager already owns a per-group
|
||||
// subscription for every group it restores or joins. There is no list-level REQ
|
||||
// to warm up, and adding one would duplicate those.
|
||||
NavBarItem.MARMOT_GROUPS -> Unit
|
||||
|
||||
NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel)
|
||||
|
||||
+17
@@ -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(
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Counts above this render as "N+" so a very busy room doesn't blow out the row. */
|
||||
const val CHAT_UNREAD_CAP = 99
|
||||
|
||||
/**
|
||||
* The unread pill every chat list shares: Concord channels, Marmot groups.
|
||||
*
|
||||
* Sizing is `sizeIn` + padding rather than a fixed `size`, so "99+" widens the
|
||||
* pill instead of being clipped by it, and the label rides `labelSmall` rather
|
||||
* than a hardcoded sp so it still tracks the reader's font scale.
|
||||
*
|
||||
* [contentDescription] is the caller's, because the plural that reads well out
|
||||
* loud is per-surface ("3 unread messages" vs "3 unread invites"); the pill
|
||||
* itself is the same object everywhere.
|
||||
*/
|
||||
@Composable
|
||||
fun ChatUnreadBadge(
|
||||
count: Int,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (count <= 0) return
|
||||
val label = if (count > CHAT_UNREAD_CAP) "$CHAT_UNREAD_CAP+" else count.toString()
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.semantics { this.contentDescription = contentDescription }
|
||||
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
|
||||
// snapping — a small touch that makes new activity feel noticed.
|
||||
.animateContentSize()
|
||||
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -166,6 +168,11 @@ fun CreateGroupScreen(
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.imePaddingSafe()
|
||||
// The form is taller than the window once the IME is up, so
|
||||
// `imePaddingSafe` alone just clips the retention picker and
|
||||
// the footer off the bottom. Scrolling is what makes them
|
||||
// reachable while the keyboard covers half the screen.
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
@@ -219,7 +226,7 @@ fun CreateGroupScreen(
|
||||
|
||||
Text(
|
||||
stringRes(Res.string.marmot_create_group_footer),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 16.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
+8
@@ -27,6 +27,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -157,6 +159,11 @@ fun EditGroupInfoScreen(
|
||||
.padding(padding)
|
||||
.consumeWindowInsets(padding)
|
||||
.imePaddingSafe()
|
||||
// Same reason as CreateGroupScreen: name + description +
|
||||
// avatar-url + footer is taller than what is left once the
|
||||
// IME is up, so the padding alone would clip the lower
|
||||
// fields with no way to reach them.
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -241,6 +248,7 @@ fun EditGroupInfoScreen(
|
||||
text = stringRes(Res.string.marmot_edit_info_footer),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+113
-45
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -29,10 +28,10 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -52,13 +51,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
@@ -66,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_create_group
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_empty_create_action
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_group_fallback_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_groups_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups
|
||||
@@ -73,17 +71,29 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_no_groups_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_invitations_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_no_messages_yet
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_group_updated
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_media
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_no_text
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_preview_with_sender
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_known_count
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_tab_new_requests_count
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.DisplayUserSetAsSubject
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size55dp
|
||||
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -142,6 +152,11 @@ fun MarmotGroupListScreen(
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
// Above the tabs, because the state it reports is the reason both
|
||||
// tabs can be empty: invites landing on another install look
|
||||
// exactly like no invites at all.
|
||||
MarmotInviteDeviceBanner(accountViewModel)
|
||||
|
||||
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||
Tab(
|
||||
selected = selectedTab == 0,
|
||||
@@ -204,13 +219,25 @@ fun MarmotGroupListScreen(
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
// Only the Known tab gets a button. There is no action
|
||||
// that fills the New Requests tab — you cannot make
|
||||
// someone invite you — so offering one there would be a
|
||||
// dead end dressed up as a next step.
|
||||
if (selectedTab == 0) {
|
||||
Button(
|
||||
onClick = { nav.nav(Route.CreateMarmotGroup) },
|
||||
modifier = Modifier.padding(top = 20.dp),
|
||||
) {
|
||||
Text(stringRes(Res.string.marmot_empty_create_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(visibleGroups, key = { it.first }) { (groupId, chatroom) ->
|
||||
itemsIndexed(visibleGroups, key = { _, it -> it.first }) { index, (groupId, chatroom) ->
|
||||
MarmotGroupListItem(
|
||||
groupId = groupId,
|
||||
chatroom = chatroom,
|
||||
@@ -219,7 +246,11 @@ fun MarmotGroupListScreen(
|
||||
nav.nav(Route.MarmotGroupChat(groupId))
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
// Between rows only: a divider under the last one draws a
|
||||
// line across empty space with nothing beneath it.
|
||||
if (index < visibleGroups.lastIndex) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +295,54 @@ fun MarmotGroupListItem(
|
||||
// to ~100 entries, so counting per recomposition is cheap.
|
||||
val unread = chatroom.messages.count { (it.createdAt() ?: Long.MIN_VALUE) > lastReadTime }
|
||||
|
||||
// A preview has to survive the messages that carry no text: a system row
|
||||
// keeps its state in tags and MIP-04 media keeps its in imeta, so both used
|
||||
// to render as a blank second line under the group name.
|
||||
val myPubKey = accountViewModel.account.signer.pubKey
|
||||
val previewEvent = newestMessage?.event
|
||||
val previewBody =
|
||||
when {
|
||||
newestMessage == null -> stringRes(Res.string.marmot_no_messages_yet)
|
||||
previewEvent == null -> stringRes(Res.string.marmot_preview_no_text)
|
||||
previewEvent.kind == MarmotAppEvent.KIND_SYSTEM -> stringRes(Res.string.marmot_preview_group_updated)
|
||||
// Text first: an attachment usually carries a caption, and showing
|
||||
// "Attachment" over the words the sender actually wrote would be a
|
||||
// step back from the raw `content` this replaced.
|
||||
previewEvent.content.isNotBlank() -> previewEvent.content
|
||||
hasMip04Media(previewEvent) || hasEncryptedMediaV2(previewEvent) -> stringRes(Res.string.marmot_preview_media)
|
||||
else -> stringRes(Res.string.marmot_preview_no_text)
|
||||
}
|
||||
// Only a genuinely known name earns the prefix: `bestName` returns null
|
||||
// without metadata, and "a1b2c3d4: hi" is noise, not attribution.
|
||||
//
|
||||
// Read through `observeUserInfo`, not `metadataOrNull()`: the latter is a
|
||||
// plain StateFlow.value read, so a kind:0 arriving after the row composed
|
||||
// would never reach it.
|
||||
//
|
||||
// Deliberately `getUserIfExists` rather than the `LoadUser` idiom: this only
|
||||
// subscribes for a sender the cache already knows, and a sender it does not
|
||||
// simply goes unprefixed. Creating a User per unknown sender would put a
|
||||
// metadata REQ behind every row of a list that is mostly strangers' names
|
||||
// the reader never asked for — a preview line is not worth that.
|
||||
val senderUser =
|
||||
previewEvent
|
||||
?.pubKey
|
||||
?.takeIf { it != myPubKey }
|
||||
?.let { accountViewModel.getUserIfExists(it) }
|
||||
val senderName =
|
||||
if (senderUser != null) {
|
||||
observeUserInfo(senderUser, accountViewModel).value?.info?.bestName()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val previewText =
|
||||
// A system caption already names its actor, so prefixing one would say it twice.
|
||||
if (senderName != null && previewEvent?.kind != MarmotAppEvent.KIND_SYSTEM) {
|
||||
stringRes(Res.string.marmot_preview_with_sender, senderName, previewBody)
|
||||
} else {
|
||||
previewBody
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -276,7 +355,7 @@ fun MarmotGroupListItem(
|
||||
if (memberPubkeys.isNotEmpty()) {
|
||||
NonClickableUserPictures(
|
||||
userHexList = memberPubkeys,
|
||||
size = 55.dp,
|
||||
size = Size55dp,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
@@ -304,47 +383,36 @@ fun MarmotGroupListItem(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (newestMessage != null) {
|
||||
Text(
|
||||
text = newestMessage.event?.content ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
Text(
|
||||
text = previewText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
// The list is sorted by newestMessage.createdAt(), so showing that time
|
||||
// is what makes the ordering legible; the message count it replaced was
|
||||
// a number no reader was asking for.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
newestMessage?.createdAt()?.let { createdAt ->
|
||||
ToggleableTimeAgoText(
|
||||
timestamp = createdAt,
|
||||
style = TimeAgoStyle.Short,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(Res.string.marmot_no_messages_yet),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
// The row is the tap target. A toggleable timestamp would
|
||||
// swallow taps meant to open the group.
|
||||
toggleable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
if (unread > 0) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = if (unread > 99) "99+" else unread.toString(),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.marmot_message_count, chatroom.messages.size, chatroom.messages.size),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
ChatUnreadBadge(
|
||||
count = unread,
|
||||
contentDescription = pluralStringResource(R.plurals.marmot_unread_messages, unread, unread),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_banner
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_banner_dismiss
|
||||
import com.vitorpamplona.amethyst.commons.resources.marmot_invite_device_publish
|
||||
import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* How long a passive owner check may reuse its previous answer. What it reports
|
||||
* only changes when another device publishes, which no user does often.
|
||||
*/
|
||||
private const val OWNER_CHECK_MAX_AGE_SECONDS = 15L * 60L
|
||||
|
||||
/**
|
||||
* Warns, on the Marmot groups screen, that another install of this account is
|
||||
* the one receiving its invites.
|
||||
*
|
||||
* This is the symptom's own screen. A user whose invites are landing on a
|
||||
* second device sees an empty group list and no new requests — exactly what
|
||||
* "nobody has invited me" looks like — so without a banner here the failure is
|
||||
* indistinguishable from nothing happening, and the diagnosis sits behind a
|
||||
* settings row nobody has a reason to open.
|
||||
*
|
||||
* Publishing stays a tap, never automatic: an inviter takes the newest
|
||||
* KeyPackage, so a banner that republished itself on sight would deadlock the
|
||||
* two installs against each other, each device's correction being the other's
|
||||
* trigger.
|
||||
*/
|
||||
@Composable
|
||||
fun MarmotInviteDeviceBanner(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var owner by remember { mutableStateOf<LatestKeyPackageOwner?>(null) }
|
||||
// Saveable: a rotation is not a decision to un-dismiss a warning the user
|
||||
// has already read and waved away.
|
||||
var dismissed by rememberSaveable { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
// A read-only login cannot publish at all, so the only action this banner
|
||||
// offers is impossible for it. Better to say nothing than to offer a button
|
||||
// that silently does nothing.
|
||||
val canPublish = accountViewModel.canPublish()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!canPublish) return@LaunchedEffect
|
||||
try {
|
||||
owner =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Passive check: reuse a recent answer rather than fanning a
|
||||
// REQ across the whole write set on every entry to this screen.
|
||||
accountViewModel.latestKeyPackageOwner(maxAgeSeconds = OWNER_CHECK_MAX_AGE_SECONDS)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// A relay we cannot reach is not evidence of anything, so it stays
|
||||
// silent rather than accusing a device that may well be this one.
|
||||
owner = null
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = owner == LatestKeyPackageOwner.OTHER_DEVICE && !dismissed) {
|
||||
// Message on its own line, actions under it. All three in one Row fit
|
||||
// only on a wide screen with default font scale; at phone width with
|
||||
// large fonts the label and the button fought for the same space.
|
||||
//
|
||||
// `surfaceVariant`, not `errorContainer`: this is an explanation, and
|
||||
// most users will never see it. The error palette would make it the
|
||||
// loudest thing on a screen whose actual subject is the group list.
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(start = 10.dp, end = 4.dp, top = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringRes(Res.string.marmot_invite_device_banner),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 8.dp, end = 4.dp).weight(1f),
|
||||
)
|
||||
IconButton(onClick = { dismissed = true }, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Close,
|
||||
contentDescription = stringRes(Res.string.marmot_invite_device_banner_dismiss),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// Hidden optimistically, but only *stays* hidden once a
|
||||
// relay has accepted: `republishKeyPackage` waits for the
|
||||
// OK, so a rejection, a read-only account or an empty relay
|
||||
// set come back false instead of being reported as the
|
||||
// success they are not.
|
||||
owner = LatestKeyPackageOwner.THIS_DEVICE
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val successMessage = stringRes(context, R.string.marmot_invite_device_success)
|
||||
val rejectedMessage = stringRes(context, R.string.marmot_invite_device_rejected)
|
||||
try {
|
||||
val accepted = accountViewModel.republishKeyPackage()
|
||||
launch(Dispatchers.Main) {
|
||||
if (accepted) {
|
||||
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
Toast.makeText(context, rejectedMessage, Toast.LENGTH_LONG).show()
|
||||
owner = LatestKeyPackageOwner.OTHER_DEVICE
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Leaving the screen mid-publish is not a failure to
|
||||
// report, and swallowing it here would break the
|
||||
// scope's cancellation as well as pop a toast for a
|
||||
// banner that is already gone.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
val failureMessage =
|
||||
stringRes(context, R.string.marmot_invite_device_failure, e.message ?: "")
|
||||
launch(Dispatchers.Main) {
|
||||
Toast.makeText(context, failureMessage, Toast.LENGTH_LONG).show()
|
||||
// The warning was right after all, so put it back.
|
||||
owner = LatestKeyPackageOwner.OTHER_DEVICE
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(Res.string.marmot_invite_device_publish),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-40
@@ -20,32 +20,21 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
/** Counts above this render as "N+" so a very busy channel doesn't blow out the row. */
|
||||
private const val CONCORD_UNREAD_CAP = 99
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.CHAT_UNREAD_CAP
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.ChatUnreadBadge
|
||||
|
||||
/**
|
||||
* A small pill showing an unread-message [count] (new messages since this account last read).
|
||||
* Renders nothing when [count] is zero, so callers can place it unconditionally. Capped at
|
||||
* [CONCORD_UNREAD_CAP]+ and carries a plural content description for screen readers.
|
||||
* [CHAT_UNREAD_CAP]+ and carries a plural content description for screen readers.
|
||||
*
|
||||
* The pill itself is [ChatUnreadBadge], shared with the other chat lists; this
|
||||
* wrapper only supplies Concord's own plural so the two surfaces cannot drift
|
||||
* apart visually.
|
||||
*/
|
||||
@Composable
|
||||
fun ConcordUnreadBadge(
|
||||
@@ -53,26 +42,9 @@ fun ConcordUnreadBadge(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (count <= 0) return
|
||||
val label = if (count > CONCORD_UNREAD_CAP) "$CONCORD_UNREAD_CAP+" else count.toString()
|
||||
val description = pluralStringResource(R.plurals.concord_unread_messages, count, count)
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.semantics { contentDescription = description }
|
||||
// Smoothly grows/shrinks as the count changes digits (1 → 2 → … → 99+) instead of
|
||||
// snapping — a small touch that makes new activity feel noticed.
|
||||
.animateContentSize()
|
||||
.sizeIn(minWidth = 20.dp, minHeight = 20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
ChatUnreadBadge(
|
||||
count = count,
|
||||
contentDescription = pluralStringResource(R.plurals.concord_unread_messages, count, count),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
+5
-1
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.AddressableVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
|
||||
class UserProfileGalleryFeedFilter(
|
||||
@@ -53,7 +54,10 @@ class UserProfileGalleryFeedFilter(
|
||||
val addressableNotes =
|
||||
LocalCache.addressables
|
||||
.filter(
|
||||
listOf(VideoVerticalEvent.KIND, VideoVerticalEvent.KIND),
|
||||
// Both NIP-71 addressable kinds: acceptableEvent() takes any
|
||||
// AddressableVideoEvent, so listing 34236 twice silently kept every
|
||||
// 34235 (horizontal) video out of the gallery.
|
||||
listOf(VideoVerticalEvent.KIND, VideoHorizontalEvent.KIND),
|
||||
user.pubkeyHex,
|
||||
) { _, it ->
|
||||
acceptableEvent(it, params, user)
|
||||
|
||||
+72
-18
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -46,6 +46,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.experimental.videoCollaboration.VideoCollaborationEvent
|
||||
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
|
||||
import com.vitorpamplona.quartz.kinds.KindNames
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
@@ -106,6 +107,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.videoCurationSet.VideoCurationSetEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
|
||||
@@ -144,6 +146,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoShortEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent
|
||||
import com.vitorpamplona.quartz.nip71Video.textTrack.TextTrackEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
|
||||
@@ -325,6 +328,9 @@ fun kindDisplayName(kind: Int): Int =
|
||||
TorrentCommentEvent.KIND -> R.string.kind_torrent_comments
|
||||
TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays
|
||||
TrustProviderListEvent.KIND -> R.string.kind_trusted_providers
|
||||
VideoCurationSetEvent.KIND -> R.string.kind_video_list
|
||||
VideoCollaborationEvent.KIND -> R.string.kind_video_collaboration
|
||||
TextTrackEvent.KIND -> R.string.kind_video_subtitles
|
||||
VideoHorizontalEvent.KIND -> R.string.kind_video_repl
|
||||
VideoVerticalEvent.KIND -> R.string.kind_shorts_repl
|
||||
VideoNormalEvent.KIND -> R.string.kind_video
|
||||
|
||||
+4
@@ -63,6 +63,8 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions
|
||||
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_captions_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast
|
||||
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_cast_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_download
|
||||
@@ -359,6 +361,7 @@ fun videoPlayerActionName(action: VideoPlayerAction): String =
|
||||
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download)
|
||||
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip)
|
||||
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast)
|
||||
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -371,4 +374,5 @@ fun videoPlayerActionDescription(action: VideoPlayerAction): String =
|
||||
VideoPlayerAction.Download -> stringRes(Res.string.video_player_settings_action_download_description)
|
||||
VideoPlayerAction.PictureInPicture -> stringRes(Res.string.video_player_settings_action_pip_description)
|
||||
VideoPlayerAction.Cast -> stringRes(Res.string.video_player_settings_action_cast_description)
|
||||
VideoPlayerAction.Captions -> stringRes(Res.string.video_player_settings_action_captions_description)
|
||||
}
|
||||
|
||||
@@ -1471,4 +1471,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1350,4 +1350,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2611,4 +2611,5 @@
|
||||
<string name="health_connect_rationale_activity_label">Health Connect a Amethyst</string>
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2393,4 +2393,5 @@
|
||||
<string name="health_connect_rationale_activity_label">Health Connect und Amethyst</string>
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1306,4 +1306,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -108,4 +108,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1341,4 +1341,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1363,4 +1363,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1356,4 +1356,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1356,4 +1356,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1352,4 +1352,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1331,4 +1331,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1283,4 +1283,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1425,4 +1425,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2396,4 +2396,5 @@
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<string name="my_fitness">मेरा स्वास्थ्य</string>
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2396,4 +2396,5 @@
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<string name="my_fitness">Fitnesz</string>
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1274,4 +1274,5 @@ Seharusnya %3$s</string>
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1315,4 +1315,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1308,4 +1308,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1304,4 +1304,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1373,4 +1373,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2299,4 +2299,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2614,4 +2614,5 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<string name="my_fitness">Moja kondycja</string>
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2391,4 +2391,5 @@
|
||||
<string name="health_connect_rationale_activity_label">Health Connect e Amethyst</string>
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1318,4 +1318,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<string name="error_parsing_error_message">Ошибка при анализе сообщения об ошибке</string>
|
||||
<string name="profile">Профиль</string>
|
||||
<string name="security_filters">Фильтры безопасности</string>
|
||||
<string name="show_less">Скрыть</string>
|
||||
<string name="clink_requesting_invoice">Запрос счёта…</string>
|
||||
<string name="clink_debit_no_response">Сервис списания не завершил платёж.</string>
|
||||
<string name="clink_confirm_pay_amount_via_source">Оплатить %1$s через %2$s?</string>
|
||||
@@ -117,6 +118,10 @@
|
||||
<string name="password_is_required">Требуется пароль</string>
|
||||
<string name="key_is_required">Нужно ввести ключ</string>
|
||||
<string name="name_is_required">Требуется имя</string>
|
||||
<string name="concord_invite_failed">Не удалось получить приглашение. Возможно, ссылка устарела или недоступна.</string>
|
||||
<string name="concord_invite_links_title">Приглашения</string>
|
||||
<string name="concord_invite_revoked_ok">Ссылка для приглашения отозвана.</string>
|
||||
<string name="concord_channels_empty">Каналов еще нет.</string>
|
||||
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
|
||||
<!-- Title of the minichat (thread) screen opened from a chat message. -->
|
||||
<!-- Title of the picked-image confirmation dialog when attaching a picture to a thread reply. -->
|
||||
@@ -1391,4 +1396,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1386,4 +1386,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2535,4 +2535,5 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1361,4 +1361,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -2391,4 +2391,5 @@
|
||||
<string name="health_connect_rationale_activity_label">Health Connect och Amethyst</string>
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1339,4 +1339,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1325,4 +1325,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1177,4 +1177,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1322,4 +1322,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1386,4 +1386,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1341,4 +1341,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
@@ -1292,4 +1292,5 @@
|
||||
because android:label on the manifest entry can only reference @string/. -->
|
||||
<!-- My Fitness: the signed-in user's own training summary. Android resource because
|
||||
NavBarItemDef labels are R.string ids. -->
|
||||
<!-- NIP-71 video credits: what a video says about the people and works it references. -->
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user