Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek

This commit is contained in:
Claude
2026-07-15 20:38:14 +00:00
115 changed files with 6344 additions and 1586 deletions
@@ -0,0 +1,124 @@
# Chat feed scroll performance — fast fling through thousands of messages
Status: plan. Owner: chat feed (`amethyst/.../ui/screen/loggedIn/chats/feed/`).
## Problem
A fling through a long chat history composes hundreds of `ChatroomMessageCompose`
rows per second. Each newly composed row currently pays for work that is either
(a) derivable off the composition path, (b) only needed for *recent* messages, or
(c) side-effectful (coroutines, relay-filter updates) and therefore multiplies
into churn under velocity. The redesign added per-row observers (group position,
reaction chips, delivery ticks) that are individually cheap but sum up at 1000+
rows.
## What is already fine (verified, don't re-litigate)
- **Rich text parse is cached**: `TranslatableRichTextViewer` →
`CachedRichTextParser.parseText` behind `remember(content, tags)`; re-scrolling
past a message doesn't re-parse.
- **Engagement flows are sampled**: `observeNoteReactions/Zaps/Replies` sample at
200–500ms, and the EventFinder assembler folds all subscribed note ids into a
small number of shared REQs (`SingleSubEoseManager`), not one REQ per note.
- **LazyColumn hygiene**: stable `key = idHex`, `contentType = kind`,
`animateItem()` gated by performance mode.
- **Tracker fan-out fixed**: delivery state is one small StateFlow per message.
## Per-row cost inventory (composition of ONE new row during fling)
| # | Cost | Where | Class |
|---|------|-------|-------|
| 1 | 4× `dateFormatter` (ThreadLocal `SimpleDateFormat` for >24h-old messages) for group break checks, + 2 more in `NewDateOrSubjectDivisor` | `ChatGroupPosition.groupsWith`, `NewDateOrSubjectDivisor` | CPU, per row |
| 2 | 3× `collectAsStateWithLifecycle` on metadata flows | `watchChatGroupPosition` | collector churn |
| 3 | `LaunchedEffect` → `loadAndMarkAsRead(route, createdAt)` coroutine | `NormalChatNote` | 1 coroutine/row; read-marker lock churn |
| 4 | `LaunchedEffect` → `accountViewModel.decrypt(note)` for the jumbo flag | `NormalChatNote` | 1 coroutine/row, even for plaintext kinds |
| 5 | Reaction observer + zap observer subscriptions (ids added to shared relay filters; filter update per batch) | `ChatReactionChips`, `ObserveZapAmountText` | relay REQ re-issue churn under velocity |
| 6 | 2× flow collectors for delivery ticks on own messages, forever, even for long-settled history | `ChatDeliveryTicks` | collector churn |
| 7 | Per-row animation/gesture state: `animateColorAsState`, press `InteractionSource`, swipe `pointerInput`, highlight `LaunchedEffect` | `ChatBubbleLayout` | allocation, mostly unavoidable |
| 8 | Media/link previews kick Coil requests immediately | `TranslatableRichTextViewer` children | IO churn during fling |
Feed-level:
| # | Cost | Where |
|---|------|-------|
| 9 | `shouldHighlight = highlightedNoteId.value == item.idHex` reads one state in every item lambda → all visible items recompose when it changes (rare; low) | `ChatFeedLoaded` |
| 10 | `onScrollToNote` recreated when `ChatFeedLoaded` recomposes → unstable param defeats item skipping | `ChatFeedLoaded` |
| 11 | Feed invalidation rebuilds the sorted list; every reaction arriving during scroll can reorder/emit | `FeedContentState` (existing infra, sampled) |
## Plan
### Phase 0 — Measure first (do not skip)
1. **Macrobenchmark**: add a `benchmark` scenario that seeds `LocalCache` with
5–10k synthetic `ChannelMessageEvent`s (mixed: text, links, emoji-only, a few
with reactions/zaps) and flings the public-chat screen. Metrics:
`frameDurationCpuMs` P50/P90/P99, jank %, `frameOverrunMs`.
2. **Composition tracing / recomposition counts** on a seeded room; compose
compiler reports (`composables.txt`) for `ChatroomMessageCompose`,
`NormalChatNote`, `InnerChatBubble`, `ChatReactionChips` — verify skippability
and find unstable params (expect #10).
3. Record baseline numbers in this file before changing anything.
### Phase 1 — Kill per-row CPU and coroutines (expected biggest wins)
4. **Day-stamp grouping (#1)**: replace `dateFormatter` equality in `groupsWith`
with an epoch-local-day integer comparison (`(createdAt + zoneOffsetSeconds) / 86400`),
and give `NewDateOrSubjectDivisor` the same predicate so the break condition
stays mirrored (only its *display* string needs formatting, and only when a
divider actually renders). Zero `SimpleDateFormat` on the scroll path.
5. **Hoist read-marking (#3)**: replace the per-row `LaunchedEffect` with one
feed-level `snapshotFlow { listState.firstVisibleItem createdAt }`-driven
marker update (only the newest visible timestamp matters; the marker is
monotonic). One coroutine per scroll session instead of one per row.
6. **Jumbo without a coroutine (#4)**: only launch the decrypt effect for
encrypted kinds (`PrivateDmEvent`, sealed rumors not yet in the decrypt
cache). Plaintext kinds (public chats, NIP-17 rumors already unwrapped —
the vast majority) take the synchronous path only.
7. **Retire settled delivery ticks (#6)**: once a message is fully accepted (or
untracked and older than the tracker window), render the tick from a
`remember`ed terminal value and drop both collectors. Only in-flight sends
keep live flows.
8. **Group position with fewer collectors (#2)**: collect the three metadata
flows only while any of the three notes is missing `event`/`author`
(the common case for history is all-loaded → pure `remember`, no collectors).
### Phase 2 — Tame side-effect churn under velocity
9. **Defer new relay-filter membership while flinging (#5)**: gate
`EventFinderFilterAssemblerSubscription` enrollment on
`!listState.isScrollInProgress` (or debounce enrollment ~300ms): rows that
fly past never join the reaction/zap filters; rows you settle on subscribe
as today. Needs a small `LocalScrollSettled` composition local (provided by
`ChatFeedLoaded`) so `ChatReactionChips`/`ChatDeliveryTicks` can wait without
threading params. Verify with #1 measurements that filter re-issues drop.
10. **Defer media loads (#8)**: same settled-gate for the image/video preview
composables inside chat bubbles (placeholder immediately, Coil request on
settle). Coil cancels in-flight requests on dispose already, but not
starting them is cheaper than cancel.
11. **Stabilize item lambdas (#10)**: `remember(items.list, listState)` around
`onScrollToNote`; pass `shouldHighlight` via `derivedStateOf` keyed per item
(#9) so only the highlighted row recomposes.
### Phase 3 — Structural (only if Phase 1/2 measurements demand more)
12. **Precomputed row model**: build a lightweight `ChatFeedRow(note, groupPos,
dayStamp, isJumbo, isSystem)` list inside `FeedContentState` (background
thread, once per feed update) so item composition becomes pure rendering.
This subsumes #4/#8 above but is a bigger refactor of shared feed infra —
justify with numbers first.
13. **Finer contentType**: distinguish `bubble / jumbo / system / zap-card`
contentTypes so Lazy slot reuse doesn't rebuild structurally different rows.
14. **Prefetch tuning**: evaluate `LazyListPrefetchStrategy(nestedPrefetchItemCount)`
for the fling case on Compose ≥1.7.
### Phase 4 — Regression guardrails
15. Wire the Phase-0 macrobenchmark into CI (or at least a documented manual
run before releases touching the chat feed), and re-record numbers here.
## Non-goals
- Virtualizing bubble internals (Compose already skips off-screen work).
- Caching parsed rich text more aggressively (already LRU-cached).
- Changing feed sorting/invalidation infra (shared with all feeds; separate plan
if measurements point there).
@@ -141,6 +141,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
@@ -157,6 +158,8 @@ import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -491,6 +494,7 @@ class Account(
communityId: String,
channelIdHex: String,
rumor: Event,
seenOnRelays: Set<NormalizedRelayUrl>,
) {
val authority =
concordSessions
@@ -500,7 +504,7 @@ class Account(
?.authority
if (authority?.isBanned(rumor.pubKey) == true) return
registerConcordEncryptedImages(rumor)
cache.consumeConcordRumor(communityId, channelIdHex, rumor)
cache.consumeConcordRumor(communityId, channelIdHex, rumor, seenOnRelays)
}
/**
@@ -678,6 +682,10 @@ class Account(
val newNotesPreProcessor = EventProcessor(this, cache)
// Per-message publish acceptance (relay OKs), feeding the delivery ticks on
// own chat bubbles.
val chatDeliveryTracker = ChatDeliveryTracker(client)
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
val marmotManager: MarmotManager? = mlsGroupStateStore?.let { MarmotManager(signer, it, marmotMessageStore, marmotKeyPackageStore) }
@@ -1075,6 +1083,10 @@ class Account(
expirationDelta: Long?,
difficulty: Int,
existingRecord: PersistedPoWJob? = null,
// The inner rumor's id (the note the chat feed displays), so the mined
// wraps still register with the delivery-ticks tracker at publish time.
// Null for restart-restored jobs, whose rumor id wasn't persisted.
displayedNoteId: HexKey? = null,
): Boolean {
val queue = powQueue() ?: return false
if (seals.isEmpty()) return true
@@ -1109,7 +1121,7 @@ class Account(
}
seals.map { NIP17Factory().wrapSeal(it, expirationDelta, templateConversion = mineWrap) }
},
publish = { wraps -> broadcastPrivately(wraps) },
publish = { wraps -> broadcastPrivately(wraps, displayedNoteId) },
)
return true
}
@@ -2090,6 +2102,7 @@ class Account(
else ->
ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags)
}
trackConcordDelivery(entry, channelKey, wrap)
publishConcordWrap(entry, wrap)
return true
}
@@ -2114,6 +2127,7 @@ class Account(
// Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message.
val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray()
val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags)
trackConcordDelivery(entry, channelKey, wrap)
publishConcordWrap(entry, wrap)
return true
}
@@ -2224,6 +2238,23 @@ class Account(
if (relays.isNotEmpty()) client.publish(wrap, relays)
}
/**
* Registers an own Concord channel message with the delivery tracker so its chat
* bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed
* shows the inner rumor, so we re-open the wrap (we just built it, so this always
* succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions
* and typing wraps skip this — they never become a feed row.
*/
private fun trackConcordDelivery(
entry: ConcordCommunityListEntry,
channelKey: GroupKey,
wrap: Event,
) {
val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays)
}
// ── Concord roles & moderation (CORD-04) ─────────────────────────────────
// Each publishes a Control Plane edition; authority is enforced at fold time by
// every client's AuthorityResolver, so a call by someone who doesn't outrank the
@@ -3315,11 +3346,14 @@ class Account(
val event = signer.sign(template)
cache.justConsumeMyOwnEvent(event)
val relays = relayList(event)
if (!relays.isNullOrEmpty()) {
client.publish(event, relays.toSet())
} else {
client.publish(event, computeRelayListToBroadcast(event))
}
val targets =
if (!relays.isNullOrEmpty()) {
relays.toSet()
} else {
computeRelayListToBroadcast(event)
}
chatDeliveryTracker.trackPublic(event.id, targets)
client.publish(event, targets)
return event
}
@@ -3720,7 +3754,7 @@ class Account(
// process death mid-mine cannot lose the file announcement.
val senderMessage = signer.sign(template)
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) {
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id)) {
// The wraps publish only after mining, but the user has already
// replied — advance the read marker now.
markDmRoomAsRead(senderMessage)
@@ -3737,7 +3771,7 @@ class Account(
// See sendNip17EncryptedFile: sign inline, queue only wrap mining.
val senderMessage = signer.sign(template)
val seals = NIP17Factory().createSeals(senderMessage, senderMessage.groupMembers(), signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) {
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderMessage.id)) {
// The wraps publish only after mining, but the user has already
// replied — advance the read marker now.
markDmRoomAsRead(senderMessage)
@@ -3770,7 +3804,7 @@ class Account(
val senderNote = signer.sign(template)
val recipients = senderNote.taggedUserIds().plus(signer.pubKey).toSet()
val seals = NIP17Factory().createSeals(senderNote, recipients, signer)
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty)) return
if (mineWrapsInBackground(seals.seals, seals.expirationDelta, powDifficulty, displayedNoteId = senderNote.id)) return
}
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
@@ -3784,11 +3818,20 @@ class Account(
}
suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
broadcastPrivately(signedEvents.wraps)
broadcastPrivately(signedEvents.wraps, signedEvents.msg.id)
markDmRoomAsRead(signedEvents.msg)
}
suspend fun broadcastPrivately(wraps: List<GiftWrapEvent>) {
/**
* [displayedNoteId] is the inner rumor's id (the note the chat feed shows).
* When present, each wrap registers with the delivery-ticks tracker — this is
* the only place the recipient -> wrap -> target-relays mapping exists, before
* the wraps are aliased onto a single note.
*/
suspend fun broadcastPrivately(
wraps: List<GiftWrapEvent>,
displayedNoteId: HexKey? = null,
) {
val mine = wraps.filter { (it.recipientPubKey() == signer.pubKey) }
mine.forEach { giftWrap ->
@@ -3805,6 +3848,19 @@ class Account(
}
val relayList = computeRelayListToBroadcast(wrap)
if (displayedNoteId != null) {
wrap.recipientPubKey()?.let { recipient ->
chatDeliveryTracker.trackWrap(
displayedNoteId = displayedNoteId,
recipient = recipient,
wrapId = wrap.id,
targetRelays = relayList,
isSelf = recipient == signer.pubKey,
)
}
}
client.publish(wrap, relayList)
}
@@ -746,6 +746,7 @@ object LocalCache : ILocalCache, ICacheProvider {
communityId: String,
channelIdHex: String,
rumor: Event,
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
) {
// Attach to the channel BEFORE justConsume sets the event and notifies feeds,
// so the note already carries its ConcordChannel gatherer when it flows through
@@ -772,6 +773,16 @@ object LocalCache : ILocalCache, ICacheProvider {
// gift-wrapped DM rumor, so we consume it as pre-verified.
justConsume(rumor, null, true)
// A Concord rumor is decrypted locally with the plane key, so it arrives with no
// per-relay attribution (relay = null above). Stamp the relays its carrying wrap was
// seen on — mirroring NIP-17's addRelayToNoteAndInners — so the chat UI can show where
// the message actually came from, not just the channel's configured relays.
if (seenOnRelays.isNotEmpty()) {
getNoteIfExists(rumor.id)?.takeIf { it.event != null }?.let { note ->
seenOnRelays.forEach { note.addRelay(it) }
}
}
// justConsume bails without loading the event when the rumor has already been deleted
// (a kind-5 delete referencing it was processed first — easy to hit in Concord because a
// reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to
@@ -73,6 +73,9 @@ class AccountCacheState(
accounts.update { existingAccounts ->
val oldValue = existingAccounts[pubkey]
oldValue?.scope?.cancel()
// Unregisters the tracker's persistent listener from the shared
// client; without this every removed account leaks a listener.
oldValue?.chatDeliveryTracker?.destroy()
existingAccounts.minus(pubkey)
}
}
@@ -266,6 +269,7 @@ class AccountCacheState(
accounts.update { existingAccounts ->
existingAccounts.forEach {
it.value.scope.cancel()
it.value.chatDeliveryTracker.destroy()
}
emptyMap()
}
@@ -0,0 +1,253 @@
/*
* 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.relayClient.chatDelivery
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/** Delivery progress of one recipient's gift wrap (NIP-17 DMs). */
@Immutable
data class RecipientDelivery(
val recipient: HexKey,
val targetRelays: Set<NormalizedRelayUrl>,
val acceptedRelays: Set<NormalizedRelayUrl> = emptySet(),
// The sender's own self-copy wrap: shown in the delivery detail (it matters
// for multi-device sync) but excluded from "delivered to everyone" and the
// k/n count, which describe the OTHER participants.
val isSelf: Boolean = false,
) {
val isDelivered: Boolean
get() = acceptedRelays.isNotEmpty()
}
/**
* Delivery progress of one outgoing chat message. For DMs [recipients] carries one
* entry per gift wrap (the sender's self-copy included); for public rooms it is
* null and [targetRelays]/[acceptedRelays] describe the room's relay set.
*/
@Immutable
data class ChatDelivery(
val targetRelays: Set<NormalizedRelayUrl>,
val acceptedRelays: Set<NormalizedRelayUrl> = emptySet(),
val recipients: List<RecipientDelivery>? = null,
) {
/** The other participants' wraps (self-copy excluded); null for rooms. */
val otherRecipients: List<RecipientDelivery>?
get() = recipients?.filterNot { it.isSelf }
val isFullyAccepted: Boolean
get() {
val others = otherRecipients
return if (others != null) {
others.isNotEmpty() && others.all { it.isDelivered }
} else {
targetRelays.isNotEmpty() && acceptedRelays.containsAll(targetRelays)
}
}
}
/**
* Remembers, per outgoing chat message, which relays were targeted at publish time
* and which have accepted (relay OK) since — the source for the delivery ticks on
* own chat bubbles.
*
* The relay pool's outbox drops its entry once an event is fully acked, and relay
* OKs for recipient gift wraps aggregate onto a single aliased Note, losing WHICH
* recipient's wrap was accepted. This tracker fills both gaps: sends register the
* `displayed note id -> (recipient, wrap id, target relays)` mapping while it still
* exists (inside the publish loop), and a persistent OK listener attributes each
* acceptance back to the message and, for DMs, to the recipient.
*
* State is held as one small StateFlow per tracked message, so a relay OK updates
* and notifies only that message's collectors instead of fanning out through a
* whole-map flow. In-memory only: history from before an app restart simply has no
* entry, and the UI falls back to the Note's seen-on relays.
*
* Owns a persistent listener on the client: [destroy] MUST be called when the
* owning Account is discarded (logout / account removal) or the listener leaks.
*/
class ChatDeliveryTracker(
client: INostrClient,
) {
private val lock = Any()
// One flow per tracked (or queried) displayed-note id; LinkedHashMap iteration
// order is insertion order, used for eviction. Guarded by [lock].
private val deliveries = LinkedHashMap<HexKey, MutableStateFlow<ChatDelivery?>>()
// Lock-free fast-path index for the OK listener, which fires for every event
// the app publishes anywhere: wrap id -> (displayed note id, recipient).
@Volatile
private var wrapIndex = mapOf<HexKey, Pair<HexKey, HexKey>>()
// Lock-free fast-path set of tracked/queried note ids (see [onAccepted]).
@Volatile
private var knownIds = setOf<HexKey>()
private val okCollector =
RelayInsertConfirmationCollector(client) { eventId, relay ->
onAccepted(eventId, relay.url)
}
/** Registers a public room message published to the room's [targetRelays]. */
fun trackPublic(
eventId: HexKey,
targetRelays: Set<NormalizedRelayUrl>,
) {
if (targetRelays.isEmpty()) return
synchronized(lock) {
flowForLocked(eventId).value = ChatDelivery(targetRelays)
}
}
/**
* Registers one recipient's gift wrap of the DM whose chat feed shows
* [displayedNoteId] (the inner rumor's id).
*/
fun trackWrap(
displayedNoteId: HexKey,
recipient: HexKey,
wrapId: HexKey,
targetRelays: Set<NormalizedRelayUrl>,
isSelf: Boolean = false,
) {
synchronized(lock) {
val flow = flowForLocked(displayedNoteId)
val current = flow.value
flow.value =
ChatDelivery(
targetRelays = (current?.targetRelays ?: emptySet()) + targetRelays,
acceptedRelays = current?.acceptedRelays ?: emptySet(),
recipients = (current?.recipients ?: emptyList()) + RecipientDelivery(recipient, targetRelays, isSelf = isSelf),
)
wrapIndex = wrapIndex + (wrapId to (displayedNoteId to recipient))
}
}
/**
* Registers a group message that was broadcast as a single encrypted [wrapId] to
* the group's [targetRelays] (Concord channels: the whole channel shares one
* plane wrap, not a per-recipient gift wrap). Relays OK the wrap, but the feed
* shows the decrypted inner rumor, so [displayedNoteId] (the rumor id) is mapped
* through [wrapId] — like [trackPublic] (relay ticks, no per-recipient breakdown)
* but keyed by the wrap the relay actually acknowledges.
*/
fun trackWrappedPublic(
displayedNoteId: HexKey,
wrapId: HexKey,
targetRelays: Set<NormalizedRelayUrl>,
) {
if (targetRelays.isEmpty()) return
synchronized(lock) {
flowForLocked(displayedNoteId).value = ChatDelivery(targetRelays)
// recipient is unused for a relay-only delivery (recipients stays null, so
// onAccepted never matches on it); reuse the note id as a harmless value.
wrapIndex = wrapIndex + (wrapId to (displayedNoteId to displayedNoteId))
}
}
fun deliveryFlow(noteId: HexKey): StateFlow<ChatDelivery?> =
synchronized(lock) {
flowForLocked(noteId)
}
fun currentFor(noteId: HexKey): ChatDelivery? =
synchronized(lock) {
deliveries[noteId]?.value
}
fun destroy() {
okCollector.destroy()
synchronized(lock) {
deliveries.clear()
wrapIndex = emptyMap()
knownIds = emptySet()
}
}
private fun onAccepted(
eventId: HexKey,
relay: NormalizedRelayUrl,
) {
// Lock-free negative path: OKs fire for every event the app publishes
// anywhere; almost all are not chat messages we track.
if (eventId !in wrapIndex && eventId !in knownIds) return
synchronized(lock) {
val wrapTarget = wrapIndex[eventId]
if (wrapTarget != null) {
val (noteId, recipient) = wrapTarget
val flow = deliveries[noteId] ?: return
val delivery = flow.value ?: return
flow.value =
delivery.copy(
acceptedRelays = delivery.acceptedRelays + relay,
recipients =
delivery.recipients?.map {
if (it.recipient == recipient) {
it.copy(acceptedRelays = it.acceptedRelays + relay)
} else {
it
}
},
)
} else {
val flow = deliveries[eventId] ?: return
val delivery = flow.value ?: return
flow.value = delivery.copy(acceptedRelays = delivery.acceptedRelays + relay)
}
}
}
// Must run under [lock]. Also creates entries for ids queried by the UI
// before their send registers (compose can win that race), so both paths
// share the same flow instance.
private fun flowForLocked(noteId: HexKey): MutableStateFlow<ChatDelivery?> {
deliveries[noteId]?.let { return it }
val flow = MutableStateFlow<ChatDelivery?>(null)
deliveries[noteId] = flow
knownIds = knownIds + noteId
while (deliveries.size > MAX_TRACKED) {
val evicted = deliveries.keys.first()
deliveries.remove(evicted)
knownIds = knownIds - evicted
wrapIndex = wrapIndex.filterValues { it.first != evicted }
}
return flow
}
companion object {
// Delivery state is only rendered on recent messages; a bounded window keeps
// the maps from growing for the whole session.
private const val MAX_TRACKED = 500
}
}
@@ -133,6 +133,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayG
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.RelayGroupThreadsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.NewConversationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.share.ShareToDMScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
@@ -753,6 +754,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ChannelMetadataEdit> { ChannelMetadataScreen(it.id, accountViewModel, nav) }
composableFromBottomArgs<Route.NewEphemeralChat> { NewEphemeralChatScreen(accountViewModel, nav) }
composableFromBottom<Route.NewConversation> { NewConversationScreen(nav) }
composableFromBottomArgs<Route.NewGroupDM> { NewGroupDMScreen(it.message, it.attachment, accountViewModel, nav) }
composableFromBottomArgs<Route.ShareToDM> { ShareToDMScreen(it.message, it.attachment, accountViewModel, nav) }
@@ -24,7 +24,6 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
@@ -185,67 +184,64 @@ private fun RenderBottomMenu(
containerColor = MaterialTheme.colorScheme.background,
tonalElevation = Size0dp,
) {
// Render in the user's saved order, built-ins and favorites interleaved.
// Render in the user's saved order — built-ins, favorites and pinned groups interleaved.
// Each entry resolves to a shared BottomBarSlot (route + icon), the same one the rail uses.
items.forEach { entry ->
when (entry) {
is BottomBarEntry.BuiltIn -> {
val def = NavBarCatalog[entry.item] ?: return@forEach
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
HasNewItemsIcon(destination == selectedRoute, def, destination, accountViewModel, nav)
}
is BottomBarEntry.Favorite -> {
val fav = favoritesById[entry.favoriteId] ?: return@forEach
val destination =
when (fav) {
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav)
}
}
val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach
val selected = slot.route == selectedRoute
NavigationBarItem(
alwaysShowLabel = false,
icon = { slot.icon(selected) },
selected = selected,
onClick = { nav(slot.route) },
)
}
}
}
}
@Composable
private fun RowScope.FavoriteNavItem(
selected: Boolean,
fav: FavoriteApp,
iconModel: Any?,
destination: Route,
nav: (Route) -> Unit,
) {
NavigationBarItem(
alwaysShowLabel = false,
icon = { FavoriteEntryIcon(fav, selected, iconModel) },
// No label — favorite tabs match the built-in items, which show icon only.
selected = selected,
onClick = { nav(destination) },
)
}
/**
* A resolved bottom-bar/rail slot: the [route] to navigate to and the [icon] to draw for a given
* selected state. Built from a [BottomBarEntry] by [rememberBottomBarSlot], so the phone bar and the
* large-screen rail render every entry kind (built-in, favorite, pinned group) through one code path.
*/
class BottomBarSlot(
val route: Route,
val icon: @Composable (selected: Boolean) -> Unit,
)
/** Resolves an entry to its live [BottomBarSlot], or null if it no longer resolves (deleted favorite, etc.). */
@Composable
private fun RowScope.HasNewItemsIcon(
selected: Boolean,
def: NavBarItemDef,
destination: Route,
internal fun rememberBottomBarSlot(
entry: BottomBarEntry,
favoritesById: Map<String, FavoriteApp>,
accountViewModel: AccountViewModel,
nav: (Route) -> Unit,
) {
NavigationBarItem(
alwaysShowLabel = false,
icon = {
NotifiableIcon(
selected,
def,
destination,
accountViewModel,
)
},
selected = selected,
onClick = { nav(destination) },
)
): BottomBarSlot? {
return when (entry) {
is BottomBarEntry.BuiltIn -> {
val def = NavBarCatalog[entry.item] ?: return null
val route = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
BottomBarSlot(route) { selected -> NotifiableIcon(selected, def, route, accountViewModel) }
}
is BottomBarEntry.Favorite -> {
val fav = favoritesById[entry.favoriteId] ?: return null
val route =
when (fav) {
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
val iconModel = rememberFavoriteIconModel(fav)
BottomBarSlot(route) { selected -> FavoriteEntryIcon(fav, selected, iconModel) }
}
is BottomBarEntry.PublicChat,
is BottomBarEntry.RelayGroup,
is BottomBarEntry.Concord,
-> {
val display = rememberGroupEntryDisplay(entry, accountViewModel) ?: return null
// A pinned chat/group shows its avatar, like the favorite-app tabs — icon only.
BottomBarSlot(display.route) { Box(Size27Modifier, contentAlignment = Alignment.Center) { GroupEntryAvatar(display, 25.dp, accountViewModel) } }
}
}
}
/** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */
@@ -32,9 +32,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -75,56 +76,42 @@ fun AppNavigationRail(
modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Same entry list + resolution as the phone bottom bar; only the item chrome and the
// selection source (live back stack vs the bar's passed-in route) differ per surface.
items.forEach { entry ->
when (entry) {
is BottomBarEntry.BuiltIn -> {
val def = NavBarCatalog[entry.item] ?: return@forEach
val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) }
val selected = currentDestination?.hasRoute(destination::class) == true
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(destination)
} else {
nav.navBottomBar(destination)
}
},
icon = { NotifiableIcon(selected, def, destination, accountViewModel) },
)
}
is BottomBarEntry.Favorite -> {
val fav = favoritesById[entry.favoriteId] ?: return@forEach
val destination =
when (fav) {
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
// Favorites carry arguments (url / coordinate), so class matching alone
// would light up every pinned app of the same kind; compare the full route.
val selected =
remember(navBackStackEntry, destination) {
when (destination) {
is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination
is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination
else -> false
}
}
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(destination)
} else {
nav.navBottomBar(destination)
}
},
icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) },
)
}
}
val slot = rememberBottomBarSlot(entry, favoritesById, accountViewModel) ?: return@forEach
val selected = remember(navBackStackEntry, slot.route) { railSelected(slot.route, nav.controller, currentDestination) }
NavigationRailItem(
selected = selected,
onClick = {
if (selected) {
reselectCoordinator.reselect(slot.route)
} else {
nav.navBottomBar(slot.route)
}
},
icon = { slot.icon(selected) },
)
}
}
}
}
/**
* Whether [route] is the rail's currently-selected destination. Parameterized routes (favorites and
* pinned groups carry a url / coordinate / id) must match the full route — class matching alone would
* light up every pinned app or group of the same kind; parameterless object routes match on class.
*/
private fun railSelected(
route: Route,
controller: NavHostController,
currentDestination: NavDestination?,
): Boolean =
when (route) {
is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, controller) == route
is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, controller) == route
is Route.PublicChatChannel -> getRouteWithArguments(Route.PublicChatChannel::class, controller) == route
is Route.RelayGroup -> getRouteWithArguments(Route.RelayGroup::class, controller) == route
is Route.ConcordServer -> getRouteWithArguments(Route.ConcordServer::class, controller) == route
else -> currentDestination?.hasRoute(route::class) == true
}
@@ -25,8 +25,9 @@ import kotlinx.serialization.Serializable
/**
* One slot in the bottom navigation bar. A single ordered list of these (persisted in
* [com.vitorpamplona.amethyst.model.UiSettings.bottomBarItems]) holds **both** built-in destinations
* and favorite apps, so the user can pin and drag-reorder them together in one list.
* [com.vitorpamplona.amethyst.model.UiSettings.bottomBarItems]) holds built-in destinations,
* favorite apps, and individual joined chats/groups, so the user can pin and drag-reorder them
* together in one list.
*
* - [BuiltIn] resolves its [Route][com.vitorpamplona.amethyst.ui.navigation.routes.Route] (and its
* icon/label/notification badge) through [NavBarCatalog], like before.
@@ -34,6 +35,9 @@ import kotlinx.serialization.Serializable
* its stable id (which already encodes the route's parameters — the `url` or addressable
* `coordinate`); the bar resolves it to a live favorite for its icon/label and to
* `Route.WebApp` / `Route.NostrApp` for navigation.
* - [PublicChat], [RelayGroup] and [Concord] each pin one specific joined chat the user picked from
* their joined list (NIP-28 channel, NIP-29 relay group, or a Concord community). The bar resolves
* each to the chat's avatar + name from the local cache and to its chat/home route for navigation.
*/
@Serializable
sealed interface BottomBarEntry {
@@ -50,7 +54,43 @@ sealed interface BottomBarEntry {
data class Favorite(
val favoriteId: String,
) : BottomBarEntry
/** A pinned NIP-28 public chat channel, keyed by its channel event id (hex). */
@Serializable
@SerialName("publicChat")
data class PublicChat(
val channelId: String,
) : BottomBarEntry
/** A pinned NIP-29 relay group, keyed by the (group id, host relay) pair — the group's real key. */
@Serializable
@SerialName("relayGroup")
data class RelayGroup(
val groupId: String,
val relayUrl: String,
) : BottomBarEntry
/** A pinned Concord community, keyed by its community id; opens the community's channel list. */
@Serializable
@SerialName("concord")
data class Concord(
val communityId: String,
) : BottomBarEntry
}
/**
* Stable, type-discriminated identity for an entry — used as a Compose list key and for membership
* checks / de-duplication in the settings picker.
*/
val BottomBarEntry.stableKey: String
get() =
when (this) {
is BottomBarEntry.BuiltIn -> "builtIn:${item.name}"
is BottomBarEntry.Favorite -> "favorite:$favoriteId"
is BottomBarEntry.PublicChat -> "publicChat:$channelId"
is BottomBarEntry.RelayGroup -> "relayGroup:$relayUrl|$groupId"
is BottomBarEntry.Concord -> "concord:$communityId"
}
/** The favorite-app ids in this bottom-bar config — the apps that should be kept warm as bottom-row tabs. */
fun List<BottomBarEntry>.favoriteIds(): List<String> = mapNotNull { (it as? BottomBarEntry.Favorite)?.favoriteId }
@@ -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.navigation.bottombars
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.ChannelState
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
* The resolved presentation of a pinned chat/group [BottomBarEntry] — enough to render its avatar in
* the bottom bar and its row in the settings picker, and to navigate when tapped. Resolved live from
* the local cache, so the name/avatar fill in as the group's metadata arrives.
*/
@Immutable
data class GroupEntryDisplay(
val label: String,
val robotSeed: String,
val model: String?,
val route: Route,
)
/**
* Observes a channel's metadata for a display row. [subscribe] gates the relay REQ:
* - the live bottom bar / rail passes true — a pinned group keeps a REQ open so its name/avatar
* refresh even if the group is never opened (bounded by the handful of pinned slots);
* - the settings picker passes false — it reads whatever metadata is already cached (filled by the
* chats/group screens) so expanding a category with many joined groups doesn't fan out into one
* subscription per row.
*/
@Composable
private fun observeChannelMetadata(
channel: Channel,
accountViewModel: AccountViewModel,
subscribe: Boolean,
): State<ChannelState?> {
if (subscribe) ChannelFinderFilterAssemblerSubscription(channel, accountViewModel)
return channel
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
}
@Composable
fun rememberPublicChatEntryDisplay(
entry: BottomBarEntry.PublicChat,
accountViewModel: AccountViewModel,
subscribe: Boolean = true,
): GroupEntryDisplay {
val channel = remember(entry.channelId) { LocalCache.getOrCreatePublicChatChannel(entry.channelId) }
val state by observeChannelMetadata(channel, accountViewModel, subscribe)
val current = (state?.channel as? PublicChatChannel) ?: channel
return GroupEntryDisplay(
label = current.toBestDisplayName(),
robotSeed = entry.channelId,
model = current.profilePicture(),
route = Route.PublicChatChannel(entry.channelId),
)
}
@Composable
fun rememberRelayGroupEntryDisplay(
entry: BottomBarEntry.RelayGroup,
accountViewModel: AccountViewModel,
subscribe: Boolean = true,
): GroupEntryDisplay {
val relay = remember(entry.relayUrl) { RelayUrlNormalizer.normalizeOrNull(entry.relayUrl) }
val channel = remember(entry.groupId, relay) { relay?.let { LocalCache.getOrCreateRelayGroupChannel(GroupId(entry.groupId, it)) } }
// Always call the observer (with a null channel when the relay won't normalize) so the composable
// call structure is unconditional; a null channel just yields a null state and the id fallback.
val state by observeChannelMetadataOrNull(channel, accountViewModel, subscribe)
val current = (state?.channel as? RelayGroupChannel) ?: channel
// The name from the group's own kind-39000 metadata, once it has loaded.
val metaName = current?.event?.name()?.ifBlank { null }
// Otherwise the name the user's joined-groups list stored for this group (the NIP-51
// ["group", id, relay, name] tag) — so the row reads as a name even before 39000 is fetched,
// which the read-only settings picker never does.
val joined by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
val tagName =
remember(joined, entry) {
joined.firstOrNull { it.groupId == entry.groupId && it.relayUrl == entry.relayUrl }?.name?.ifBlank { null }
}
return GroupEntryDisplay(
label = metaName ?: tagName ?: entry.groupId,
robotSeed = entry.groupId,
model = current?.profilePicture(),
route = Route.RelayGroup(entry.groupId, entry.relayUrl),
)
}
/** [observeChannelMetadata] tolerant of a null channel (unresolvable relay), so callers avoid an early return. */
@Composable
private fun observeChannelMetadataOrNull(
channel: Channel?,
accountViewModel: AccountViewModel,
subscribe: Boolean,
): State<ChannelState?> {
if (channel == null) return remember { mutableStateOf<ChannelState?>(null) }
return observeChannelMetadata(channel, accountViewModel, subscribe)
}
@Composable
fun rememberConcordEntryDisplay(
entry: BottomBarEntry.Concord,
accountViewModel: AccountViewModel,
): GroupEntryDisplay {
val account = accountViewModel.account
// Recompute the folded metadata (name / icon) whenever a Control Plane folds.
val revision by account.concordSessions.revision.collectAsStateWithLifecycle()
val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle()
val session = remember(entry.communityId, revision) { account.concordSessions.sessionFor(entry.communityId) }
val metadata =
session
?.state
?.value
.takeIf { revision >= 0 }
?.metadata
val fallbackName = remember(communities, entry.communityId) { communities.firstOrNull { it.id == entry.communityId }?.name?.ifBlank { null } }
val label = metadata?.name?.takeIf { it.isNotBlank() } ?: fallbackName ?: stringRes(R.string.concord_home_title)
val model = rememberConcordImageModel(metadata?.icon, accountViewModel)
return GroupEntryDisplay(
label = label,
robotSeed = entry.communityId,
model = model,
route = Route.ConcordServer(entry.communityId),
)
}
/**
* Resolves any chat/group entry to its live display, or null for a non-group entry. [subscribe] is
* forwarded to the channel observers: true (the default) keeps a relay REQ open — used by the live
* bar/rail; false reads only cached metadata — used by the settings picker (see [observeChannelMetadata]).
*/
@Composable
fun rememberGroupEntryDisplay(
entry: BottomBarEntry,
accountViewModel: AccountViewModel,
subscribe: Boolean = true,
): GroupEntryDisplay? =
when (entry) {
is BottomBarEntry.PublicChat -> rememberPublicChatEntryDisplay(entry, accountViewModel, subscribe)
is BottomBarEntry.RelayGroup -> rememberRelayGroupEntryDisplay(entry, accountViewModel, subscribe)
is BottomBarEntry.Concord -> rememberConcordEntryDisplay(entry, accountViewModel)
is BottomBarEntry.BuiltIn -> null
is BottomBarEntry.Favorite -> null
}
/** The circular avatar for a pinned chat/group, shared by the bottom bar and the settings picker. */
@Composable
fun GroupEntryAvatar(
display: GroupEntryDisplay,
size: Dp,
accountViewModel: AccountViewModel,
) {
RobohashFallbackAsyncImage(
robot = display.robotSeed,
model = display.model,
contentDescription = display.label,
modifier = Modifier.size(size).clip(CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = false,
)
}
@@ -437,6 +437,100 @@ val DrawerYouItems: List<NavBarItem> =
NavBarItem.WALLET,
)
/**
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
* picker; these curated categories give the "Available" list a deliberate, grouped order instead.
* Every [NavBarItem] in [NavBarCatalog] appears in exactly one category (see [BottomBarCategories]).
*/
data class NavBarCategory(
val titleRes: Int,
val items: List<NavBarItem>,
)
/**
* The ordered, grouped catalog for the settings picker. Kept in sync with [NavBarCatalog]: every
* catalog id must appear here exactly once (asserted by BottomBarCategoriesTest), so a newly added
* screen surfaces in the picker under a deliberate heading rather than vanishing.
*/
val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarCategory(
R.string.bottom_bar_category_main,
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
),
),
NavBarCategory(
R.string.bottom_bar_category_chats,
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
),
),
NavBarCategory(
R.string.bottom_bar_category_you,
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
),
),
NavBarCategory(
R.string.bottom_bar_category_feeds,
listOf(
NavBarItem.ARTICLES,
NavBarItem.LONGS,
NavBarItem.PICTURES,
NavBarItem.SHORTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.PODCASTS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.POLLS,
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.COMMUNITIES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.BADGES,
NavBarItem.EMOJI_SETS,
),
),
NavBarCategory(
R.string.bottom_bar_category_apps,
listOf(
NavBarItem.BROWSER,
NavBarItem.FAVORITE_APPS,
NavBarItem.SOFTWARE_APPS,
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
),
),
NavBarCategory(
R.string.bottom_bar_category_other,
listOf(
NavBarItem.SETTINGS,
),
),
)
val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
@@ -752,6 +752,11 @@ sealed class Route {
val attachment: String? = null,
) : Route()
// Full-screen chooser opened from the Messages FAB: explains each conversation type
// (DM, Marmot group, Concord, public chat, relay group, disappearing chat) with its
// pros/cons and routes to that type's creation flow.
@Serializable object NewConversation : Route()
@Serializable data class ShareToDM(
val message: String? = null,
val attachment: String? = null,
@@ -45,12 +45,20 @@ fun TopBarExtensibleWithBackButton(
title: @Composable RowScope.() -> Unit,
extendableRow: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
// A back arrow is meaningless when there's nothing to pop (e.g. this screen is a bottom-nav
// root); callers pass nav.canPop() so the arrow hides and the bottom bar takes its place.
showBackButton: Boolean = true,
popBack: () -> Unit,
) {
MyExtensibleTopAppBar(
title = title,
extendableRow = extendableRow,
navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } },
navigationIcon =
if (showBackButton) {
{ IconButton(onClick = popBack) { ArrowBackIcon() } }
} else {
null
},
actions = actions,
)
}
@@ -1153,6 +1153,31 @@ private data class OnchainZapRequest(
val amountSats: Long?,
)
/**
* Pays a single payable via a wallet intent, or routes multiple payables (zap
* splits) to the manual payment screen. The shared tail of every zap flow.
*/
@OptIn(ExperimentalUuidApi::class)
fun payViaIntentOrManualSplit(
payables: ImmutableList<ZapPaymentHandler.Payable>,
context: Context,
accountViewModel: AccountViewModel,
nav: INav,
onPaymentError: () -> Unit = {},
) {
if (payables.size == 1) {
val payable = payables.first()
payViaIntent(payable.invoice, context, { }) { error ->
onPaymentError()
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, payables)
nav.nav(Route.ManualZapSplitPayment(uid))
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class, ExperimentalUuidApi::class)
fun ZapReaction(
@@ -1204,17 +1229,7 @@ fun ZapReaction(
}
},
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
payViaIntentOrManualSplit(it, context, accountViewModel, nav, onPaymentError = { zappingProgress = 0f })
},
onCustomAmount = {
wantsToSetCustomZap = true
@@ -1260,17 +1275,7 @@ fun ZapReaction(
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
zappingProgress = 0f
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, UserBasedErrorMessage(error, payable.info.user))
}
} else {
val uid = Uuid.random().toString()
accountViewModel.tempManualPaymentCache.put(uid, it)
nav.nav(Route.ManualZapSplitPayment(uid))
}
payViaIntentOrManualSplit(it, context, accountViewModel, nav, onPaymentError = { zappingProgress = 0f })
},
onReloadNutzap = { amount ->
wantsToZap = false
@@ -2025,6 +2030,42 @@ fun ZapAmountChoicePopup(
)
}
/**
* The recipient/sender rail availability (lightning, cashu, on-chain) for zapping
* [baseNote], recomputed as the async inputs (recipient kind:0 / kind:10019, the
* local cashu wallet) load. Each observe call also subscribes the relay fetch, so
* missing inputs are pulled in while the caller is on screen.
*/
@Composable
fun observeZapRailCapability(
baseNote: Note,
accountViewModel: AccountViewModel,
onchainSupported: Boolean,
): RailCapability {
val cashuState = accountViewModel.account.cashuWalletState
val author = baseNote.author
// These four are deliberately read only to drive the recompute below — do NOT
// delete them as "unused". Each observe* call ALSO subscribes the relay fetch
// (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is
// open), and each value is a remember() key so railCapability recomputes when
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
// just say *when* to re-run it.
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
// rail from the zap chips too, matching the wallet screen, profile chips, and
// Send Payment screen.
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
.collectAsStateWithLifecycle()
return remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
if (onchainSupported && showOnchainWallet) rc else rc.copy(hasOnchain = false)
}
}
@Composable
fun ZapAmountChoicePopup(
baseNote: Note,
@@ -2055,29 +2096,7 @@ fun ZapAmountChoicePopup(
// also trigger the relay fetch, so a not-yet-seen lnAddress / kind:10019 is
// pulled in while the popup is open. A caller that can't drive the on-chain
// dialog (onchainSupported == false) masks that rail off here.
val cashuState = accountViewModel.account.cashuWalletState
val author = baseNote.author
// These four are deliberately read only to drive the recompute below — do NOT
// delete them as "unused". Each observe* call ALSO subscribes the relay fetch
// (so a not-yet-seen kind:0 / kind:10019 gets pulled in while the popup is
// open), and each value is a remember() key so railCapability recomputes when
// it arrives. RailCapabilityResolver.peek re-reads everything itself; these
// just say *when* to re-run it.
val cashuMints by cashuState.mints.collectAsStateWithLifecycle()
val cashuEntries by cashuState.tokenEntries.collectAsStateWithLifecycle()
val recipientInfo = author?.let { observeUserInfo(it, accountViewModel).value }
val nutzapInfo = author?.let { observeNoteEvent<NutzapInfoEvent>(it.nutzapInfoNote, accountViewModel).value }
// Honors the user's "show on-chain wallet" preference: off hides the on-chain
// rail from the zap chips too, matching the wallet screen, profile chips, and
// Send Payment screen.
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
.collectAsStateWithLifecycle()
val railCapability =
remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
if (onchainSupported && showOnchainWallet) rc else rc.copy(hasOnchain = false)
}
val railCapability = observeZapRailCapability(baseNote, accountViewModel, onchainSupported)
val amountChoices =
remember(zapAmountChoices) {
// Keep the user's saved order (already de-duped at the settings
@@ -2197,39 +2216,66 @@ fun ZapAmountChoicePopupContent(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
FlowRow(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
itemVerticalAlignment = CenterVertically,
) {
amountChoices.forEach { amountInSats ->
UnifiedZapAmountChip(
amountInSats = amountInSats,
railCapability = railCapability,
onLightningZap = onLightningZap,
onNutzap = onNutzap,
onOnchainAmount = onOnchainAmount,
onReloadNutzap = onReloadNutzap,
onChangeAmount = onChangeAmount,
)
}
ClickableBox(
modifier =
Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
.size(32.dp)
.padding(7.dp),
onClick = onChangeAmount,
) {
Icon(
symbol = MaterialSymbols.Tune,
contentDescription = stringRes(R.string.quick_zap_amounts),
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
ZapAmountChoiceGrid(
amountChoices = amountChoices,
railCapability = railCapability,
onLightningZap = onLightningZap,
onNutzap = onNutzap,
onOnchainAmount = onOnchainAmount,
onChangeAmount = onChangeAmount,
onReloadNutzap = onReloadNutzap,
)
}
}
}
/**
* The bare zap-amount chip grid (one rail-aware pill per preset amount plus the
* preset editor button), without the popup card — embeddable in any surface, e.g.
* the chat long-press sheet.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ZapAmountChoiceGrid(
amountChoices: ImmutableList<Long>,
railCapability: RailCapability,
onLightningZap: (Long) -> Unit,
onNutzap: (Long) -> Unit,
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onReloadNutzap: (Long) -> Unit = {},
) {
FlowRow(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
itemVerticalAlignment = CenterVertically,
) {
amountChoices.forEach { amountInSats ->
UnifiedZapAmountChip(
amountInSats = amountInSats,
railCapability = railCapability,
onLightningZap = onLightningZap,
onNutzap = onNutzap,
onOnchainAmount = onOnchainAmount,
onReloadNutzap = onReloadNutzap,
onChangeAmount = onChangeAmount,
)
}
ClickableBox(
modifier =
Modifier
.padding(horizontal = 4.dp, vertical = 6.dp)
.size(32.dp)
.padding(7.dp),
onClick = onChangeAmount,
) {
Icon(
symbol = MaterialSymbols.Tune,
contentDescription = stringRes(R.string.quick_zap_amounts),
modifier = Size18Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
@@ -26,43 +26,33 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalClipboard
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun MoreOptionsButton(
@@ -114,6 +104,8 @@ fun NoteDropDownMenu(
var reportDialogShowing by remember { mutableStateOf(false) }
var addLabelDialogShowing by remember { mutableStateOf(false) }
var showShareSheet by remember { mutableStateOf(false) }
var deleteConfirmationShowing by remember { mutableStateOf(false) }
var concordBanConfirming by remember { mutableStateOf(false) }
// Tapping "Share" hands the note's share options off to the shared Share
// drawer (ShareOptionsBottomSheet). We render it INSTEAD of the menu dialog
@@ -167,247 +159,89 @@ fun NoteDropDownMenu(
)
}
// Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped
// deletion — a public NIP-09 would e-tag the rumor id onto public relays.
val performDelete = {
if (note.isPrivateRumor()) {
accountViewModel.deletePrivately(note)
} else {
accountViewModel.delete(note)
}
}
if (deleteConfirmationShowing) {
QuickActionAlertDialog(
title = stringRes(R.string.quick_action_request_deletion_alert_title),
textContent = stringRes(R.string.quick_action_request_deletion_alert_body),
buttonIcon = MaterialSymbols.Delete,
buttonText = stringRes(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
performDelete()
onDismiss()
},
onClickDontShowAgain = {
performDelete()
accountViewModel.account.settings.setHideDeleteRequestDialog()
onDismiss()
},
onDismiss = { deleteConfirmationShowing = false },
)
}
if (concordBanConfirming) {
ConcordBanConfirmationDialog(
note = note,
accountViewModel = accountViewModel,
onClose = { concordBanConfirming = false },
onBanned = onDismiss,
)
}
val handlers =
NoteActionHandlers(
onShare = { showShareSheet = true },
onEditPost = { wantsToEditPost.value = true },
onEditDraft = { nav.nav { routeEditDraftTo(note, accountViewModel.account) } },
onAddLabel = { addLabelDialogShowing = true },
onReport = { reportDialogShowing = true },
onDeleteRequest = {
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
performDelete()
onDismiss()
} else {
deleteConfirmationShowing = true
}
},
onConcordBan = { concordBanConfirming = true },
onDismiss = onDismiss,
)
// "Copy Text" copies the newest version of a versioned post when the caller
// is looking at one.
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
M3ActionDialog(
title = stringRes(R.string.note_actions_dialog_title),
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
// Unsealed rumors (private replies/posts received in gift wraps) are
// unsigned and must never be referenced by a public event: hide every
// action that would publish an e-tag of this note (broadcast, edit,
// OTS timestamp, pin, label, public bookmark, deletion request).
val isPrivateRumor = note.isPrivateRumor()
// Follow section
M3ActionSection {
if (!state.isFollowingAuthor) {
M3ActionRow(icon = MaterialSymbols.PersonAdd, text = stringRes(R.string.follow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.follow(author)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.PersonRemove, text = stringRes(R.string.unfollow)) {
val author = note.author ?: return@M3ActionRow
accountViewModel.unfollow(author)
onDismiss()
}
}
M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.follow_set_add_author_from_note_action)) {
val authorHexKey = note.author?.pubkeyHex ?: return@M3ActionRow
nav.nav(Route.PeopleListManagement(authorHexKey))
onDismiss()
}
}
// Copy & Share section. The copy-to-clipboard rows live here (and only
// here); the "Share" row hands off to the shared Share drawer.
M3ActionSection {
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) {
scope.launch {
clipboardManager.setText(it)
}
}
onDismiss()
}
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
onDismiss()
}
}
}
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(note.toNostrUri())
onDismiss()
}
}
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_raw_json)) {
val event = note.event
if (event != null) {
scope.launch {
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
clipboardManager.setText(json)
onDismiss()
}
} else {
onDismiss()
}
}
if (!isPrivateRumor) {
M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) {
showShareSheet = true
}
}
}
// Edit & Broadcast section
M3ActionSection {
if (state.isLoggedUser && note.isDraft()) {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_draft)) {
nav.nav { routeEditDraftTo(note, accountViewModel.account) }
}
}
if (!note.isDraft() && !isPrivateRumor) {
if (note.event is TextNoteEvent) {
if (state.isLoggedUser) {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_post)) {
wantsToEditPost.value = true
}
} else {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.propose_an_edit)) {
wantsToEditPost.value = true
}
}
} else if (note.event is LongTextNoteEvent && state.isLoggedUser) {
M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_article)) {
nav.nav { Route.NewLongFormPost(version = note.idHex) }
}
}
}
// Rumors are rebroadcast as their delivering gift wrap; hidden
// when the wrap is unknown (the unsigned rumor must never be
// published).
if (accountViewModel.canBroadcast(note)) {
M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
onDismiss()
}
}
}
// Timestamp & Bookmarks section
M3ActionSection {
if (!isPrivateRumor) {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() }
} else {
M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
onDismiss()
}
}
}
if (state.isLoggedUser && !isPrivateRumor) {
if (state.isPinnedNote) {
M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.unpin_from_profile)) {
accountViewModel.removePin(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.pin_to_profile)) {
accountViewModel.addPin(note)
onDismiss()
}
}
}
if (!isPrivateRumor) {
M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) {
addLabelDialogShowing = true
}
}
// Pick exactly one curation flow per kind: music tracks go to playlists, emoji
// packs go to the emoji list, everything else gets the standard bookmark rows.
// Showing both at once is noisy and makes "bookmark" feel like the catch-all when
// it really isn't for these kinds.
when {
isPrivateRumor -> {
// No bookmark/playlist/emoji-list rows for private rumors:
// those lists reference the note by id, which other devices
// can't resolve from relays and public lists would leak.
}
note.event is MusicTrackEvent && note is AddressableNote -> {
// Music tracks (kind 36787) belong in playlists (kind 34139). The
// sheet behind this nav lets the user toggle membership across all of
// their own playlists in one place — that subsumes the private/public
// bookmark add+remove pair for this kind.
M3ActionRow(icon = MaterialSymbols.AutoMirrored.PlaylistAdd, text = stringRes(R.string.add_to_music_playlist)) {
nav.nav(Route.AddToMusicPlaylist(note.address.toValue()))
onDismiss()
}
}
note.event is EmojiPackEvent -> {
// Emoji packs belong in the user's emoji list (kind 10030), not the
// bookmark list.
val emojiText =
if (state.isEmojiPackInMyList) {
stringRes(R.string.remove_from_emoji_list)
} else {
stringRes(R.string.add_to_emoji_list)
}
M3ActionRow(icon = MaterialSymbols.EmojiEmotions, text = emojiText) {
val address = (note as AddressableNote).address
nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag))
onDismiss()
}
}
else -> {
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
M3ActionRow(icon = MaterialSymbols.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
}
if (state.isPrivateBookmarkNote) {
M3ActionRow(icon = MaterialSymbols.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Lock, text = stringRes(R.string.add_to_private_bookmarks)) {
accountViewModel.addPrivateBookmark(note)
onDismiss()
}
}
if (state.isPublicBookmarkNote) {
M3ActionRow(icon = MaterialSymbols.BookmarkRemove, text = stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Bookmark, text = stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
onDismiss()
}
}
}
}
}
// Moderation section
M3ActionSection {
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
M3ActionRow(
icon = MaterialSymbols.AutoMirrored.VolumeOff,
text = stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
) {
if (isThreadMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
}
onDismiss()
}
if (state.isLoggedUser && !isPrivateRumor) {
M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) {
accountViewModel.delete(note)
onDismiss()
}
} else {
M3ActionRow(icon = MaterialSymbols.Report, text = stringRes(R.string.block_report), isDestructive = true) {
reportDialogShowing = true
// The action inventory is shared with the chat long-press sheet
// (noteActionSections), so the two surfaces cannot drift.
noteActionSections(
note = note,
noteVersionToCopy = lastNoteVersion,
state = state,
handlers = handlers,
accountViewModel = accountViewModel,
nav = nav,
).forEach { section ->
M3ActionSection {
section.forEach { action ->
M3ActionRow(
icon = action.symbol,
text = action.label,
isDestructive = action.isDestructive,
onClick = action.onClick,
)
}
}
}
@@ -0,0 +1,405 @@
/*
* 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.elements
import androidx.compose.material3.ButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialogOneButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.LightRedColor
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** One note action, rendered as a menu row or an action-sheet tile. */
@Immutable
data class NoteAction(
val symbol: MaterialSymbol,
val label: String,
val isDestructive: Boolean = false,
val onClick: () -> Unit,
)
/**
* Callbacks for actions whose UI is owned by the rendering surface (dialogs,
* sheets), so the shared inventory stays surface-agnostic.
*/
@Immutable
data class NoteActionHandlers(
val onShare: () -> Unit,
val onEditPost: () -> Unit,
val onEditDraft: () -> Unit,
val onAddLabel: () -> Unit,
val onReport: () -> Unit,
val onDeleteRequest: () -> Unit,
// Banning a Concord member re-keys the community for everyone, so the surface
// owns a confirmation dialog (like delete). Null where a caller never renders
// Concord messages (they simply never see the Ban tile anyway).
val onConcordBan: (() -> Unit)? = null,
val onDismiss: () -> Unit,
)
/**
* The shared note-action inventory behind both the 3-dot menu (NoteDropDownMenu)
* and the chat long-press sheet: one list of sections, each a list of actions with
* their visibility gating applied, so the two surfaces can never drift.
*
* [noteVersionToCopy] lets the menu copy the latest edit of a versioned post; every
* other caller passes [note].
*/
@Composable
fun noteActionSections(
note: Note,
noteVersionToCopy: Note,
state: DropDownParams,
handlers: NoteActionHandlers,
accountViewModel: AccountViewModel,
nav: INav,
): List<List<NoteAction>> {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
// Unsealed rumors (private replies/posts received in gift wraps) are
// unsigned and must never be referenced by a public event: hide every
// action that would publish an e-tag of this note (broadcast, edit,
// OTS timestamp, pin, label, public bookmark).
val isPrivateRumor = note.isPrivateRumor()
val author =
buildList {
if (!state.isLoggedUser) {
if (!state.isFollowingAuthor) {
add(
NoteAction(MaterialSymbols.PersonAdd, stringRes(R.string.follow)) {
note.author?.let { accountViewModel.follow(it) }
handlers.onDismiss()
},
)
} else {
add(
NoteAction(MaterialSymbols.PersonRemove, stringRes(R.string.unfollow)) {
note.author?.let { accountViewModel.unfollow(it) }
handlers.onDismiss()
},
)
}
}
add(
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.follow_set_add_author_from_note_action)) {
note.author?.pubkeyHex?.let { nav.nav(Route.PeopleListManagement(it)) }
handlers.onDismiss()
},
)
}
val copyAndShare =
buildList {
add(
NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) {
accountViewModel.decrypt(noteVersionToCopy) {
scope.launch { clipboardManager.setText(it) }
}
handlers.onDismiss()
},
)
add(
NoteAction(MaterialSymbols.AlternateEmail, stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
scope.launch(Dispatchers.IO) {
clipboardManager.setText("nostr:${it.pubkeyNpub()}")
handlers.onDismiss()
}
}
},
)
add(
NoteAction(MaterialSymbols.FormatQuote, stringRes(R.string.copy_note_id)) {
scope.launch(Dispatchers.IO) {
clipboardManager.setText(note.toNostrUri())
handlers.onDismiss()
}
},
)
add(
NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_raw_json)) {
val event = note.event
if (event != null) {
scope.launch {
val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) }
clipboardManager.setText(json)
handlers.onDismiss()
}
} else {
handlers.onDismiss()
}
},
)
if (!isPrivateRumor) {
add(NoteAction(MaterialSymbols.Share, stringRes(R.string.quick_action_share), onClick = handlers.onShare))
}
}
val editAndBroadcast =
buildList {
if (state.isLoggedUser && note.isDraft()) {
add(NoteAction(MaterialSymbols.Edit, stringRes(R.string.edit_draft), onClick = handlers.onEditDraft))
}
if (!note.isDraft() && !isPrivateRumor) {
if (note.event is TextNoteEvent) {
add(
NoteAction(
MaterialSymbols.Edit,
stringRes(if (state.isLoggedUser) R.string.edit_post else R.string.propose_an_edit),
onClick = handlers.onEditPost,
),
)
} else if (note.event is LongTextNoteEvent && state.isLoggedUser) {
add(
NoteAction(MaterialSymbols.Edit, stringRes(R.string.edit_article)) {
nav.nav { Route.NewLongFormPost(version = note.idHex) }
},
)
}
}
// Rumors are rebroadcast as their delivering gift wrap; hidden when the
// wrap is unknown (the unsigned rumor must never be published).
if (accountViewModel.canBroadcast(note)) {
add(
NoteAction(MaterialSymbols.CellTower, stringRes(R.string.broadcast)) {
accountViewModel.broadcast(note)
handlers.onDismiss()
},
)
}
}
val organize =
buildList {
if (!isPrivateRumor) {
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
add(NoteAction(MaterialSymbols.Schedule, stringRes(R.string.timestamp_pending)) { handlers.onDismiss() })
} else {
add(
NoteAction(MaterialSymbols.Schedule, stringRes(R.string.timestamp_it)) {
accountViewModel.timestamp(note)
handlers.onDismiss()
},
)
}
}
if (state.isLoggedUser && !isPrivateRumor) {
add(
NoteAction(
MaterialSymbols.PushPin,
stringRes(if (state.isPinnedNote) R.string.unpin_from_profile else R.string.pin_to_profile),
) {
if (state.isPinnedNote) {
accountViewModel.removePin(note)
} else {
accountViewModel.addPin(note)
}
handlers.onDismiss()
},
)
}
if (!isPrivateRumor) {
add(NoteAction(MaterialSymbols.Tag, stringRes(R.string.add_hashtag_label), onClick = handlers.onAddLabel))
}
// Pick exactly one curation flow per kind: music tracks go to playlists,
// emoji packs go to the emoji list, everything else gets the standard
// bookmark rows. No bookmark/playlist/emoji-list rows for private rumors:
// those lists reference the note by id, which other devices can't resolve
// from relays and public lists would leak.
when {
isPrivateRumor -> {}
note.event is MusicTrackEvent && note is AddressableNote -> {
add(
NoteAction(MaterialSymbols.AutoMirrored.PlaylistAdd, stringRes(R.string.add_to_music_playlist)) {
nav.nav(Route.AddToMusicPlaylist(note.address.toValue()))
handlers.onDismiss()
},
)
}
note.event is EmojiPackEvent -> {
val emojiText =
if (state.isEmojiPackInMyList) {
stringRes(R.string.remove_from_emoji_list)
} else {
stringRes(R.string.add_to_emoji_list)
}
add(
NoteAction(MaterialSymbols.EmojiEmotions, emojiText) {
val address = (note as AddressableNote).address
nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag))
handlers.onDismiss()
},
)
}
else -> {
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
add(
NoteAction(MaterialSymbols.BookmarkAdd, stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
handlers.onDismiss()
},
)
if (state.isPrivateBookmarkNote) {
add(
NoteAction(MaterialSymbols.LockOpen, stringRes(R.string.remove_from_private_bookmarks)) {
accountViewModel.removePrivateBookmark(note)
handlers.onDismiss()
},
)
} else {
add(
NoteAction(MaterialSymbols.Lock, stringRes(R.string.add_to_private_bookmarks)) {
accountViewModel.addPrivateBookmark(note)
handlers.onDismiss()
},
)
}
if (state.isPublicBookmarkNote) {
add(
NoteAction(MaterialSymbols.BookmarkRemove, stringRes(R.string.remove_from_public_bookmarks)) {
accountViewModel.removePublicBookmark(note)
handlers.onDismiss()
},
)
} else {
add(
NoteAction(MaterialSymbols.Bookmark, stringRes(R.string.add_to_public_bookmarks)) {
accountViewModel.addPublicBookmark(note)
handlers.onDismiss()
},
)
}
}
}
}
val moderation =
buildList {
val isThreadMuted = accountViewModel.isThreadMutedFor(note)
add(
NoteAction(
MaterialSymbols.AutoMirrored.VolumeOff,
stringRes(if (isThreadMuted) R.string.quick_action_unmute_thread else R.string.quick_action_mute_thread),
) {
if (isThreadMuted) {
accountViewModel.unmuteThread(note)
} else {
accountViewModel.muteThread(note)
}
handlers.onDismiss()
},
)
// Own messages always get a delete affordance (the surface routes private
// rumors through the gift-wrapped deletion); reporting yourself never
// makes sense, so Report is others-only.
if (state.isLoggedUser) {
add(NoteAction(MaterialSymbols.Delete, stringRes(R.string.request_deletion), isDestructive = true, onClick = handlers.onDeleteRequest))
} else {
add(NoteAction(MaterialSymbols.Report, stringRes(R.string.block_report), isDestructive = true, onClick = handlers.onReport))
}
// Concord channel moderation, gated by this account's authority over the
// message's author (both return null unless it's a Concord message this
// account may act on). Promote/demote is instant; a ban re-keys the
// community, so it defers to the surface's confirmation dialog.
val concordAdmin = accountViewModel.account.concordAdminTarget(note)
if (concordAdmin != null) {
val isAdmin = concordAdmin.third
add(
NoteAction(
MaterialSymbols.Shield,
stringRes(if (isAdmin) R.string.concord_remove_admin else R.string.concord_make_admin),
) {
accountViewModel.toggleConcordAdmin(note)
handlers.onDismiss()
},
)
}
if (handlers.onConcordBan != null && accountViewModel.account.concordBanTarget(note) != null) {
add(NoteAction(MaterialSymbols.Gavel, stringRes(R.string.concord_ban_user), isDestructive = true, onClick = handlers.onConcordBan))
}
}
return listOf(author, copyAndShare, editAndBroadcast, organize, moderation).filter { it.isNotEmpty() }
}
/**
* Confirmation for a Concord ban — banning re-keys the community for every remaining
* member (CORD-06 refounding), so it's a heavier action than a mute and gets its own
* dialog. Shared by both action surfaces (sheet + 3-dot menu). [onClose] dismisses the
* dialog; [onBanned] fires after the ban is issued (so the host can also close itself).
*/
@Composable
fun ConcordBanConfirmationDialog(
note: Note,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
onBanned: () -> Unit,
) {
QuickActionAlertDialogOneButton(
title = stringRes(R.string.concord_ban_user_title),
textContent = stringRes(R.string.concord_ban_user_body),
buttonIcon = MaterialSymbols.Gavel,
buttonText = stringRes(R.string.concord_ban_user),
buttonColors =
ButtonDefaults.buttonColors(
containerColor = LightRedColor,
contentColor = Color.White,
),
onClickDoOnce = {
accountViewModel.banConcordMember(note)
onBanned()
},
onDismiss = onClose,
)
}
@@ -89,6 +89,9 @@ fun ToggleableTimeAgoText(
fontSize: TextUnit = TextUnit.Unspecified,
maxLines: Int = 1,
overflow: TextOverflow = TextOverflow.Clip,
// When false, the text is not itself clickable, so an enclosing tap target (e.g. the
// chat timestamp opening the relay/delivery dialog) receives the tap instead.
toggleable: Boolean = true,
) {
val context = LocalContext.current
val nowState = LocalNowSeconds.current
@@ -125,10 +128,14 @@ fun ToggleableTimeAgoText(
maxLines = maxLines,
overflow = overflow,
modifier =
modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute },
if (toggleable) {
modifier.clickable(
interactionSource = interactionSource,
indication = null,
) { showAbsolute = !showAbsolute }
} else {
modifier
},
)
}
@@ -58,6 +58,13 @@ fun RenderChat(
) {
val noteEvent = note.event ?: return
val eventContent = remember(noteEvent) { noteEvent.content }
// Content actually handed to the media renderer. The shared renderer only shows media whose URL
// appears in the text, but some NIP-C7/Concord clients (e.g. Ditto/Soapbox Armada) attach an
// image purely as a NIP-92 `imeta` tag and leave the content empty. Append any imeta URL missing
// from the content so those attachments render — symmetric to ChannelChat.imageMessage, which
// appends the URL on send. Encrypted-blob key/nonce are already registered by URL, so the shared
// pipeline fetches and decrypts them transparently.
val displayContent = remember(noteEvent) { appendMissingImetaUrls(noteEvent.content, noteEvent) }
// A boosted note inside a zap/nutzap/onchain activity card is always shown as a
// compact 2-line preview, even when the logged-in user is only a zap-split
@@ -101,7 +108,7 @@ fun RenderChat(
remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent,
content = displayContent,
canPreview = canPreview && !makeItShort,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth(),
@@ -0,0 +1,46 @@
/*
* 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 com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip92IMeta.imetas
/**
* When [content] is blank, returns [event]'s NIP-92 `imeta` URLs joined one per line so an attachment
* carried only as an `imeta` tag (empty content) still renders. The shared media renderer only shows
* media whose URL appears in the text, but some NIP-C7/Concord clients (e.g. Ditto/Soapbox Armada)
* attach an image purely as an `imeta` tag and leave the content empty — symmetric to
* [com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat.imageMessage], which appends the URL
* on send. Encrypted-blob key/nonce are already registered by URL, so the shared pipeline fetches and
* decrypts them transparently.
*
* Returns [content] unchanged whenever it is non-blank (the common case) or [event] is null / carries
* no imeta URLs, so any message that already has text is untouched.
*/
fun appendMissingImetaUrls(
content: String,
event: Event?,
): String {
if (event == null || content.isNotBlank()) return content
val urls = event.imetas().map { it.url }.filter { it.isNotBlank() }
if (urls.isEmpty()) return content
return urls.joinToString("\n")
}
@@ -193,6 +193,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -596,6 +597,17 @@ class AccountViewModel(
reactToOrDelete(note, reaction)
}
/**
* Retracts the user's own private rumor (e.g. a NIP-17 DM message) with a
* gift-wrapped NIP-09 deletion to the same participants. A public deletion
* would e-tag the private rumor id onto public relays.
*/
fun deletePrivately(note: Note) {
launchSigner {
account.deletePrivately(listOf(note), note)
}
}
/** Ban the author of a Concord channel message (no-op unless this account may ban them). */
fun banConcordMember(note: Note) {
val (communityId, member) = account.concordBanTarget(note) ?: return
@@ -1038,6 +1050,27 @@ class AccountViewModel(
}
}
// Notes the user is currently zapping. A zap receipt only reaches relays a moment
// after the payment clears, so a surface (the chat bubble) can show an optimistic
// "zapping" indicator in that gap. Cleared when the receipt's amount shows, on error,
// when the payment hands off to an external wallet, or by a safety timeout.
private val zapsInFlight = MutableStateFlow<Set<HexKey>>(emptySet())
val zapsInFlightFlow: StateFlow<Set<HexKey>> get() = zapsInFlight
fun markZapInFlight(noteId: HexKey) {
zapsInFlight.value = zapsInFlight.value + noteId
viewModelScope.launch {
// Safety net: clear even if no receipt ever arrives (silent failure, or a
// receipt that never propagates back to us).
delay(45_000L)
endZapInFlight(noteId)
}
}
fun endZapInFlight(noteId: HexKey) {
zapsInFlight.value = zapsInFlight.value - noteId
}
fun zap(
note: Note,
amountInMillisats: Long,
@@ -299,7 +299,9 @@ class GiftWrapEventHandler(
// the payload opens with a derived plane key, not our identity — so route
// them to the Concord read-path first. A recognized wrap is fully handled
// there (folded / re-projected) and must not fall through to the DM path.
if (account.concordSessions.ingest(event)) {
// Pass the wrap note's seen-on relays so the Concord read-path can stamp them onto the
// decrypted inner rumor (the rumor otherwise arrives with no per-relay attribution).
if (account.concordSessions.ingest(event, eventNote.relays.toSet())) {
// Concord typing heartbeats ride kind-21059 ephemeral wraps that arrive
// continuously while anyone in any joined community is composing. NIP-01
// ephemeral events (20000–29999) must never be persisted; the session has
@@ -0,0 +1,393 @@
/*
* 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.feed
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.platform.LocalContext
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.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDelivery
import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.RecipientDelivery
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAbsolute
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.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* The timestamp on a chat message, as a single tap target that opens the relay /
* delivery detail — "where did this message come from" and, for our own messages, how
* far it got. It's always tappable so the relay list is one tap away on every message.
*
* Own messages additionally render the relay-acceptance tick glyph next to the time when
* we have delivery data:
* - clock: published, no relay has accepted yet
* - single check: accepted somewhere (at least one relay OK / seen-on relay)
* - double check (green): every recipient's / target relay accepted
*
* Old messages we didn't track this session simply show no tick, but the time still
* opens the dialog (which lists the relays it was seen on, if any).
*/
@Composable
fun ChatTimeWithDelivery(
baseNote: Note,
isLoggedInUser: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val delivery by
remember(baseNote) { accountViewModel.account.chatDeliveryTracker.deliveryFlow(baseNote.idHex) }
.collectAsStateWithLifecycle()
val seenOnState by
remember(baseNote) { baseNote.flow().relays.stateFlow }
.collectAsStateWithLifecycle()
val seenOnRelays = seenOnState.note.relays
val seenSomewhere = seenOnRelays.isNotEmpty()
var showDetails by remember { mutableStateOf(false) }
ClickableBox(onClick = { showDetails = true }) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp),
) {
ChatTimeAgo(baseNote)
if (isLoggedInUser && (delivery != null || seenSomewhere)) {
Spacer(StdHorzSpacer)
RenderDeliveryTicks(delivery, seenSomewhere)
}
}
}
if (showDetails) {
ChatDeliveryDetailDialog(
baseNote = baseNote,
delivery = delivery,
seenOnRelays = seenOnRelays,
onDismiss = { showDetails = false },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
/**
* Opens the relay / delivery detail for [baseNote] from anywhere (e.g. the long-press
* action sheet), independent of the timestamp affordance — pulls our own delivery
* tracking when present and always shows the relays the message was seen on.
*/
@Composable
fun ChatMessageDeliveryDialog(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
val delivery by
remember(baseNote) { accountViewModel.account.chatDeliveryTracker.deliveryFlow(baseNote.idHex) }
.collectAsStateWithLifecycle()
val seenOnState by
remember(baseNote) { baseNote.flow().relays.stateFlow }
.collectAsStateWithLifecycle()
ChatDeliveryDetailDialog(
baseNote = baseNote,
delivery = delivery,
seenOnRelays = seenOnState.note.relays,
onDismiss = onDismiss,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* Per-recipient (DMs) or per-relay (rooms) acceptance detail behind the tick,
* with a re-broadcast escape hatch for messages stuck on pending relays.
*/
@Composable
private fun ChatDeliveryDetailDialog(
baseNote: Note,
delivery: ChatDelivery?,
seenOnRelays: List<NormalizedRelayUrl>,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.chat_delivery_details_title)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Absolute timestamp header (the chat time no longer toggles to absolute
// on tap — it opens this dialog instead).
val context = LocalContext.current
Text(
text = timeAbsolute(baseNote.createdAt(), context, prefix = "").trim(),
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP,
)
// Encrypted-channel messages (Concord/Armada) are decrypted locally with no
// per-relay attribution, so seen-on is empty — fall back to the channel's own
// relays as "where this message lives".
val channelRelays =
remember(baseNote) {
baseNote.inGatherers?.firstNotNullOfOrNull { (it as? Channel)?.relays()?.takeIf { r -> r.isNotEmpty() } } ?: emptySet()
}
val recipients = delivery?.recipients
when {
!recipients.isNullOrEmpty() ->
recipients.forEach { recipient ->
RecipientDeliveryRow(recipient, accountViewModel, nav)
}
delivery != null && delivery.targetRelays.isNotEmpty() ->
delivery.targetRelays.sortedBy { it.url }.forEach { relay ->
RelayDeliveryRow(
relay = relay,
accepted = relay in delivery.acceptedRelays || relay in seenOnRelays,
)
}
seenOnRelays.isNotEmpty() ->
// Untracked (sent before a restart): only the seen-on set is known.
seenOnRelays.sortedBy { it.url }.forEach { relay ->
RelayDeliveryRow(relay = relay, accepted = true)
}
channelRelays.isNotEmpty() ->
channelRelays.sortedBy { it.url }.forEach { relay ->
RelayDeliveryRow(relay = relay, accepted = true)
}
else ->
// Nothing to list: our own just-sent message no relay has acknowledged
// yet (pending), or a received/old message with no recorded relays.
Text(
text =
stringRes(
if (delivery != null) {
R.string.chat_delivery_pending
} else {
R.string.chat_delivery_no_relay_info
},
),
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
},
confirmButton = {
if (accountViewModel.canBroadcast(baseNote)) {
TextButton(
onClick = {
accountViewModel.broadcast(baseNote)
onDismiss()
},
) {
Text(stringRes(R.string.broadcast))
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.close))
}
},
)
}
@Composable
private fun RecipientDeliveryRow(
recipient: RecipientDelivery,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
LoadUser(baseUserHex = recipient.recipient, accountViewModel = accountViewModel) { user ->
if (user != null) {
UserPicture(user, Size20dp, Modifier, accountViewModel, nav)
Row(modifier = Modifier.weight(1f)) {
UsernameDisplay(baseUser = user, accountViewModel = accountViewModel)
}
} else {
Text(text = recipient.recipient.take(8), modifier = Modifier.weight(1f), maxLines = 1)
}
}
DeliveryStatusTick(recipient.isDelivered)
}
}
@Composable
private fun RelayDeliveryRow(
relay: NormalizedRelayUrl,
accepted: Boolean,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = relay.displayUrl(),
modifier = Modifier.weight(1f),
maxLines = 1,
)
DeliveryStatusTick(accepted)
}
}
@Composable
private fun DeliveryStatusTick(delivered: Boolean) {
if (delivered) {
TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, MaterialTheme.colorScheme.allGoodColor)
} else {
TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText)
}
}
@Composable
private fun RenderDeliveryTicks(
delivery: ChatDelivery?,
seenSomewhere: Boolean,
) {
val pendingColor = MaterialTheme.colorScheme.placeholderText
val deliveredColor = MaterialTheme.colorScheme.allGoodColor
if (delivery == null) {
// Untracked (sent before a restart): the seen-on relay set is the only signal.
if (seenSomewhere) {
TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, pendingColor)
} else {
TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, pendingColor)
}
return
}
// "Delivered to everyone" and the k/n count describe the OTHER participants;
// the sender's own self-copy wrap only shows in the detail dialog.
val others = delivery.otherRecipients
if (others != null && others.size > 1) {
// Group DM: double check once everyone got it, plus a delivered count.
val deliveredCount = others.count { it.isDelivered }
Row(verticalAlignment = Alignment.CenterVertically) {
DeliveryLadderTick(
pending = deliveredCount == 0 && !seenSomewhere,
fullyAccepted = delivery.isFullyAccepted,
)
Text(
text = "$deliveredCount/${others.size}",
fontSize = Font12SP,
color = if (delivery.isFullyAccepted) deliveredColor else pendingColor,
maxLines = 1,
)
}
return
}
// 1:1 DMs and public rooms share the classic tick ladder.
val acceptedSomewhere = seenSomewhere || delivery.acceptedRelays.isNotEmpty()
DeliveryLadderTick(
pending = !acceptedSomewhere,
fullyAccepted = delivery.isFullyAccepted,
)
}
/** The shared pending -> accepted-somewhere -> fully-accepted tick selection. */
@Composable
private fun DeliveryLadderTick(
pending: Boolean,
fullyAccepted: Boolean,
) {
when {
pending ->
TickIcon(MaterialSymbols.Schedule, R.string.chat_delivery_pending, MaterialTheme.colorScheme.placeholderText)
fullyAccepted ->
TickIcon(MaterialSymbols.DoneAll, R.string.chat_delivery_delivered_all, MaterialTheme.colorScheme.allGoodColor)
else ->
TickIcon(MaterialSymbols.Done, R.string.chat_delivery_accepted, MaterialTheme.colorScheme.placeholderText)
}
}
@Composable
private fun TickIcon(
symbol: MaterialSymbol,
contentDescription: Int,
tint: Color,
) {
Icon(
symbol = symbol,
contentDescription = stringRes(contentDescription),
modifier = Modifier.size(14.dp),
tint = tint,
)
}
@@ -0,0 +1,232 @@
/*
* 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.feed
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
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.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.grayText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Immutable
private data class ReactionEntry(
val user: User,
val reactionType: String,
// The kind-7 reaction note itself, so the row can open it in its own thread
// view (replies and reactions TO the reaction).
val reactionNote: Note,
)
/**
* Who engaged with a chat message: every zapper with their amount and comment,
* then every reactor with the emoji they sent. Opened from the bubble's
* engagement chips (long-press a reaction chip, tap the sats chip). Rows navigate
* to the user's profile.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatEngagementDetailSheet(
baseNote: Note,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val reactionsState by observeNoteReactions(baseNote, accountViewModel)
val reactionEntries =
remember(reactionsState) {
buildReactionEntries(reactionsState?.note ?: baseNote)
}
val zaps by
produceState<ImmutableList<ZapAmountCommentNotification>>(initialValue = persistentListOf(), baseNote) {
accountViewModel.decryptAmountMessageInGroup(baseNote) { value = it }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier =
Modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
zaps.forEach { zap ->
ZapperRow(zap, accountViewModel, nav, onDismiss)
}
reactionEntries.forEach { entry ->
ReactorRow(entry, accountViewModel, nav, onDismiss)
}
Spacer(Modifier.height(24.dp))
}
}
}
private fun buildReactionEntries(note: Note): ImmutableList<ReactionEntry> =
note.reactions
.toList()
.sortedByDescending { it.second.size }
.flatMap { (type, reactionNotes) ->
reactionNotes.mapNotNull { reactionNote ->
reactionNote.author?.let { ReactionEntry(it, type, reactionNote) }
}
}.toImmutableList()
@Composable
private fun ZapperRow(
zap: ZapAmountCommentNotification,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
EngagementRow(
user = zap.user,
// The zap receipt's own thread view (replies to the zap itself);
// falls back to the zapper's profile while the receipt is unknown.
eventNote = zap.zapNote,
accountViewModel = accountViewModel,
nav = nav,
onDismiss = onDismiss,
) {
zap.amount?.let { amount ->
Text(
text = "⚡ $amount",
color = BitcoinOrange,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
zap.comment?.takeIf { it.isNotBlank() }?.let { comment ->
Text(
text = comment,
fontSize = Font12SP,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(start = 33.dp, top = 2.dp),
)
}
}
}
@Composable
private fun ReactorRow(
entry: ReactionEntry,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
) {
EngagementRow(
user = entry.user,
eventNote = entry.reactionNote,
accountViewModel = accountViewModel,
nav = nav,
onDismiss = onDismiss,
) {
ChipReactionGlyph(entry.reactionType)
}
}
/**
* Shared engagement-list row: avatar + name + a trailing element. The row opens
* the engagement event's own thread view (replies/reactions TO the zap or
* reaction), falling back to the user's profile; the avatar keeps its built-in
* one-tap profile navigation.
*/
@Composable
private fun EngagementRow(
user: User?,
eventNote: Note?,
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
trailing: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier =
Modifier
.fillMaxWidth()
.clickable {
val destination =
eventNote?.let { routeFor(it, accountViewModel.account) }
?: user?.let { routeFor(it) }
destination?.let {
nav.nav(it)
onDismiss()
}
},
) {
if (user != null) {
UserPicture(user, Size25dp, Modifier, accountViewModel, nav)
Row(modifier = Modifier.weight(1f)) {
UsernameDisplay(baseUser = user, accountViewModel = accountViewModel)
}
} else {
Spacer(Modifier.weight(1f))
}
trailing()
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
@@ -44,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.watchChatGroupPosition
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import kotlinx.coroutines.launch
@@ -187,30 +189,48 @@ fun ChatFeedLoaded(
itemsIndexed(items.list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }) { index, item ->
val noteEvent = item.event
if (avoidDraft == null || noteEvent !is DraftWrapEvent || noteEvent.dTag() !in avoidDraft.usedDraftTags) {
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
)
// Reverse layout: index - 1 is the newer message (visually below),
// index + 1 the older one (visually above).
val newer = items.list.getOrNull(index - 1)
val older = items.list.getOrNull(index + 1)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
// Send/arrival motion: new items fade in and existing ones slide to
// make room, so a sent message enters instead of appearing.
val itemModifier =
if (accountViewModel.settings.isPerformanceMode()) {
Modifier
} else {
Modifier.animateItem()
}
// Per-relay paging markers belonging in the gap toward the next-older message. With the
// reverse layout this draws just above the message (the older side), so a relay's marker
// appears right below the oldest message it has reached and slides down as it pages.
markersInGap?.invoke(
item.event?.createdAt,
items.list
.getOrNull(index + 1)
?.event
?.createdAt,
)
Column(modifier = itemModifier) {
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
shouldHighlight = highlightedNoteId.value == item.idHex,
onHighlightFinished = { highlightedNoteId.value = null },
groupPosition = watchChatGroupPosition(newer, item, older),
previousNoteId = older?.idHex,
)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
// Per-relay paging markers belonging in the gap toward the next-older message. With the
// reverse layout this draws just above the message (the older side), so a relay's marker
// appears right below the oldest message it has reached and slides down as it pages.
markersInGap?.invoke(
item.event?.createdAt,
items.list
.getOrNull(index + 1)
?.event
?.createdAt,
)
}
}
}
@@ -0,0 +1,602 @@
/*
* 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.feed
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.draw.clip
import androidx.compose.ui.platform.LocalContext
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
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ChangeReactionIcon
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.RenderReaction
import com.vitorpamplona.amethyst.ui.note.ZapAmountChoiceGrid
import com.vitorpamplona.amethyst.ui.note.elements.AddHashtagLabelDialog
import com.vitorpamplona.amethyst.ui.note.elements.ConcordBanConfirmationDialog
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
import com.vitorpamplona.amethyst.ui.note.elements.NoteActionHandlers
import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet
import com.vitorpamplona.amethyst.ui.note.elements.noteActionSections
import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount
import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability
import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size28Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallishBorder
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlin.uuid.ExperimentalUuidApi
// null amount = open the on-chain dialog with no prefill.
@Immutable
private data class OnchainZapRequest(
val amountSats: Long?,
)
/**
* Long-press surface for a chat message: a quick-reaction row and the unpacked
* zap amount presets on top, chat-only tiles (reply, edit draft), then the shared
* note-action inventory (noteActionSections — the same one behind the 3-dot menu,
* so the two surfaces can never drift) rendered as rows of icon tiles.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatMessageActionSheet(
note: Note,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
var showShareSheet by remember { mutableStateOf(false) }
var wantsToEditPost by remember { mutableStateOf(false) }
var reportDialogShowing by remember { mutableStateOf(false) }
var addLabelDialogShowing by remember { mutableStateOf(false) }
var deleteConfirmationShowing by remember { mutableStateOf(false) }
var concordBanConfirming by remember { mutableStateOf(false) }
var showDeliveryDialog by remember { mutableStateOf(false) }
// Own private rumors (NIP-17 DMs) must be retracted with a gift-wrapped
// deletion — a public NIP-09 would e-tag the rumor id onto public relays.
val performDelete = {
if (note.isPrivateRumor()) {
accountViewModel.deletePrivately(note)
} else {
accountViewModel.delete(note)
}
}
if (deleteConfirmationShowing) {
QuickActionAlertDialog(
title = stringRes(R.string.quick_action_request_deletion_alert_title),
textContent = stringRes(R.string.quick_action_request_deletion_alert_body),
buttonIcon = MaterialSymbols.Delete,
buttonText = stringRes(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = {
performDelete()
onDismiss()
},
onClickDontShowAgain = {
performDelete()
accountViewModel.account.settings.setHideDeleteRequestDialog()
onDismiss()
},
onDismiss = { deleteConfirmationShowing = false },
)
}
// On-chain zaps need a dialog that outlives the sheet, so the request swaps
// the sheet for the dialog. Share works the same way (see NoteDropDownMenu).
var onchainZapRequest by remember { mutableStateOf<OnchainZapRequest?>(null) }
onchainZapRequest?.let { request ->
val zappedEventHint = remember(note) { note.toEventHint<Event>() }
OnchainZapSendDialog(
accountViewModel = accountViewModel,
onDismiss = onDismiss,
recipientPubKey = note.author?.pubkeyHex,
zappedEvent = zappedEventHint,
prefillAmountSats = request.amountSats,
)
return
}
if (showShareSheet) {
ShareOptionsBottomSheet(
note = note,
nav = nav,
onDismiss = onDismiss,
)
return
}
if (showDeliveryDialog) {
ChatMessageDeliveryDialog(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
onDismiss = onDismiss,
)
return
}
if (wantsToEditPost) {
EditPostView(
onClose = onDismiss,
edit = note,
versionLookingAt = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
if (reportDialogShowing) {
ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
reportDialogShowing = false
onDismiss()
}
}
if (addLabelDialogShowing) {
AddHashtagLabelDialog(
note = note,
accountViewModel = accountViewModel,
onDismiss = {
addLabelDialogShowing = false
onDismiss()
},
)
}
if (concordBanConfirming) {
ConcordBanConfirmationDialog(
note = note,
accountViewModel = accountViewModel,
onClose = { concordBanConfirming = false },
onBanned = onDismiss,
)
}
val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle(
DropDownParams(
isFollowingAuthor = false,
isPrivateBookmarkNote = false,
isPublicBookmarkNote = false,
isPinnedNote = false,
isLoggedUser = false,
isSensitive = false,
showSensitiveContent = null,
),
)
var showAllActions by remember { mutableStateOf(false) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
if (!note.isDraft()) {
QuickReactionRow(note, onDismiss, accountViewModel, nav)
QuickZapAmountRow(
note = note,
onDismiss = onDismiss,
onOnchainRequest = { onchainZapRequest = OnchainZapRequest(it) },
accountViewModel = accountViewModel,
nav = nav,
)
SectionDivider()
}
// Stage one: the primary chat action (reply / edit draft) is always shown.
ChatOnlyRow(note, state, onWantsToReply, onWantsToEditDraft, onDismiss)
val handlers =
NoteActionHandlers(
onShare = { showShareSheet = true },
onEditPost = { wantsToEditPost = true },
onEditDraft = {
onWantsToEditDraft(note)
onDismiss()
},
onAddLabel = { addLabelDialogShowing = true },
onReport = { reportDialogShowing = true },
onDeleteRequest = {
if (accountViewModel.account.settings.hideDeleteRequestDialog) {
performDelete()
onDismiss()
} else {
deleteConfirmationShowing = true
}
},
onConcordBan = { concordBanConfirming = true },
onDismiss = onDismiss,
)
// Stage two: the full shared note-action inventory (the same one behind the
// 3-dot menu, so the two surfaces never drift). Tucked behind a toggle so the
// sheet opens compact — react, zap, reply — and only grows to full height
// when the user asks for the rest.
val sections =
noteActionSections(
note = note,
noteVersionToCopy = note,
state = state,
handlers = handlers,
accountViewModel = accountViewModel,
nav = nav,
)
if (sections.isNotEmpty()) {
SectionDivider()
MoreActionsToggle(expanded = showAllActions, onToggle = { showAllActions = !showAllActions })
if (showAllActions) {
sections.forEach { section ->
SectionDivider()
TileRow {
section.forEach { action ->
ActionTile(action.symbol, action.label, action.isDestructive, action.onClick)
}
}
}
// Chat-specific: which relays carry this message (and, for our own,
// their acceptance). A secondary action, so it lives under Show More.
if (!note.isDraft()) {
SectionDivider()
TileRow {
ActionTile(MaterialSymbols.DoneAll, stringRes(R.string.chat_delivery_details_title)) {
showDeliveryDialog = true
}
}
}
}
}
Spacer(Modifier.height(24.dp))
}
}
}
/**
* The expand/collapse control between the always-visible quick actions and the full
* action inventory. Keeps the sheet short on open (the common case is a reaction, a
* zap, or a reply) and reveals everything else on demand.
*/
@Composable
private fun MoreActionsToggle(
expanded: Boolean,
onToggle: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(SmallishBorder)
.clickable(onClick = onToggle)
.padding(vertical = 12.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(if (expanded) R.string.show_less else R.string.more_options),
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(4.dp))
Icon(
symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// ---------- Action tile sections ----------
/** Chat-specific tiles (reply, edit draft) that have no 3-dot menu equivalent. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ChatOnlyRow(
note: Note,
state: DropDownParams,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onDismiss: () -> Unit,
) {
if (note.isDraft() && !state.isLoggedUser) return
if (note.isDraft() && state.isLoggedUser) {
TileRow {
ActionTile(MaterialSymbols.Edit, stringRes(R.string.edit_draft)) {
onWantsToEditDraft(note)
onDismiss()
}
}
return
}
TileRow {
ActionTile(MaterialSymbols.AutoMirrored.Chat, stringRes(R.string.reply_description)) {
onWantsToReply(note)
onDismiss()
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TileRow(content: @Composable () -> Unit) {
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
content()
}
}
@Composable
private fun ActionTile(
symbol: MaterialSymbol,
label: String,
isDestructive: Boolean = false,
onClick: () -> Unit,
) {
val tint = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant
Column(
modifier =
Modifier
.width(80.dp)
.clip(SmallishBorder)
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = tint,
)
Text(
text = label,
fontSize = 11.sp,
lineHeight = 13.sp,
color = tint,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
private const val SECTION_DIVIDER_ALPHA = 0.5f
@Composable
private fun SectionDivider() {
HorizontalDivider(
thickness = DividerThickness,
color = MaterialTheme.colorScheme.placeholderText.copy(alpha = SECTION_DIVIDER_ALPHA),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
// ---------- Quick reaction + zap rows ----------
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun QuickReactionRow(
note: Note,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val reactions by accountViewModel.reactionChoicesFlow().collectAsStateWithLifecycle()
val myReactions =
remember(note) {
note.allReactionsByAuthor(accountViewModel.userProfile()).toImmutableSet()
}
// Wrap onto as many rows as needed (like the zap grid) rather than a single
// horizontally-scrolling strip, so long custom-reaction sets stay reachable.
FlowRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
reactions.forEach { reactionType ->
ClickableBox(
modifier = if (reactionType in myReactions) MaterialTheme.colorScheme.selectedReactionBoxModifier else reactionBox,
onClick = {
accountViewModel.reactToOrDelete(note, reactionType)
onDismiss()
},
) {
RenderReaction(reactionType)
}
}
ClickableBox(
modifier = reactionBox,
onClick = {
nav.nav(Route.UpdateReactionType)
onDismiss()
},
) {
ChangeReactionIcon(Size28Modifier, MaterialTheme.colorScheme.placeholderText)
}
}
}
/**
* The user's zap presets unpacked as rail-aware amount chips (same grid the zap
* amount popup shows), firing directly from the sheet. Lightning/cashu zaps
* dismiss immediately — errors surface as toasts and the receipt lands on the
* bubble's sats chip; on-chain amounts hand off to the dialog hosted by the sheet.
*/
@OptIn(ExperimentalUuidApi::class)
@Composable
private fun QuickZapAmountRow(
note: Note,
onDismiss: () -> Unit,
onOnchainRequest: (Long?) -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val zapAmountChoices by
accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices
.collectAsStateWithLifecycle()
val amountChoices = remember(zapAmountChoices) { zapAmountChoices.distinct().toImmutableList() }
if (amountChoices.isEmpty()) return
val railCapability =
observeZapRailCapability(
baseNote = note,
accountViewModel = accountViewModel,
// Onchain zap events are public and would e-tag the rumor id.
onchainSupported = !note.isPrivateRumor(),
)
val context = LocalContext.current
val onError = { _: String, message: String, user: User? ->
// Payment failed — drop the optimistic "zapping" indicator on the bubble.
accountViewModel.endZapInFlight(note.idHex)
accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user)
}
val onPayViaIntent = { payables: ImmutableList<ZapPaymentHandler.Payable> ->
// Handoff to an external wallet: we can't observe whether it completes, so clear
// the optimistic indicator rather than leave it spinning forever.
accountViewModel.endZapInFlight(note.idHex)
payViaIntentOrManualSplit(payables, context, accountViewModel, nav)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.Center,
) {
ZapAmountChoiceGrid(
amountChoices = amountChoices,
railCapability = railCapability,
onLightningZap = { amountInSats ->
// Show a pending zap chip on the bubble immediately; it settles into the
// real sats chip once the receipt lands (or clears on error/timeout).
accountViewModel.markZapInFlight(note.idHex)
accountViewModel.zap(
note,
amountInSats * 1000,
null,
"",
context,
true,
onError,
{ },
onPayViaIntent,
)
onDismiss()
},
onNutzap = { amountInSats ->
accountViewModel.markZapInFlight(note.idHex)
accountViewModel.sendNutzap(
baseNote = note,
amountSats = amountInSats,
message = "",
onError = onError,
onProgress = { },
)
onDismiss()
},
onOnchainAmount = onOnchainRequest,
onReloadNutzap = { amount ->
navigateToReloadMint(accountViewModel, nav, note, amount)
onDismiss()
},
onChangeAmount = {
nav.nav(Route.UpdateZapAmount())
onDismiss()
},
)
}
}
@@ -21,19 +21,13 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -45,60 +39,37 @@ 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.Alignment.Companion.CenterStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount
import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.DisplayDraftChat
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu
import com.vitorpamplona.amethyst.ui.note.RelayBadgesHorizontal
import com.vitorpamplona.amethyst.ui.note.RenderZapRaiser
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.WatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatBubbleLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChangeChannelMetadataNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.ChatGroupPosition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChannelAdminSystemMessage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatRaid
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderCreateChannelNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderDraftEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderEncryptedFile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderMarmotEncryptedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderRegularTextNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasMip04Media
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.IncognitoBadge
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.ReactionRowZapraiser
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
@@ -110,7 +81,6 @@ import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as MaterialSymbolIcon
@Composable
fun ChatroomMessageCompose(
@@ -125,6 +95,11 @@ fun ChatroomMessageCompose(
onScrollToNote: ((Note) -> Unit)? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
// The id of the message rendered directly above this one in the feed. A reply
// quote that targets it is redundant (the reader can see the parent right
// there), so the reply row is skipped.
previousNoteId: HexKey? = null,
// Replaces the generic "post not found" blank while baseNote's event hasn't loaded. Used for
// reply quotes inside a DM, where the target is simply older than the loaded window (see
// LoadingReplyNote). Null keeps the default blank for every other caller.
@@ -152,6 +127,8 @@ fun ChatroomMessageCompose(
RenderChatRaid(baseNote, accountViewModel, nav)
} else if (event is LiveActivitiesClipEvent) {
RenderChatClip(baseNote, accountViewModel, nav)
} else if (event is ChannelCreateEvent || event is ChannelMetadataEvent) {
RenderChannelAdminSystemMessage(baseNote, accountViewModel, nav)
} else {
NormalChatNote(
baseNote,
@@ -166,6 +143,8 @@ fun ChatroomMessageCompose(
onScrollToNote,
shouldHighlight,
onHighlightFinished,
groupPosition,
previousNoteId,
)
}
}
@@ -194,6 +173,8 @@ fun NormalChatNote(
onScrollToNote: ((Note) -> Unit)? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
previousNoteId: HexKey? = null,
) {
val isLoggedInUser =
remember(note.author) {
@@ -225,41 +206,104 @@ fun NormalChatNote(
}
}
// Emoji-only messages render as bare jumbo emoji, no bubble fill. Ciphertext
// never passes the emoji-only check, so the cheap synchronous read decides
// most cases; the decrypt callback updates the flag when an encrypted
// message's plaintext arrives after first composition, keeping the bubble
// transparency in agreement with the jumbo text rendering downstream.
var isJumboEmoji by
remember(note.event?.id) {
val noteEvent = note.event
val content =
if (noteEvent is DraftWrapEvent) {
null
} else {
accountViewModel.cachedDecrypt(note) ?: noteEvent?.content
}
mutableStateOf(content != null && jumboEmojiCount(content) > 0)
}
if (note.event !is DraftWrapEvent) {
LaunchedEffect(note.event?.id) {
accountViewModel.decrypt(note) { content ->
val jumbo = jumboEmojiCount(content) > 0
if (jumbo != isJumboEmoji) {
isJumboEmoji = jumbo
}
}
}
}
// The footer shows on the last message of a run (for the time) and on any message
// carrying per-message metadata (expiration, geohash, PoW, legacy-DM marker).
val footerHasMeta = remember(note.event) { chatFooterHasMeta(note) }
ChatBubbleLayout(
isLoggedInUser = isLoggedInUser,
isDraft = note.event is DraftWrapEvent,
innerQuote = innerQuote,
isComplete = accountViewModel.settings.isCompleteUIMode(),
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
drawAuthorInfo = drawAuthorInfo,
drawAuthorInfo = drawAuthorInfo && groupPosition.isFirstOfGroup,
groupPosition = groupPosition,
transparentBubble = isJumboEmoji,
parentBackgroundColor = parentBackgroundColor,
shouldHighlight = shouldHighlight,
onHighlightFinished = onHighlightFinished,
onClick = {
if (note.event is ChannelCreateEvent) {
nav.nav(Route.PublicChatChannel(note.idHex))
true
} else if (innerQuote && onScrollToNote != null) {
if (innerQuote && onScrollToNote != null) {
onScrollToNote(note)
true
} else {
false
}
},
onDoubleTap = {
// Double-tap to send the default reaction; only when it can actually
// sign one (reactToOrDelete assumes a non-empty choice list).
if (!note.isDraft() && accountViewModel.isWriteable() && accountViewModel.reactionChoices().isNotEmpty()) {
accountViewModel.reactToOrDelete(note)
}
},
onSwipeReply =
if (!innerQuote && !note.isDraft()) {
{ onWantsToReply(note) }
} else {
null
},
onAuthorClick = {
note.author?.let {
nav.nav(routeFor(it))
}
},
actionMenu = { onDismiss ->
NoteQuickActionMenu(
ChatMessageActionSheet(
note = note,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onDismiss = onDismiss,
onWantsToEditDraft = { onWantsToEditDraft(note) },
accountViewModel = accountViewModel,
nav = nav,
)
},
reactionsRow =
if (!innerQuote) {
{ ChatReactionChips(note, accountViewModel, nav) }
} else {
null
},
footerRow =
if (!innerQuote && (groupPosition.isLastOfGroup || footerHasMeta)) {
{
ChatMessageFooter(
note = note,
isLoggedInUser = isLoggedInUser,
showTime = groupPosition.isLastOfGroup,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else {
null
},
drawAuthorLine = {
DrawAuthorInfo(
note,
@@ -267,55 +311,6 @@ fun NormalChatNote(
nav,
)
},
detailRow = {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat,
) {
IncognitoBadge(note)
ChatTimeAgo(note)
ChatExpiration(note)
RelayBadgesHorizontal(note, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
if (!note.isDraft()) {
ReplyReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSizeModifier = Size18Modifier,
) {
onWantsToReply(note)
}
Spacer(StdHorzSpacer)
LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
MinichatReplyChip(note, accountViewModel, nav)
val geo = remember(note) { note.event?.geoHashOrScope() }
if (geo != null) {
Spacer(StdHorzSpacer)
DisplayLocation(geo, accountViewModel, nav)
}
val pow = remember(note) { note.event?.strongPoWOrNull() }
if (pow != null) {
Spacer(StdHorzSpacer)
DisplayPoW(pow, accountViewModel)
}
} else {
DisplayDraftChat()
}
}
}
LoadAndDisplayClickableZapraiser(note, accountViewModel)
}
},
) { bgColor ->
MessageBubbleLines(
note,
@@ -327,6 +322,7 @@ fun NormalChatNote(
accountViewModel,
nav,
onScrollToNote,
previousNoteId,
)
}
}
@@ -363,6 +359,7 @@ private fun MessageBubbleLines(
accountViewModel: AccountViewModel,
nav: INav,
onScrollToNote: ((Note) -> Unit)? = null,
previousNoteId: HexKey? = null,
) {
if (baseNote.event !is DraftWrapEvent) {
RenderReplyRow(
@@ -374,6 +371,7 @@ private fun MessageBubbleLines(
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
onScrollToNote = onScrollToNote,
previousNoteId = previousNoteId,
)
}
@@ -398,56 +396,10 @@ private fun MessageBubbleLines(
Spacer(modifier = Modifier.height(2.dp))
}
}
}
}
/**
* A chip on a chat message's action row showing how many kind-1111 thread ("minichat")
* replies it has; tapping opens that thread. Shown only when there is at least one — an
* inline reply is an ordinary message and isn't counted.
*
* Only shown where minichats are actually wired: the public chats (Concord, NIP-28, NIP-29).
* NIP-17 DMs are deliberately excluded — most clients don't render kind-1111 replies in a DM
* view, so a thread there would be a dead end.
*/
@Composable
private fun MinichatReplyChip(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val supportsMinichat =
remember(note) {
note.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true
}
if (!supportsMinichat) return
val count by observeNoteMinichatReplyCount(note, accountViewModel)
if (count > 0) {
Spacer(StdHorzSpacer)
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.clickable { nav.nav(Route.ChatMinichat(note.idHex)) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
) {
MaterialSymbolIcon(
symbol = MaterialSymbols.Forum,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(13.dp),
)
Text(
text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
// Zapraiser goal bar (self-hides when the message has no zapraiser). Used to live
// in the tap-to-expand detail row; now always shown inline under the message.
LoadAndDisplayClickableZapraiser(baseNote, accountViewModel)
}
}
@@ -468,11 +420,21 @@ fun RenderReplyRow(
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
previousNoteId: HexKey? = null,
) {
val replyTo = note.replyTo?.lastOrNull()
// Suppress the reply-to preview when it would just repeat a parent the reader can already
// see: either the message rendered directly above this one in the feed (previousNoteId), or
// a parent that a thread view has pinned at the top (LocalSuppressReplyToNoteId — the
// minichat pins its root).
val suppressId = LocalSuppressReplyToNoteId.current
if (!innerQuote && replyTo != null && replyTo.idHex != suppressId && !isCitedInContent(note, replyTo)) {
RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote)
if (!innerQuote &&
replyTo != null &&
replyTo.idHex != previousNoteId &&
replyTo.idHex != suppressId &&
!isCitedInContent(note, replyTo)
) {
RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote, previousNoteId)
}
}
@@ -501,6 +463,7 @@ private fun RenderReply(
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
onScrollToNote: ((Note) -> Unit)? = null,
previousNoteId: HexKey? = null,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@Suppress("ProduceStateDoesNotAssignValue")
@@ -511,7 +474,10 @@ private fun RenderReply(
}
}
replyTo.value?.let { replyNote ->
// Unwrapping can map a gift-wrap id to its rumor, so the suppression checks
// (feed neighbour + thread-pinned root) have to be re-run on the resolved note.
val suppressId = LocalSuppressReplyToNoteId.current
replyTo.value?.takeIf { it.idHex != previousNoteId && it.idHex != suppressId }?.let { replyNote ->
// For a DM, a reply target that hasn't arrived isn't lost — it's older than the loaded
// window (and for gift wraps can't be fetched by id). Swap the generic blank for one that
// walks history backward until it surfaces. Pick the pager by the PARENT's protocol; leave
@@ -555,8 +521,6 @@ fun NoteRow(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
when {
note.event is ChannelCreateEvent -> RenderCreateChannelNote(note, bgColor, accountViewModel, nav)
note.event is ChannelMetadataEvent -> RenderChangeChannelMetadataNote(note, bgColor, accountViewModel, nav)
note.event is DraftWrapEvent -> RenderDraftEvent(note, canPreview, innerQuote, onWantsToReply, onWantsToEditDraft, bgColor, accountViewModel, nav)
note.event is ChatMessageEncryptedFileHeaderEvent -> RenderEncryptedFile(note, bgColor, accountViewModel, nav)
hasMip04Media(note.event) -> RenderMarmotEncryptedMedia(note, bgColor, accountViewModel, nav)
@@ -0,0 +1,374 @@
/*
* 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.feed
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.derivedStateOf
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.draw.clip
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
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.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactions
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LikedIcon
import com.vitorpamplona.amethyst.ui.note.ObserveZapAmountText
import com.vitorpamplona.amethyst.ui.note.ZappedIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Immutable
private data class ReactionChip(
val type: String,
val count: Int,
val includesMe: Boolean,
)
private val ChipGlyphFontSize = 13.sp
/**
* Messenger-style pills under a chat bubble summarizing who engaged with the message:
* one chip per reaction emoji (with count, highlighted when the logged-in user is
* among the reactors — tapping toggles the same reaction back) plus a sats chip when
* the message was zapped. Renders nothing when the message has no engagement.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ChatReactionChips(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Deliberate cost: this observes reactions/zaps for EVERY visible message
// (the ids fold into the batched EventFinder relay filters — one shared REQ,
// not one per note), which is exactly what ReactionsRow already does for
// every note in the main feeds. Gating on already-known engagement would be
// cheaper but would never DISCOVER reactions for messages nobody expanded.
val reactionsState by observeNoteReactions(baseNote, accountViewModel)
val chips by
remember(reactionsState) {
derivedStateOf {
val note = reactionsState?.note ?: baseNote
val me = accountViewModel.userProfile()
note.reactions
.map { (type, reactors) ->
ReactionChip(type, reactors.size, reactors.any { it.author == me })
}.sortedByDescending { it.count }
.toImmutableList()
}
}
var showDetails by remember { mutableStateOf(false) }
if (showDetails) {
ChatEngagementDetailSheet(
baseNote = baseNote,
onDismiss = { showDetails = false },
accountViewModel = accountViewModel,
nav = nav,
)
}
// Kind-1111 thread ("minichat") replies. Only public chats wire them up (Concord,
// NIP-28, NIP-29); NIP-17 DMs deliberately don't, so most clients wouldn't render
// a thread there. An inline reply is an ordinary message and isn't counted.
val supportsMinichat =
remember(baseNote) {
baseNote.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true
}
val minichatCount by if (supportsMinichat) observeNoteMinichatReplyCount(baseNote, accountViewModel) else remember { mutableStateOf(0) }
// Optimistic feedback for a zap the user just fired from the drawer: the receipt (and
// its amount) only reach relays a moment later, so show a pending zap chip until then.
// The set changes only at zap start/end, so this rarely re-runs the row.
val zapsInFlight by accountViewModel.zapsInFlightFlow.collectAsStateWithLifecycle()
val isZapping = zapsInFlight.contains(baseNote.idHex)
ObserveZapAmountText(baseNote, accountViewModel) { zapAmount ->
RenderChatReactionChips(
chips = chips,
zapAmount = zapAmount,
minichatCount = minichatCount,
// Once the receipt's amount arrives the real sats chip takes over, so the
// pending chip only shows while the amount is still blank.
isZapping = isZapping && zapAmount.isBlank(),
onToggleReaction = { accountViewModel.reactToOrDelete(baseNote, it) },
onOpenDetails = { showDetails = true },
onOpenMinichat = { nav.nav(Route.ChatMinichat(baseNote.idHex)) },
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderChatReactionChips(
chips: ImmutableList<ReactionChip>,
zapAmount: String,
minichatCount: Int,
isZapping: Boolean,
onToggleReaction: (String) -> Unit,
onOpenDetails: () -> Unit,
onOpenMinichat: () -> Unit,
) {
if (chips.isEmpty() && zapAmount.isBlank() && minichatCount <= 0 && !isZapping) return
FlowRow(
// Inset from the bubble's edge so overlapping chips ride the border without
// poking past the bubble's rounded corners.
modifier = Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (zapAmount.isNotBlank()) {
ZapChip(zapAmount, onClick = onOpenDetails)
} else if (isZapping) {
PendingZapChip()
}
chips.forEach { chip ->
ReactionChipView(
chip = chip,
onClick = { onToggleReaction(chip.type) },
onLongClick = onOpenDetails,
)
}
if (minichatCount > 0) {
MinichatChip(minichatCount, onClick = onOpenMinichat)
}
}
}
/**
* "N replies" thread entry, styled like the reaction/zap chips so the engagement row
* reads as one strip under the bubble. Tapping opens the message's minichat thread.
*/
@Composable
private fun MinichatChip(
count: Int,
onClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder),
// Plain clickable (not the Surface onClick overload) so the chip keeps its
// content height instead of being padded to the 48dp minimum touch target,
// which would drop it below the bubble border the reaction chips ride.
modifier = Modifier.clip(ButtonBorder).clickable(onClick = onClick),
) {
ChipContentRow {
Icon(
symbol = MaterialSymbols.Forum,
contentDescription = null,
tint = MaterialTheme.colorScheme.grayText,
modifier = Size14Modifier,
)
Text(
text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count),
fontSize = Font12SP,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ReactionChipView(
chip: ReactionChip,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val background =
if (chip.includesMe) {
MaterialTheme.colorScheme.primary
.copy(alpha = 0.20f)
.compositeOver(MaterialTheme.colorScheme.background)
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val border =
if (chip.includesMe) {
BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.60f))
} else {
BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder)
}
Surface(
shape = ButtonBorder,
color = background,
border = border,
modifier =
Modifier
.clip(ButtonBorder)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
ChipContentRow {
ChipReactionGlyph(chip.type)
if (chip.count > 1) {
Text(
text = chip.count.toString(),
fontSize = Font12SP,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
)
}
}
}
}
@Composable
private fun ZapChip(
amount: String,
onClick: () -> Unit,
) {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder),
// Plain clickable (not the Surface onClick overload) so the chip keeps its
// content height instead of being padded to the 48dp minimum touch target,
// which would drop it below the bubble border the reaction chips ride.
modifier = Modifier.clip(ButtonBorder).clickable(onClick = onClick),
) {
ChipContentRow {
ZappedIcon(Size14Modifier)
Text(
text = amount,
fontSize = Font12SP,
fontWeight = FontWeight.Bold,
// Match the lightning glyph's bitcoin orange (theme-aware for contrast).
color = MaterialTheme.colorScheme.bitcoinColor,
maxLines = 1,
)
}
}
}
/**
* Optimistic placeholder shown while a zap the user just fired is still being paid:
* the lightning glyph next to a small spinner. Replaced by the real sats [ZapChip]
* as soon as the receipt's amount arrives.
*/
@Composable
private fun PendingZapChip() {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.subtleBorder),
) {
ChipContentRow {
ZappedIcon(Size14Modifier)
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
@Composable
private fun ChipContentRow(content: @Composable () -> Unit) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
content()
}
}
@Composable
internal fun ChipReactionGlyph(reactionType: String) {
if (reactionType.startsWith(":")) {
val url = reactionType.removePrefix(":").substringAfter(":")
InLineIconRenderer(
persistentListOf(CustomEmoji.ImageUrlType(url)),
style = SpanStyle(color = MaterialTheme.colorScheme.onBackground),
fontSize = ChipGlyphFontSize,
maxLines = 1,
)
} else {
when (reactionType) {
"+" -> LikedIcon(Size14Modifier)
"-" -> Text(text = "👎", fontSize = ChipGlyphFontSize, maxLines = 1)
else -> {
if (EmojiCoder.isCoded(reactionType)) {
AnimatedBorderTextCornerRadius(reactionType, fontSize = ChipGlyphFontSize)
} else {
Text(text = reactionType, fontSize = ChipGlyphFontSize, maxLines = 1)
}
}
}
}
}
@@ -35,13 +35,21 @@ 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.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation
import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle
import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.IncognitoBadge
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geoHashOrScope
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip13Pow.strongPoWOrNull
import com.vitorpamplona.quartz.nip40Expiration.expiration
@Composable
@@ -51,9 +59,76 @@ fun ChatTimeAgo(baseNote: Note) {
style = TimeAgoStyle.Short,
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font12SP,
// The chat time is wrapped in a tap target that opens the relay/delivery dialog,
// so it must not steal the tap to toggle relative/absolute. The absolute time is
// shown in that dialog instead.
toggleable = false,
)
}
/**
* Whether the compact bubble footer would show anything besides the timestamp — a
* legacy-DM marker, an expiration, a geohash, or a proof-of-work badge. Lets the caller
* skip the footer entirely on the common no-metadata message (rather than emit an empty
* row) while still surfacing these per-message details that used to live in the
* tap-to-expand "complete UI" detail row.
*/
fun chatFooterHasMeta(note: Note): Boolean {
val event = note.event ?: return false
return event is PrivateDmEvent ||
event.expiration() != null ||
event.geoHashOrScope() != null ||
event.strongPoWOrNull() != null
}
/**
* The small row at the bottom of a chat bubble: inline status glyphs (legacy-DM,
* expiration, geohash, proof-of-work — each shown only when present) followed, on the
* last message of an author run ([showTime]), by the timestamp and its delivery affordance
* — relay-acceptance ticks for our own messages, a tappable "seen on relays" for received
* ones. Replaces the old tap-to-expand detail row.
*/
@Composable
fun ChatMessageFooter(
note: Note,
isLoggedInUser: Boolean,
showTime: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = note.event
val geo = remember(event) { event?.geoHashOrScope() }
val pow = remember(event) { event?.strongPoWOrNull() }
Row(verticalAlignment = Alignment.CenterVertically) {
// Each glyph self-gates and renders nothing when not applicable.
IncognitoBadge(note)
ChatExpiration(note)
if (geo != null) {
Spacer(StdHorzSpacer)
DisplayLocation(geo, accountViewModel, nav)
}
if (pow != null) {
Spacer(StdHorzSpacer)
DisplayPoW(pow, accountViewModel)
}
if (showTime) {
val hasGlyph = event is PrivateDmEvent || event?.expiration() != null || geo != null || pow != null
if (hasGlyph) Spacer(StdHorzSpacer)
// Drafts aren't published, so no relay/delivery detail; everything else gets
// the tappable timestamp that opens "where did this come from".
if (note.isDraft()) {
ChatTimeAgo(note)
} else {
ChatTimeWithDelivery(note, isLoggedInUser, accountViewModel, nav)
}
}
}
}
@Composable
fun ChatExpiration(note: Note) {
val event = note.event
@@ -20,10 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
@@ -40,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.UserDisplayNameLayout
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size5Modifier
import com.vitorpamplona.amethyst.ui.theme.isLight
@Composable
fun DrawAuthorInfo(
@@ -52,6 +56,23 @@ fun DrawAuthorInfo(
}
}
/**
* A stable, pubkey-derived name color so authors are scannable in fast-moving
* group rooms. The hue comes from the pubkey; saturation/lightness are tuned per
* theme so every hue stays readable on the "them" bubble fill.
*/
fun authorNameColorFor(
pubkeyHex: String,
isLightTheme: Boolean,
): Color {
val hue = (pubkeyHex.take(6).toIntOrNull(16) ?: pubkeyHex.hashCode()).mod(360).toFloat()
return if (isLightTheme) {
Color.hsl(hue, saturation = 0.70f, lightness = 0.35f)
} else {
Color.hsl(hue, saturation = 0.55f, lightness = 0.70f)
}
}
@Composable
private fun WatchAndDisplayUser(
author: User,
@@ -62,6 +83,12 @@ private fun WatchAndDisplayUser(
val nickname by observeUserNickname(author, accountViewModel)
val petName = nickname?.petName
val isLightTheme = MaterialTheme.colorScheme.isLight
val nameColor =
remember(author.pubkeyHex, isLightTheme) {
authorNameColorFor(author.pubkeyHex, isLightTheme)
}
UserDisplayNameLayout(
picture = {
InnerUserPicture(
@@ -86,6 +113,7 @@ private fun WatchAndDisplayUser(
CreateTextWithEmoji(
text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
color = nameColor,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
@@ -93,6 +121,7 @@ private fun WatchAndDisplayUser(
CreateTextWithEmoji(
text = author.pubkeyDisplayHex(),
tags = EmptyTagList,
color = nameColor,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
@@ -0,0 +1,139 @@
/*
* 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.feed
import com.vitorpamplona.amethyst.commons.util.codePointAtKmp
import com.vitorpamplona.amethyst.commons.util.codePointCharCount
/** A message of up to this many emoji renders as jumbo emoji without a bubble. */
const val MAX_JUMBO_EMOJI = 3
/**
* Number of emoji in [content] when the message is emoji-only (ignoring
* whitespace) and short enough to render jumbo, or 0 when it contains any
* non-emoji text or more than [MAX_JUMBO_EMOJI] emoji.
*
* Modifier code points (variation selectors, ZWJ, skin tones, keycap marks)
* don't count as separate emoji: a ZWJ family counts what it draws as, a keycap
* like 1️⃣ counts as one, and a flag needs its full regional-indicator pair —
* incomplete sequences make the message non-jumbo.
*
* Built on the KMP-safe code-point helpers so the logic can move to commonMain.
*/
fun jumboEmojiCount(content: String): Int {
var count = 0
var previousWasZwj = false
// A regional indicator waiting for its pair (flags are two RIs = one emoji).
var pendingRegionalIndicator = false
// An ASCII keycap base ('0'-'9', '#', '*') waiting for its combining keycap
// mark; only then does it count as one emoji.
var pendingKeycapBase = false
var i = 0
while (i < content.length) {
val cp = content.codePointAtKmp(i)
i += codePointCharCount(cp)
when {
isWhitespace(cp) -> {
if (pendingKeycapBase || pendingRegionalIndicator) return 0
previousWasZwj = false
}
cp == KEYCAP_MARK -> {
// Completes a keycap sequence like '1' + VS16 + U+20E3.
if (!pendingKeycapBase) return 0
pendingKeycapBase = false
count++
previousWasZwj = false
}
isEmojiModifier(cp) -> {
previousWasZwj = cp == ZWJ
}
isKeycapBase(cp) -> {
// A bare digit is only an emoji when its keycap mark follows.
if (pendingKeycapBase || pendingRegionalIndicator) return 0
pendingKeycapBase = true
previousWasZwj = false
}
isRegionalIndicator(cp) -> {
if (pendingKeycapBase) return 0
// Two regional indicators pair into one flag; count on completion.
if (pendingRegionalIndicator) {
pendingRegionalIndicator = false
count++
} else {
pendingRegionalIndicator = true
}
previousWasZwj = false
}
isEmojiBase(cp) -> {
if (pendingKeycapBase || pendingRegionalIndicator) return 0
if (!previousWasZwj) count++
previousWasZwj = false
}
else -> return 0
}
if (count > MAX_JUMBO_EMOJI) return 0
}
// Incomplete sequences (half a flag, a digit with no keycap mark) mean the
// message is not emoji-only.
if (pendingRegionalIndicator || pendingKeycapBase) return 0
return count
}
private const val ZWJ = 0x200D
private const val KEYCAP_MARK = 0x20E3
private fun isWhitespace(cp: Int): Boolean = cp <= 0xFFFF && cp.toChar().isWhitespace()
private fun isEmojiModifier(cp: Int): Boolean =
cp == ZWJ ||
cp == 0xFE0E || // variation selector 15 (text style)
cp == 0xFE0F || // variation selector 16 (emoji style)
cp in 0x1F3FB..0x1F3FF // skin tones
private fun isKeycapBase(cp: Int): Boolean = cp in '0'.code..'9'.code || cp == '#'.code || cp == '*'.code
private fun isRegionalIndicator(cp: Int): Boolean = cp in 0x1F1E6..0x1F1FF
private fun isEmojiBase(cp: Int): Boolean =
cp in 0x1F000..0x1FAFF || // pictographs, emoticons, transport, supplemental, extended-A
cp in 0x2600..0x27BF || // misc symbols + dingbats (hearts, stars, hands)
cp in 0x2B00..0x2BFF || // arrows-C block (star, heavy circles)
cp == 0x203C || // double exclamation
cp == 0x2049 || // exclamation question
cp == 0x00A9 || // copyright (with VS16)
cp == 0x00AE || // registered (with VS16)
cp in 0x2934..0x2935 || // arrow emoji
cp in 0x3297..0x3299 || // circled ideographs
cp == 0x3030 || // wavy dash
cp == 0x303D // part alternation mark
@@ -21,14 +21,24 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
@@ -38,32 +48,58 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.note.CommentIcon
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.ChatPaddingGroupedModifier
import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier
import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.chatBackground
import com.vitorpamplona.amethyst.ui.theme.chatBubbleMe
import com.vitorpamplona.amethyst.ui.theme.chatBubbleThem
import com.vitorpamplona.amethyst.ui.theme.chatDraftBackground
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
private const val RELAYS_AND_ACTIONS_TEXT = "Relays and Actions"
// Half the height of a reaction chip, so the chip row overlaps the bubble by
// exactly its own vertical center.
private val ChatChipOverlapArrangement = Arrangement.spacedBy((-12).dp)
// Extra space reserved at the bottom of a bubble that has overlapping chips but no
// footer, so the chips ride over padding instead of the last line of text. The bubble
// already has 6dp bottom padding; the chips poke ~12dp in, so ~8dp more clears them.
private val ChatChipOverlapReserve = 8.dp
// Swipe-to-reply: releasing past the threshold fires the reply; the bubble never
// drags further than the max.
private val SwipeReplyThreshold = 56.dp
private val SwipeReplyMaxDrag = 72.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -71,39 +107,46 @@ fun ChatBubbleLayout(
isLoggedInUser: Boolean,
isDraft: Boolean,
innerQuote: Boolean,
isComplete: Boolean,
hasDetailsToShow: Boolean,
drawAuthorInfo: Boolean,
groupPosition: ChatGroupPosition = ChatGroupPosition.SINGLE,
// Jumbo-emoji messages render without a bubble fill.
transparentBubble: Boolean = false,
parentBackgroundColor: MutableState<Color>? = null,
shouldHighlight: Boolean = false,
onHighlightFinished: (() -> Unit)? = null,
onClick: () -> Boolean,
onDoubleTap: (() -> Unit)? = null,
// Drag the bubble toward the screen center to quote-reply. Null disables the
// gesture (inner quotes, drafts).
onSwipeReply: (() -> Unit)? = null,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
detailRow: @Composable () -> Unit,
reactionsRow: (@Composable () -> Unit)? = null,
// Small footer in the bubble's bottom corner (timestamp, delivery, per-message
// status glyphs). Null skips it entirely.
footerRow: (@Composable () -> Unit)? = null,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> Unit,
) {
val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink
val otherColors = MaterialTheme.colorScheme.chatBackground
val loggedInColors = MaterialTheme.colorScheme.chatBubbleMe
val otherColors = MaterialTheme.colorScheme.chatBubbleThem
val defaultBackground = MaterialTheme.colorScheme.background
val draftColor = MaterialTheme.colorScheme.chatDraftBackground
// Keyed on the theme-derived inputs so bubbles recolor when the user switches
// light/dark or accent while the chat is composed (MaterialTheme recomposes
// in place — nothing recreates this composable on a theme change).
val parentBg = parentBackgroundColor?.value
val bgColor =
remember {
if (isLoggedInUser) {
if (isDraft) {
mutableStateOf(
draftColor.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
} else {
mutableStateOf(
loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
remember(isLoggedInUser, isDraft, loggedInColors, otherColors, draftColor, defaultBackground, parentBg) {
val base = parentBg ?: defaultBackground
val bubble =
when {
isLoggedInUser && isDraft -> draftColor
isLoggedInUser -> loggedInColors
else -> otherColors
}
} else {
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
mutableStateOf(bubble.compositeOver(base))
}
val highlightActive = remember { mutableStateOf(false) }
@@ -124,68 +167,219 @@ fun ChatBubbleLayout(
label = "highlightAnimation",
)
Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
val haptic = LocalHapticFeedback.current
// Plain state during the drag (a coroutine per pointer frame is wasted work);
// a single animation job settles it back on release.
var dragOffset by remember { mutableFloatStateOf(0f) }
var settleJob by remember { mutableStateOf<Job?>(null) }
val swipeScope = rememberCoroutineScope()
val density = LocalDensity.current
val swipeThresholdPx = remember(density) { with(density) { SwipeReplyThreshold.toPx() } }
val swipeMaxPx = remember(density) { with(density) { SwipeReplyMaxDrag.toPx() } }
Box(
modifier =
when {
innerQuote -> ChatPaddingInnerQuoteModifier
groupPosition.isConnectedAbove -> ChatPaddingGroupedModifier
else -> ChatPaddingModifier
},
) {
val popupExpanded = remember { mutableStateOf(false) }
val showDetails =
remember {
mutableStateOf(
if (isComplete) {
true
} else {
hasDetailsToShow
},
)
}
val clickableModifier =
remember {
Modifier.combinedClickable(
onClick = {
if (!onClick() && !isComplete) {
showDetails.value = !showDetails.value
}
},
onLongClick = { popupExpanded.value = true },
)
}
Row(
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier,
) {
Surface(
color = animatedColor,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
modifier = clickableModifier,
) {
Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) {
if (drawAuthorInfo) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = HalfHalfVertPadding.clickable(onClick = onAuthorClick),
) {
drawAuthorLine()
}
}
inner(bgColor)
if (showDetails.value) {
detailRow()
}
if (onSwipeReply != null) {
val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f)
if (progress > 0f) {
Box(
modifier =
Modifier
.align(if (isLoggedInUser) Alignment.CenterEnd else Alignment.CenterStart)
.graphicsLayer {
alpha = progress
scaleX = 0.6f + 0.4f * progress
scaleY = 0.6f + 0.4f * progress
},
) {
CommentIcon(Size20Modifier, MaterialTheme.colorScheme.placeholderText)
}
}
}
if (popupExpanded.value) {
actionMenu {
popupExpanded.value = false
val swipeModifier =
if (onSwipeReply != null) {
Modifier
.graphicsLayer { translationX = dragOffset }
.pointerInput(onSwipeReply) {
// Drag toward the screen center only; a haptic tick marks the
// commit point, releasing past it fires the reply.
var crossedThreshold = false
val settleBack = {
settleJob =
swipeScope.launch {
animate(dragOffset, 0f) { value, _ -> dragOffset = value }
}
}
detectHorizontalDragGestures(
onDragStart = {
settleJob?.cancel()
crossedThreshold = false
},
onDragEnd = {
if (abs(dragOffset) >= swipeThresholdPx) {
onSwipeReply()
}
settleBack()
},
onDragCancel = { settleBack() },
) { change, dragAmount ->
change.consume()
val newOffset =
if (isLoggedInUser) {
(dragOffset + dragAmount).coerceIn(-swipeMaxPx, 0f)
} else {
(dragOffset + dragAmount).coerceIn(0f, swipeMaxPx)
}
if (!crossedThreshold && abs(newOffset) >= swipeThresholdPx) {
crossedThreshold = true
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
dragOffset = newOffset
}
}
} else {
Modifier
}
Row(
modifier = Modifier.fillMaxWidth().then(swipeModifier),
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
) {
InnerChatBubble(
isLoggedInUser = isLoggedInUser,
innerQuote = innerQuote,
drawAuthorInfo = drawAuthorInfo,
groupPosition = groupPosition,
transparentBubble = transparentBubble,
animatedColor = animatedColor,
highlightActive = highlightActive.value,
bgColor = bgColor,
onClick = onClick,
onDoubleTap = onDoubleTap,
onAuthorClick = onAuthorClick,
actionMenu = actionMenu,
reactionsRow = reactionsRow,
footerRow = footerRow,
drawAuthorLine = drawAuthorLine,
inner = inner,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun InnerChatBubble(
isLoggedInUser: Boolean,
innerQuote: Boolean,
drawAuthorInfo: Boolean,
groupPosition: ChatGroupPosition,
transparentBubble: Boolean,
animatedColor: Color,
highlightActive: Boolean,
bgColor: MutableState<Color>,
onClick: () -> Boolean,
onDoubleTap: (() -> Unit)?,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
reactionsRow: (@Composable () -> Unit)?,
footerRow: (@Composable () -> Unit)?,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> Unit,
) {
val haptic = LocalHapticFeedback.current
val popupExpanded = remember { mutableStateOf(false) }
val pressInteractionSource = remember { MutableInteractionSource() }
val isPressed by pressInteractionSource.collectIsPressedAsState()
val pressScale by animateFloatAsState(if (isPressed) 0.97f else 1f, label = "bubblePressScale")
val indication = LocalIndication.current
val clickableModifier =
remember {
Modifier.combinedClickable(
interactionSource = pressInteractionSource,
indication = indication,
onClick = {
// Only inner-quote bubbles do anything on tap (scroll to the quoted
// message); a normal bubble tap is a no-op.
onClick()
},
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
popupExpanded.value = true
},
onDoubleClick =
onDoubleTap?.let { action ->
{
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
action()
}
},
)
}
Column(
horizontalAlignment = if (isLoggedInUser) Alignment.End else Alignment.Start,
// Negative spacing pulls the reaction chips up so their vertical center
// rides the bubble's bottom border instead of floating detached below it.
verticalArrangement = if (reactionsRow != null) ChatChipOverlapArrangement else Arrangement.Top,
modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier,
) {
Surface(
// Jumbo emoji show bare; the scroll-to highlight still tints them.
color = if (transparentBubble && !highlightActive) Color.Transparent else animatedColor,
shape = chatBubbleShapeFor(isLoggedInUser, if (innerQuote) ChatGroupPosition.SINGLE else groupPosition),
modifier =
clickableModifier.graphicsLayer {
scaleX = pressScale
scaleY = pressScale
},
) {
Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) {
if (drawAuthorInfo) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = HalfHalfVertPadding.clickable(onClick = onAuthorClick),
) {
drawAuthorLine()
}
}
inner(bgColor)
if (footerRow != null) {
// Mirror the footer with the bubble so it sits on the same
// center-facing edge for both sides: the left for the logged-in user's
// own (right-aligned) bubbles, the right for everyone else's.
Box(modifier = Modifier.align(if (isLoggedInUser) Alignment.Start else Alignment.End)) {
footerRow()
}
} else if (reactionsRow != null) {
// No footer to sit over — reserve space so the overlapping chips don't
// cover the last line of text.
Spacer(Modifier.height(ChatChipOverlapReserve))
}
}
}
reactionsRow?.invoke()
}
if (popupExpanded.value) {
actionMenu {
popupExpanded.value = false
}
}
}
@@ -203,8 +397,6 @@ private fun BubblePreview() {
isLoggedInUser = false,
isDraft = false,
innerQuote = false,
isComplete = true,
hasDetailsToShow = true,
drawAuthorInfo = true,
parentBackgroundColor = bgColor,
onClick = { false },
@@ -229,7 +421,6 @@ private fun BubblePreview() {
},
)
},
detailRow = { Text(RELAYS_AND_ACTIONS_TEXT) },
) { _ ->
Text("This is my note")
}
@@ -238,9 +429,8 @@ private fun BubblePreview() {
isLoggedInUser = true,
isDraft = false,
innerQuote = false,
isComplete = true,
hasDetailsToShow = true,
drawAuthorInfo = true,
groupPosition = ChatGroupPosition.TOP,
parentBackgroundColor = bgColor,
onClick = { false },
onAuthorClick = {},
@@ -263,7 +453,6 @@ private fun BubblePreview() {
},
)
},
detailRow = { Text(RELAYS_AND_ACTIONS_TEXT) },
) { _ ->
Text("This is a very long long loong note")
}
@@ -272,9 +461,8 @@ private fun BubblePreview() {
isLoggedInUser = true,
isDraft = true,
innerQuote = false,
isComplete = true,
hasDetailsToShow = true,
drawAuthorInfo = true,
groupPosition = ChatGroupPosition.MIDDLE,
parentBackgroundColor = bgColor,
onClick = { false },
onAuthorClick = {},
@@ -297,7 +485,6 @@ private fun BubblePreview() {
},
)
},
detailRow = { Text(RELAYS_AND_ACTIONS_TEXT) },
) { _ ->
Text("This is a draft note")
}
@@ -306,9 +493,8 @@ private fun BubblePreview() {
isLoggedInUser = true,
isDraft = false,
innerQuote = false,
isComplete = false,
hasDetailsToShow = false,
drawAuthorInfo = false,
groupPosition = ChatGroupPosition.BOTTOM,
parentBackgroundColor = bgColor,
onClick = { false },
onAuthorClick = {},
@@ -331,7 +517,6 @@ private fun BubblePreview() {
},
)
},
detailRow = { Text(RELAYS_AND_ACTIONS_TEXT) },
) { _ ->
Text("Short note")
}
@@ -0,0 +1,189 @@
/*
* 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.feed.layouts
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeBottom
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeMiddle
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMeTop
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemBottom
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemMiddle
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThemTop
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlin.math.abs
/**
* Where a message sits inside a run of consecutive bubbles by the same author.
* [TOP] is the visually-first (oldest) message of the run and the only one that
* shows the author line; [BOTTOM] is the visually-last (newest) and carries the
* bubble tail.
*/
enum class ChatGroupPosition {
SINGLE,
TOP,
MIDDLE,
BOTTOM,
;
val isFirstOfGroup: Boolean
get() = this == SINGLE || this == TOP
val isLastOfGroup: Boolean
get() = this == SINGLE || this == BOTTOM
val isConnectedAbove: Boolean
get() = this == MIDDLE || this == BOTTOM
}
fun chatBubbleShapeFor(
isLoggedInUser: Boolean,
position: ChatGroupPosition,
): RoundedCornerShape =
if (isLoggedInUser) {
when (position) {
ChatGroupPosition.SINGLE -> ChatBubbleShapeMe
ChatGroupPosition.TOP -> ChatBubbleShapeMeTop
ChatGroupPosition.MIDDLE -> ChatBubbleShapeMeMiddle
ChatGroupPosition.BOTTOM -> ChatBubbleShapeMeBottom
}
} else {
when (position) {
ChatGroupPosition.SINGLE -> ChatBubbleShapeThem
ChatGroupPosition.TOP -> ChatBubbleShapeThemTop
ChatGroupPosition.MIDDLE -> ChatBubbleShapeThemMiddle
ChatGroupPosition.BOTTOM -> ChatBubbleShapeThemBottom
}
}
/** Messages more than this far apart never group, even from the same author. */
private const val GROUP_WINDOW_SECONDS = 10 * 60L
/**
* Event kinds that don't render as regular bubbles (zaps, raids, clips) or that
* read as system messages (channel admin events) never join an author run.
*/
private fun isGroupableEvent(event: Event?): Boolean =
event != null &&
event !is LnZapEvent &&
event !is LiveActivitiesRaidEvent &&
event !is LiveActivitiesClipEvent &&
event !is ChannelCreateEvent &&
event !is ChannelMetadataEvent
/**
* Whether [newer] continues the author run started by [older]. Mirrors the break
* conditions of `NewDateOrSubjectDivisor`: a date change or a subject header between
* the two also breaks the bubble group.
*/
private fun groupsWith(
newer: Note,
older: Note,
): Boolean {
val newerEvent = newer.event
val olderEvent = older.event
if (!isGroupableEvent(newerEvent) || !isGroupableEvent(olderEvent)) return false
if (newerEvent == null || olderEvent == null) return false
val newerAuthor = newer.author?.pubkeyHex ?: return false
val olderAuthor = older.author?.pubkeyHex ?: return false
if (newerAuthor != olderAuthor) return false
if (abs(newerEvent.createdAt - olderEvent.createdAt) > GROUP_WINDOW_SECONDS) return false
// A subject header renders as a divisor above the newer message.
if (newerEvent.subject() != null) return false
// Same rendered date as the day divisor uses (the strings only matter for
// null/today sentinels, which compare equal for same-day timestamps).
return dateFormatter(newerEvent.createdAt, "never", "today") ==
dateFormatter(olderEvent.createdAt, "never", "today")
}
/**
* Computes the group position for [note] given its feed neighbors. The chat feed is
* reverse-laid-out: [newer] is the message rendered visually below, [older] the one
* visually above.
*/
fun computeChatGroupPosition(
newer: Note?,
note: Note,
older: Note?,
): ChatGroupPosition {
val connectedAbove = older != null && groupsWith(note, older)
val connectedBelow = newer != null && groupsWith(newer, note)
return when {
connectedAbove && connectedBelow -> ChatGroupPosition.MIDDLE
connectedAbove -> ChatGroupPosition.BOTTOM
connectedBelow -> ChatGroupPosition.TOP
else -> ChatGroupPosition.SINGLE
}
}
/**
* [computeChatGroupPosition] that recomputes when any of the three notes' events or
* authors finish loading. The grouping math reads `event`/`author`, which resolve
* asynchronously on the same Note objects, so caching by Note identity alone
* latches a stale position for neighbors that compose before their data arrives.
*/
@Composable
fun watchChatGroupPosition(
newer: Note?,
note: Note,
older: Note?,
): ChatGroupPosition {
val noteVersion by note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
val newerVersion =
newer
?.flow()
?.metadata
?.stateFlow
?.collectAsStateWithLifecycle()
?.value
val olderVersion =
older
?.flow()
?.metadata
?.stateFlow
?.collectAsStateWithLifecycle()
?.value
return remember(noteVersion, newerVersion, olderVersion, newer, older) {
computeChatGroupPosition(newer, note, older)
}
}
@@ -0,0 +1,92 @@
/*
* 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.feed.layouts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* A centered, muted system line for events that narrate the room rather than talk
* in it (channel created, profile updated, ...). Visually distinct from user
* bubbles: no author row, no tail, one small pill in the middle of the feed.
*/
@Composable
fun ChatSystemMessage(
text: String,
onClick: (() -> Unit)? = null,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
) {
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier =
if (onClick != null) {
Modifier.clip(ButtonBorder).clickable(onClick = onClick)
} else {
Modifier
},
) {
SystemMessageText(text)
}
}
}
@Composable
private fun SystemMessageText(text: String) {
Text(
text = text,
fontSize = Font12SP,
color = MaterialTheme.colorScheme.grayText,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 5.dp),
)
}
@Preview
@Composable
private fun ChatSystemMessagePreview() {
ThemeComparisonColumn {
ChatSystemMessage("Alice created the channel Amethyst Users", onClick = {})
ChatSystemMessage("Alice updated the channel profile")
}
}
@@ -1,56 +0,0 @@
/*
* 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.feed.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
@Composable
fun RenderChangeChannelMetadataNote(
note: Note,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? ChannelMetadataEvent ?: return
val channelInfo = remember(noteEvent) { noteEvent.channelInfo() }
val tags =
remember(noteEvent) {
noteEvent.tags.toImmutableListOfLists()
}
RenderChannelData(
noteEvent.id,
note.toNostrUri(),
channelInfo,
tags,
bgColor,
accountViewModel,
nav,
)
}
@@ -0,0 +1,87 @@
/*
* 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.feed.types
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
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.feed.layouts.ChatSystemMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
/**
* Channel admin events (NIP-28 kind 40/41) narrate the room instead of talking in
* it, so they render as a centered system line — "X created the channel", "X
* updated the channel profile" — that taps through to the channel, instead of a
* full profile card inside a user bubble.
*/
@Composable
fun RenderChannelAdminSystemMessage(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
when (val noteEvent = note.event) {
is ChannelCreateEvent -> {
val authorName = watchAuthorName(note, accountViewModel)
val channelName = remember(noteEvent) { noteEvent.channelInfo().name }
ChatSystemMessage(
text =
if (channelName.isNullOrBlank()) {
stringRes(R.string.chat_system_created_channel_unnamed, authorName)
} else {
stringRes(R.string.chat_system_created_channel, authorName, channelName)
},
onClick = { nav.nav(Route.PublicChatChannel(note.idHex)) },
)
}
is ChannelMetadataEvent -> {
val authorName = watchAuthorName(note, accountViewModel)
val channelId = remember(noteEvent) { noteEvent.channelId() }
ChatSystemMessage(
text = stringRes(R.string.chat_system_updated_channel, authorName),
onClick = channelId?.let { { nav.nav(Route.PublicChatChannel(it)) } },
)
}
else -> {}
}
}
@Composable
private fun watchAuthorName(
note: Note,
accountViewModel: AccountViewModel,
): String {
val author = note.author ?: return note.event?.pubKey?.take(8) ?: ""
val userState by observeUserInfo(author, accountViewModel)
return userState?.info?.bestName() ?: author.pubkeyDisplayHex()
}
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ui.StreamSystemCard
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -51,7 +52,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
private const val BIG_ZAP_THRESHOLD_SATS = 50_000L
@@ -79,54 +79,65 @@ fun RenderChatZap(
val accentAlpha = if (isBigZap) 0.18f else 0.08f
val amountText = card.amount ?: showAmountInteger(zapEvent.amount)
StreamSystemCard(accent = BitcoinOrange, accentAlpha = accentAlpha) {
// Content-hugging centered pill, matching the system-message design the rest
// of the chat feed moved to; the bitcoin accent keeps zaps celebratory.
// Tapping opens the zap receipt in its own thread view.
StreamSystemCard(
accent = BitcoinOrange,
accentAlpha = accentAlpha,
fillWidth = false,
onClick = {
routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) }
},
) {
val backgroundColor = remember(accentAlpha) { mutableStateOf(BitcoinOrange.copy(alpha = accentAlpha)) }
Row(
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ZapIcon(
if (isBigZap) Size24Modifier else Size20Modifier,
BitcoinOrange,
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ZapIcon(
if (isBigZap) Size24Modifier else Size20Modifier,
BitcoinOrange,
)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val sender = card.user
if (sender != null) {
UserPicture(sender, Size20dp, Modifier, accountViewModel, nav)
Spacer(StdHorzSpacer)
val sender = card.user
if (sender != null) {
UserPicture(sender, Size20dp, Modifier, accountViewModel, nav)
// weight(fill = false) hugs short names but yields to the
// amount when the name is long — the amount is the point of
// the card and must always stay visible.
Row(modifier = Modifier.weight(1f, fill = false)) {
UsernameDisplay(
baseUser = sender,
fontWeight = FontWeight.Bold,
accountViewModel = accountViewModel,
)
} else {
Text(
text = stringRes(R.string.chat_zap_anonymous),
fontWeight = FontWeight.Bold,
)
}
Spacer(StdHorzSpacer)
} else {
Text(
text = stringRes(R.string.chat_zap_amount_suffix, amountText),
color = BitcoinOrange,
fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal,
text = stringRes(R.string.chat_zap_anonymous),
fontWeight = FontWeight.Bold,
)
}
card.comment?.takeIf { it.isNotBlank() }?.let { comment ->
Spacer(Modifier.padding(top = 2.dp))
CrossfadeToDisplayComment(
comment = comment,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
}
Text(
text = stringRes(R.string.chat_zap_amount_suffix, amountText),
color = BitcoinOrange,
fontWeight = if (isBigZap) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
)
}
card.comment?.takeIf { it.isNotBlank() }?.let { comment ->
Spacer(Modifier.padding(top = 2.dp))
CrossfadeToDisplayComment(
comment = comment,
backgroundColor = backgroundColor,
nav = nav,
accountViewModel = accountViewModel,
)
}
}
}
@@ -1,307 +0,0 @@
/*
* 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.feed.types
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
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.defaults.Constants
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
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.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.base.ChannelDataNorm
import kotlinx.coroutines.launch
@Composable
fun RenderCreateChannelNote(
note: Note,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = note.event as? ChannelCreateEvent ?: return
val channelInfo = remember(noteEvent) { noteEvent.channelInfo() }
val tags =
remember(noteEvent) {
noteEvent.tags.toImmutableListOfLists()
}
RenderChannelData(
noteEvent.id,
note.toNostrUri(),
channelInfo,
tags,
bgColor,
accountViewModel,
nav,
)
}
@Preview
@Composable
fun RenderChannelDataPreview() {
ThemeComparisonRow {
RenderChannelData(
id = "bbaacc",
uri = "nostr:nevent1...",
channelInfo =
ChannelDataNorm(
"My Group",
"Testing About me",
"http://test.com",
listOf(Constants.mom, Constants.nos),
),
tags = EmptyTagList,
bgColor = remember { mutableStateOf(Color.Transparent) },
accountViewModel = mockAccountViewModel(),
nav = EmptyNav(),
)
}
}
@Composable
fun RenderChannelData(
id: HexKey,
uri: String,
channelInfo: ChannelDataNorm,
tags: ImmutableListOfLists<String>,
bgColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column {
Row {
TranslatableRichTextViewer(
content = stringRes(R.string.changed_chat_profile_to),
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = id,
callbackUri = uri,
accountViewModel = accountViewModel,
nav = nav,
)
}
channelInfo.picture?.let {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
RobohashFallbackAsyncImage(
robot = id,
model = it,
contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
)
}
}
channelInfo.name?.let {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
CreateTextWithEmoji(
text = it,
tags = tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
}
channelInfo.about?.let {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
TranslatableRichTextViewer(
content = it,
canPreview = true,
quotesLeft = 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = id,
callbackUri = uri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
channelInfo.relays?.let {
Text(
stringRes(R.string.public_chat_relays_title) + ": ",
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
)
it.forEach {
Spacer(StdVertSpacer)
RenderRelayLinePublicChat(
it,
accountViewModel,
nav,
)
}
}
}
}
@Preview
@Composable
fun RenderRelayLinePreview() {
ThemeComparisonRow {
RenderRelayLine(
"wss://nos.lol",
"http://icon.com/icon.ico",
Modifier,
showPicture = true,
loadRobohash = true,
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RenderRelayLinePublicChat(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
nav: INav,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val relayInfo by loadRelayInfo(relay)
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
val clickableModifier =
remember(relay) {
Modifier.combinedClickable(
onLongClick = {
scope.launch {
clipboardManager.setText(relay.url)
}
},
onClick = { nav.nav(Route.RelayInfo(relay.url)) },
)
}
RenderRelayLine(
relay.displayUrl(),
relayInfo.icon,
clickableModifier,
showPicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@Composable
fun RenderRelayLine(
url: String,
icon: String?,
modifier: Modifier = Modifier,
showPicture: Boolean = true,
loadRobohash: Boolean = true,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = modifier,
) {
Text(" -")
Spacer(modifier = StdHorzSpacer)
RobohashFallbackAsyncImage(
robot = url,
model = icon,
contentDescription = stringRes(id = R.string.relay_info, url),
colorFilter = RelayIconFilter,
modifier =
Modifier
.size(Size20dp)
.clip(shape = CircleShape),
loadProfilePicture = showPicture,
loadRobohash = loadRobohash,
)
Spacer(modifier = StdHorzSpacer)
Text(
text = url,
)
}
}
@@ -20,11 +20,13 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
@@ -33,9 +35,16 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.types.appendMissingImetaUrls
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.jumboEmojiCount
import com.vitorpamplona.amethyst.ui.stringRes
// Jumbo sizes step down as the emoji count grows so up to three still fit a line.
private val JumboEmojiSingle = 50.sp
private val JumboEmojiPair = 40.sp
private val JumboEmojiTriple = 32.sp
@Composable
fun RenderRegularTextNote(
note: Note,
@@ -51,21 +60,44 @@ fun RenderRegularTextNote(
note = note,
accountViewModel = accountViewModel,
) {
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
val jumboCount = remember(eventContent) { jumboEmojiCount(eventContent) }
TranslatableRichTextViewer(
content = eventContent,
canPreview = canPreview,
quotesLeft = if (innerQuote) 0 else 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
authorPubKey = note.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
if (jumboCount > 0) {
// Emoji-only messages render as jumbo emoji (the bubble behind
// them is transparent — see NormalChatNote).
Text(
text = eventContent.trim(),
fontSize =
when (jumboCount) {
1 -> JumboEmojiSingle
2 -> JumboEmojiPair
else -> JumboEmojiTriple
},
)
} else {
val tags = remember(note.event) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList }
// Some NIP-C7/Concord clients (e.g. Ditto/Soapbox Armada) attach an image purely as a
// NIP-92 `imeta` tag and leave the content empty. The shared renderer only shows media
// whose URL appears in the text, so append any imeta URL missing from the (decrypted)
// content — encrypted-blob key/nonce are already registered by URL, so the shared
// pipeline fetches and decrypts them transparently.
val displayContent = remember(note.event, eventContent) { appendMissingImetaUrls(eventContent, note.event) }
TranslatableRichTextViewer(
content = displayContent,
canPreview = canPreview,
quotesLeft = if (innerQuote) 0 else 1,
modifier = Modifier,
tags = tags,
backgroundColor = bgColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
authorPubKey = note.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
} else {
TranslatableRichTextViewer(
@@ -114,7 +114,7 @@ fun MinichatScreen(
// public-chat (NIP-28/NIP-29) replies come over a relay REQ for this message's kind-1111 children.
if (isConcord) {
ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel)
ConcordChannelHistorySubscription(communityId!!, channelId!!, accountViewModel.dataSources().concordChannelHistory, accountViewModel)
ConcordChannelHistorySubscription(communityId, channelId, accountViewModel.dataSources().concordChannelHistory, accountViewModel)
ConcordMinichatBackfillUntilRoot(rootNote, accountViewModel.dataSources().concordChannelHistory.history)
}
EventFinderFilterAssemblerSubscription(rootNote, accountViewModel)
@@ -151,7 +151,7 @@ fun MinichatScreen(
// itself", where the whole timeline loads and (if you aren't a member yet) you can
// join. This is the way out when the pinned root is still backfilling or unavailable.
if (isConcord) {
IconButton(onClick = { nav.nav(Route.Concord(communityId!!, channelId!!)) }) {
IconButton(onClick = { nav.nav(Route.Concord(communityId, channelId)) }) {
SymbolIcon(symbol = MaterialSymbols.Forum, contentDescription = stringRes(R.string.concord_open_channel))
}
}
@@ -62,6 +62,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -168,8 +169,11 @@ fun ConcordChannelListScreen(
Text(title, maxLines = 1)
},
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
// Back arrow only when pushed from elsewhere; as a bottom-nav tab the bar takes its place.
if (nav.canPop()) {
IconButton(onClick = { nav.popBack() }) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back))
}
}
},
actions = {
@@ -207,6 +211,13 @@ fun ConcordChannelListScreen(
},
)
},
bottomBar = {
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// so a pinned Concord community works both as a pushed detail and as a bottom-nav tab.
AppBottomBar(Route.ConcordServer(communityId), nav, accountViewModel) { route ->
if (route != Route.ConcordServer(communityId)) nav.navBottomBar(route)
}
},
floatingActionButton = {
if (canManageChannels) {
FloatingActionButton(
@@ -236,11 +247,11 @@ fun ConcordChannelListScreen(
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
items(channels, key = { it.key }) { entry ->
val def = entry.value.definition
val name = def?.name ?: entry.key
val name = def.name.ifBlank { entry.key }
val icon =
when {
def?.voice == true -> MaterialSymbols.Mic
def?.private == true -> MaterialSymbols.Lock
def.voice == true -> MaterialSymbols.Mic
def.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
}
Row(
@@ -566,5 +566,6 @@ private fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
blurhash = result.fileHeader.blurHash?.blurhash,
cipher = cipher,
originalHash = result.hashBeforeEncryption,
thumbhash = result.fileHeader.thumbHash?.thumbhash,
)
}
@@ -72,8 +72,8 @@ fun rememberConcordImageModel(
val ciphertext =
client.newCall(Request.Builder().url(pointer.url).build()).execute().use { resp ->
if (!resp.isSuccessful) return@runCatching null
resp.body?.bytes()
} ?: return@runCatching null
resp.body.bytes()
}
val plaintext = pointer.decryptOrNull(ciphertext) ?: return@runCatching null
cacheFile.writeBytes(plaintext)
@@ -222,11 +222,11 @@ fun ConcordHomeScreen(
ConcordChannelRow(
communityId = entry.id,
channelKey = ch.key,
channelName = def?.name ?: ch.key,
channelName = def.name.ifBlank { ch.key },
icon =
when {
def?.voice == true -> MaterialSymbols.Mic
def?.private == true -> MaterialSymbols.Lock
def.voice == true -> MaterialSymbols.Mic
def.private == true -> MaterialSymbols.Lock
else -> MaterialSymbols.Tag
},
hideIfRead = mode == ChannelExpand.UNREAD,
@@ -26,7 +26,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar
@@ -44,6 +46,7 @@ fun PublicChatChannelScreen(
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
val selfRoute = remember(channelId) { Route.PublicChatChannel(channelId) }
DisappearingScaffold(
isInvertedLayout = true,
@@ -52,6 +55,13 @@ fun PublicChatChannelScreen(
PublicChatTopBar(it, accountViewModel, nav)
}
},
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// so a pinned public chat works both as a pushed detail and as a bottom-nav tab.
bottomBar = {
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
if (route != selfRoute) nav.navBottomBar(route)
}
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
@@ -43,6 +43,8 @@ fun PublicChatTopBar(
extendableRow = {
LongPublicChatChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav)
},
// No back arrow when opened as a bottom-nav tab (nothing to pop); the bottom bar shows instead.
showBackButton = nav.canPop(),
popBack = nav::popBack,
)
}
@@ -26,7 +26,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
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.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -46,6 +48,7 @@ fun RelayGroupChatScreen(
val channelId = remember(id, relay) { GroupId(id, relay) }
val draft = remember(draftId) { draftId?.let { accountViewModel.getNoteIfExists(it) } }
val replyTo = remember(replyToId) { replyToId?.let { accountViewModel.checkGetOrCreateNote(it) } }
val selfRoute = remember(id, relayUrl) { Route.RelayGroup(id, relayUrl) }
DisappearingScaffold(
isInvertedLayout = true,
@@ -54,6 +57,13 @@ fun RelayGroupChatScreen(
RelayGroupTopBar(it, inviteCode, accountViewModel, nav)
}
},
// Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop),
// so a pinned relay group works both as a pushed detail and as a bottom-nav tab.
bottomBar = {
AppBottomBar(selfRoute, nav, accountViewModel) { route ->
if (route != selfRoute) nav.navBottomBar(route)
}
},
accountViewModel = accountViewModel,
allowBarHide = false,
) {
@@ -243,6 +243,8 @@ fun RelayGroupTopBar(
}
}
},
// No back arrow when opened as a bottom-nav tab (nothing to pop); the bottom bar shows instead.
showBackButton = nav.canPop(),
popBack = nav::popBack,
)
@@ -31,6 +31,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** One screen's request to keep the roster of the user's joined groups fresh. */
class RelayGroupMyJoinedGroupsQueryState(
@@ -49,6 +52,20 @@ private val RELAY_GROUP_ROSTER_KINDS =
GroupMembersEvent.KIND,
)
/**
* Timeline kinds shown in a group's chat — chat messages and polls. Kept in sync with the
* in-group feed ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource
* .subassemblies.filterMessagesToRelayGroup]) so the preview and the opened chat agree.
*/
private val RELAY_GROUP_PREVIEW_CONTENT_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/**
* How many recent chat events to prefetch per joined group. Enough for the Messages-list
* preview to reflect the true newest message and for opening the group to land on a populated
* first screen. Matches [RELAY_GROUP_WARMUP_LIMIT].
*/
private const val RELAY_GROUP_JOINED_PREVIEW_LIMIT = 50
/**
* Keeps the relay-signed roster (metadata/admins/members) of every group the user
* has joined live while a groups-bearing screen is on top, so membership,
@@ -56,8 +73,15 @@ private val RELAY_GROUP_ROSTER_KINDS =
* without having to open each chat. This matters most for closed/private groups,
* where the only way to confirm a join was admitted is a fresh 39002.
*
* One subscription per host relay, scoped by `#d` to just that relay's joined
* group ids — so we don't pull a relay's whole directory, only what we're in.
* On top of the roster it prefetches a bounded slice of each group's most recent chat
* (kind 9 + polls), so the Messages-list preview shows the true newest message instead of
* whatever kind-9 events happened to already be cached, and opening a group lands on
* populated content. Without this the list would only ever surface "scattered" messages
* that arrived through unrelated subscriptions until the group was actually opened.
*
* Roster is one `#d`-scoped filter per host relay (only what we're in, not the relay's whole
* directory); content is one `#h`-scoped, limited filter per group (a per-filter limit can't be
* shared across groups, and `#d`/`#h` can't be merged into a single filter).
*/
class RelayGroupMyJoinedGroupsFilterAssembler(
client: INostrClient,
@@ -85,21 +109,43 @@ class RelayGroupMyJoinedGroupsSubAssembler(
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) return null
// Group the joined group ids by their host relay: one #d-scoped filter each.
// Group the joined group ids by their host relay: one #d-scoped roster filter each.
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
return idsByRelay.mapNotNull { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_ROSTER_KINDS,
tags = mapOf("d" to groupIds.distinct()),
since = since?.get(relay)?.time,
),
)
}
val rosterFilters =
idsByRelay.mapNotNull { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_ROSTER_KINDS,
tags = mapOf("d" to groupIds.distinct()),
since = since?.get(relay)?.time,
),
)
}
// One #h-scoped, limited content slice per joined group so list previews show the true
// newest chat and opening the group lands on cached messages. A group's #d roster id and
// its #h message id are the same string, but #d and #h can't be merged into one filter and
// a limit is per-filter, so this stays one bounded filter per group.
val contentFilters =
joined.mapNotNull { group ->
val relay = RelayUrlNormalizer.normalizeOrNull(group.relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_PREVIEW_CONTENT_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(group.groupId)),
limit = RELAY_GROUP_JOINED_PREVIEW_LIMIT,
since = since?.get(relay)?.time,
),
)
}
return rosterFilters + contentFilters
}
override fun id(key: RelayGroupMyJoinedGroupsQueryState) = key.account
@@ -20,148 +20,41 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
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.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.Route.NewGroupDM
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
/**
* The Messages "+" button. Instead of a speed-dial that fanned out several cryptic one-word FABs
* (Private / Public / Group / Find groups), a single button opens the full-screen
* [NewConversationScreen] chooser, which explains every conversation type and its trade-offs before
* the user commits to one.
*/
@Composable
fun ChannelFabColumn(nav: INav) {
var isOpen by remember { mutableStateOf(false) }
Column {
AnimatedVisibility(
visible = isOpen,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
Column {
FloatingActionButton(
onClick = {
nav.nav(NewGroupDM())
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_new_message),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
nav.nav(Route.ChannelMetadataEdit())
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_create_public_chat),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
nav.nav(Route.CreateMarmotGroup)
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_create_group),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
nav.nav(Route.RelayGroupBrowse)
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.relay_group_browse_title),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
}
val rotationDegree by animateFloatAsState(
targetValue = if (isOpen) 45f else 0f,
FloatingActionButton(
onClick = { nav.nav(Route.NewConversation) },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.messages_create_public_private_chat_description),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
FloatingActionButton(
onClick = { isOpen = !isOpen },
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = stringRes(R.string.messages_create_public_private_chat_description),
modifier =
Modifier.size(26.dp).graphicsLayer {
rotationZ = rotationDegree
},
tint = Color.White,
)
}
}
}
@@ -0,0 +1,413 @@
/*
* 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.rooms
import androidx.annotation.StringRes
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.graphics.lerp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.grayText
// Per-type accent colors. Each protocol gets its own hue so private-vs-public reads at a glance
// instead of via text. Used at full strength for the icon tile (white glyph on top) and, tint-mixed,
// for the type's chip / expanded accents. In dark theme the accent is lightened for legibility on the
// dark ground (see [ConversationRow]); the solid tile keeps the saturated base either way.
private val ColorPrivate = Color(0xFF7C3AED)
private val ColorMarmot = Color(0xFF4F46E5)
private val ColorConcord = Color(0xFF0F766E)
private val ColorPublic = Color(0xFFB45309)
private val ColorRelay = Color(0xFF2563EB)
private val ColorEphemeral = Color(0xFFC2410C)
/**
* One selectable conversation type. Collapsed, a row shows only the icon, name, a short tagline, and
* a one-word [chip] naming its deciding axis (scale / device-bound / moderation / live). Tapping the
* row expands it to reveal [bestFor] and the [pros]/[cons] before the [cta] button routes to that
* type's existing creation (or browse) flow.
*/
@Immutable
private class ConversationType(
val icon: MaterialSymbol,
val color: Color,
@StringRes val title: Int,
@StringRes val tagline: Int,
@StringRes val chip: Int,
@StringRes val bestFor: Int,
@StringRes val cta: Int,
val pros: List<Int>,
val cons: List<Int>,
val route: Route,
)
@Immutable
private class ConversationSection(
@StringRes val header: Int,
val types: List<ConversationType>,
)
// Grouped by intent so the six protocols read as three simple buckets: talk privately, run an
// encrypted group, or use a relay-aware room (where the relay can see/moderate who's there).
private val conversationSections =
listOf(
ConversationSection(
header = R.string.new_conversation_section_direct,
types =
listOf(
ConversationType(
icon = MaterialSymbols.Mail,
color = ColorPrivate,
title = R.string.new_conversation_dm_title,
tagline = R.string.new_conversation_dm_tagline,
chip = R.string.new_conversation_dm_chip,
bestFor = R.string.new_conversation_dm_best,
cta = R.string.new_conversation_dm_cta,
pros = listOf(R.string.new_conversation_dm_pro_1, R.string.new_conversation_dm_pro_2),
cons = listOf(R.string.new_conversation_dm_con_1),
route = Route.NewGroupDM(),
),
),
),
ConversationSection(
header = R.string.new_conversation_section_encrypted,
types =
listOf(
ConversationType(
icon = MaterialSymbols.Lock,
color = ColorMarmot,
title = R.string.new_conversation_marmot_title,
tagline = R.string.new_conversation_marmot_tagline,
chip = R.string.new_conversation_marmot_chip,
bestFor = R.string.new_conversation_marmot_best,
cta = R.string.new_conversation_marmot_cta,
pros = listOf(R.string.new_conversation_marmot_pro_1, R.string.new_conversation_marmot_pro_2),
cons = listOf(R.string.new_conversation_marmot_con_1),
route = Route.CreateMarmotGroup,
),
ConversationType(
icon = MaterialSymbols.Groups,
color = ColorConcord,
title = R.string.new_conversation_concord_title,
tagline = R.string.new_conversation_concord_tagline,
chip = R.string.new_conversation_concord_chip,
bestFor = R.string.new_conversation_concord_best,
cta = R.string.new_conversation_concord_cta,
pros = listOf(R.string.new_conversation_concord_pro_1, R.string.new_conversation_concord_pro_2),
cons = listOf(R.string.new_conversation_concord_con_1),
route = Route.ConcordCreate,
),
),
),
ConversationSection(
header = R.string.new_conversation_section_relay,
types =
listOf(
ConversationType(
icon = MaterialSymbols.Public,
color = ColorPublic,
title = R.string.new_conversation_public_chat_title,
tagline = R.string.new_conversation_public_chat_tagline,
chip = R.string.new_conversation_public_chat_chip,
bestFor = R.string.new_conversation_public_chat_best,
cta = R.string.new_conversation_public_chat_cta,
pros = listOf(R.string.new_conversation_public_chat_pro_1, R.string.new_conversation_public_chat_pro_2),
cons = listOf(R.string.new_conversation_public_chat_con_1, R.string.new_conversation_public_chat_con_2),
route = Route.ChannelMetadataEdit(),
),
ConversationType(
icon = MaterialSymbols.Dns,
color = ColorRelay,
title = R.string.new_conversation_relay_group_title,
tagline = R.string.new_conversation_relay_group_tagline,
chip = R.string.new_conversation_relay_group_chip,
bestFor = R.string.new_conversation_relay_group_best,
cta = R.string.new_conversation_relay_group_cta,
pros = listOf(R.string.new_conversation_relay_group_pro_1, R.string.new_conversation_relay_group_pro_2),
cons = listOf(R.string.new_conversation_relay_group_con_1),
route = Route.RelayGroupBrowse,
),
ConversationType(
icon = MaterialSymbols.Timer,
color = ColorEphemeral,
title = R.string.new_conversation_ephemeral_title,
tagline = R.string.new_conversation_ephemeral_tagline,
chip = R.string.new_conversation_ephemeral_chip,
bestFor = R.string.new_conversation_ephemeral_best,
cta = R.string.new_conversation_ephemeral_cta,
pros = listOf(R.string.new_conversation_ephemeral_pro_1, R.string.new_conversation_ephemeral_pro_2),
cons = listOf(R.string.new_conversation_ephemeral_con_1, R.string.new_conversation_ephemeral_con_2),
route = Route.NewEphemeralChat,
),
),
),
)
@Composable
fun NewConversationScreen(nav: INav) {
// Accordion: at most one row expanded, keyed by its (unique) title resource id. Survives config
// changes so an opened card stays open on rotation.
var expandedId by rememberSaveable { mutableStateOf(0) }
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.new_conversation_title), nav) },
) { pad ->
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding =
PaddingValues(
start = 12.dp,
end = 12.dp,
top = pad.calculateTopPadding() + 4.dp,
bottom = pad.calculateBottomPadding() + 16.dp,
),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
conversationSections.forEach { section ->
item(key = section.header) {
Text(
text = stringRes(section.header),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(start = 6.dp, top = 8.dp),
)
}
section.types.forEach { type ->
item(key = type.title) {
ConversationRow(
type = type,
expanded = expandedId == type.title,
onToggle = { expandedId = if (expandedId == type.title) 0 else type.title },
onCreate = { nav.nav(type.route) },
)
}
}
}
}
}
}
@Composable
private fun ConversationRow(
type: ConversationType,
expanded: Boolean,
onToggle: () -> Unit,
onCreate: () -> Unit,
) {
// Lighten the accent in dark mode so chip/label/checkmark text stays legible on the dark ground;
// the solid icon tile keeps the saturated base color in both themes.
val accent = if (isSystemInDarkTheme()) lerp(type.color, Color.White, 0.42f) else type.color
ElevatedCard(
onClick = onToggle,
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(Modifier.animateContentSize().padding(13.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
shape = RoundedCornerShape(13.dp),
color = type.color,
modifier = Modifier.size(44.dp),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
symbol = type.icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
Text(
text = stringRes(type.title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(type.tagline),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(8.dp))
AxisChip(stringRes(type.chip), accent)
}
if (expanded) {
HorizontalDivider(
modifier = Modifier.padding(top = 13.dp, bottom = 12.dp),
color = accent.copy(alpha = 0.22f),
)
Column {
ColumnHeader(stringRes(R.string.new_conversation_best_for))
Text(
text = stringRes(type.bestFor),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 3.dp, bottom = 12.dp),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Column(Modifier.weight(1f)) {
ColumnHeader(stringRes(R.string.new_conversation_pros))
Spacer(Modifier.height(4.dp))
type.pros.forEach { ProConRow(it, accent, isPro = true) }
}
Column(Modifier.weight(1f)) {
ColumnHeader(stringRes(R.string.new_conversation_cons))
Spacer(Modifier.height(4.dp))
type.cons.forEach { ProConRow(it, accent, isPro = false) }
}
}
Button(
onClick = onCreate,
modifier = Modifier.fillMaxWidth().padding(top = 13.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = type.color, contentColor = Color.White),
) {
Text(stringRes(type.cta), fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun AxisChip(
label: String,
accent: Color,
) {
Surface(
shape = RoundedCornerShape(999.dp),
color = accent.copy(alpha = 0.14f),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = accent,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
@Composable
private fun ColumnHeader(text: String) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.grayText,
)
}
@Composable
private fun ProConRow(
@StringRes text: Int,
accent: Color,
isPro: Boolean,
) {
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.padding(vertical = 2.dp),
) {
Icon(
symbol = if (isPro) MaterialSymbols.Check else MaterialSymbols.Close,
contentDescription = null,
tint = if (isPro) accent else MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 1.dp).size(15.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = stringRes(text),
style = MaterialTheme.typography.bodySmall,
)
}
}
@Preview
@Composable
private fun NewConversationScreenPreview() {
ThemeComparisonColumn {
NewConversationScreen(nav = EmptyNav())
}
}
@@ -0,0 +1,108 @@
/*
* 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.settings
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.amethyst.ui.navigation.bottombars.stableKey
/**
* Pure list transforms for the bottom-bar pinned list. Kept free of Compose/Android so the pin,
* unpin and reorder rules are exercised directly by unit tests (BottomBarSettingsStateTest) rather
* than only through the drag UI. [BottomBarSettingsState] is the thin stateful wrapper the screen uses.
*/
object BottomBarEditing {
fun isPinned(
items: List<BottomBarEntry>,
entry: BottomBarEntry,
): Boolean = items.any { it.stableKey == entry.stableKey }
/** Adds [entry] to the end if absent, or removes it (by stable identity) if present. */
fun togglePin(
items: List<BottomBarEntry>,
entry: BottomBarEntry,
): List<BottomBarEntry> =
if (isPinned(items, entry)) {
items.filter { it.stableKey != entry.stableKey }
} else {
items + entry
}
/** Moves the item at [from] to index [to]; a no-op if either index is out of bounds. */
fun move(
items: List<BottomBarEntry>,
from: Int,
to: Int,
): List<BottomBarEntry> {
if (from == to || from !in items.indices || to !in items.indices) return items
return items.toMutableList().apply { add(to, removeAt(from)) }
}
}
/**
* State holder for the Bottom Bar settings screen: owns the ordered pinned list and the pin / unpin /
* reorder / restore-default operations, so the composable only renders and forwards events. During a
* drag, [moveTransient] reorders without persisting; [commit] writes the final order once the drag ends.
*/
@Stable
class BottomBarSettingsState(
initial: List<BottomBarEntry>,
private val persist: (List<BottomBarEntry>) -> Unit,
) {
var pinned by mutableStateOf(initial)
private set
fun isPinned(entry: BottomBarEntry): Boolean = BottomBarEditing.isPinned(pinned, entry)
fun pinnedKeys(): Set<String> = pinned.mapTo(HashSet(pinned.size)) { it.stableKey }
fun togglePin(entry: BottomBarEntry) = update(BottomBarEditing.togglePin(pinned, entry))
fun restoreDefault() = update(DefaultBottomBarEntries)
/** Reorder mid-drag WITHOUT persisting. Pair with [commit] when the gesture ends. */
fun moveTransient(
from: Int,
to: Int,
) {
pinned = BottomBarEditing.move(pinned, from, to)
}
/** Persist the current (post-drag) order. */
fun commit() = persist(pinned)
/**
* Re-seed from an external change (the saved settings flow emitted) without re-persisting. A no-op
* when equal, so the echo of our own [persist] doesn't clobber an in-progress edit.
*/
fun syncFrom(items: List<BottomBarEntry>) {
if (items != pinned) pinned = items
}
private fun update(newItems: List<BottomBarEntry>) {
pinned = newItems
persist(newItems)
}
}
@@ -78,8 +78,9 @@ val ButtonBorder = RoundedCornerShape(20.dp)
val LeftHalfCircleButtonBorder = ButtonBorder.copy(topEnd = CornerSize(0f), bottomEnd = CornerSize(0f))
val EditFieldBorder = RoundedCornerShape(25.dp)
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
// Chat bubble shapes live in commons (ChatTheme.kt) so Android and Desktop share
// one geometry: see ChatBubbleShapeMe/Them and their grouped Top/Middle/Bottom
// variants there.
val StdButtonSizeModifier = Modifier.size(19.dp)
@@ -258,6 +259,18 @@ val ChatPaddingModifier =
bottom = 3.dp,
)
// Tighter top gap for a message visually connected to the previous message of the
// same author run (grouped bubbles).
val ChatPaddingGroupedModifier =
Modifier
.fillMaxWidth(1f)
.padding(
start = 12.dp,
end = 12.dp,
top = 1.dp,
bottom = 3.dp,
)
val profileContentHeaderModifier =
Modifier.fillMaxWidth().padding(top = 70.dp, start = Size25dp, end = Size25dp)
val bannerModifier = Modifier.fillMaxWidth().height(120.dp)
@@ -140,6 +140,11 @@ private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.05f)
private val DarkChatBackground = DarkColorPalette.onSurface.copy(alpha = 0.12f)
private val LightChatBackground = LightColorPalette.onSurface.copy(alpha = 0.08f)
// Bubble fill for other users' chat messages. Stronger than chatBackground so the
// bubble clearly separates from the screen background in both themes.
private val DarkChatBubbleThem = DarkColorPalette.onSurface.copy(alpha = 0.18f)
private val LightChatBubbleThem = LightColorPalette.onSurface.copy(alpha = 0.11f)
private val DarkChatDraftBackground = DarkColorPalette.onSurface.copy(alpha = 0.15f)
private val LightChatDraftBackground = LightColorPalette.onSurface.copy(alpha = 0.15f)
@@ -470,6 +475,15 @@ val ColorScheme.subtleBorder: Color
val ColorScheme.chatBackground: Color
get() = if (isLight) LightChatBackground else DarkChatBackground
// Accent-following bubble fill for the logged-in user's own chat messages. Stronger
// than mediumImportanceLink so "mine" vs "theirs" vs background read at a glance
// while the default onBackground text stays readable on top of it.
val ColorScheme.chatBubbleMe: Color
get() = primary.copy(alpha = if (isLight) 0.36f else 0.45f)
val ColorScheme.chatBubbleThem: Color
get() = if (isLight) LightChatBubbleThem else DarkChatBubbleThem
val ColorScheme.chatDraftBackground: Color
get() = if (isLight) LightChatDraftBackground else DarkChatDraftBackground
@@ -1580,6 +1580,8 @@
<string name="messages_create_public_chat">عام</string>
<string name="messages_create_group">مجموعة</string>
<string name="messages_create_public_private_chat_description">مجموعة عامة أو خاصة جديدة</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">خاص</string>
<string name="messages_new_message_to">إلى</string>
<string name="messages_new_message_subject">الموضوع</string>
@@ -1510,6 +1510,8 @@
<string name="messages_create_public_chat">সর্বজনীন</string>
<string name="messages_create_group">গ্রুপ</string>
<string name="messages_create_public_private_chat_description">নতুন পাবলিক বা প্রাইভেট গ্রুপ</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">ব্যক্তিগত</string>
<string name="messages_new_message_to">প্রতি</string>
<string name="messages_new_message_subject">বিষয়</string>
@@ -1898,6 +1898,8 @@
<string name="messages_create_public_chat">Veřejné</string>
<string name="messages_create_group">Skupina</string>
<string name="messages_create_public_private_chat_description">Nová veřejná nebo soukromá skupina</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">Skupiny relay</string>
<string name="relay_group_view_inline">V řadě</string>
<string name="relay_group_view_grouped">Podle relay</string>
@@ -1823,6 +1823,8 @@
<string name="messages_create_public_chat">Öffentlich</string>
<string name="messages_create_group">Gruppe</string>
<string name="messages_create_public_private_chat_description">Neue öffentliche oder private Gruppe</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">Relay-Gruppen</string>
<string name="relay_group_view_grouped">Nach Relay</string>
<string name="relay_group_view_inline_desc">Zeigt jede Gruppe als eigene Unterhaltung, gemischt mit deinen Chats.</string>
@@ -1495,6 +1495,8 @@
<string name="messages_create_public_chat">Δημόσια</string>
<string name="messages_create_group">Ομάδα</string>
<string name="messages_create_public_private_chat_description">Νέα δημόσια ή ιδιωτική ομάδα</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Ιδιωτικό</string>
<string name="messages_new_message_to">Προς</string>
<string name="messages_new_message_subject">Θέμα</string>
@@ -29,6 +29,8 @@
<!-- Settings search keywords: an English concept/protocol index that supplements the
(translated) row titles. Marked translatable="false" so protocol terms (blossom, nsec,
negentropy, …) stay searchable in every locale and aren't sent to translators. -->
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<!-- Onchain zap send dialog -->
<!-- %1$s is replaced at runtime by an inline star icon, not text. Keep the placeholder. -->
<!-- Kind display names for relay subscription filter chips -->
@@ -1516,6 +1516,8 @@
<string name="messages_create_public_chat">Publika</string>
<string name="messages_create_group">Grupo</string>
<string name="messages_create_public_private_chat_description">Nova Publika aŭ Privata Grupo</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privata</string>
<string name="messages_new_message_to">Al</string>
<string name="messages_new_message_subject">Temo</string>
@@ -1544,6 +1544,8 @@
<string name="messages_create_public_chat">Público</string>
<string name="messages_create_group">Grupo</string>
<string name="messages_create_public_private_chat_description">Nuevo grupo público o privado</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privado</string>
<string name="messages_new_message_to">Para</string>
<string name="messages_new_message_subject">Asunto</string>
@@ -1535,6 +1535,8 @@
<string name="messages_create_public_chat">Público</string>
<string name="messages_create_group">Grupo</string>
<string name="messages_create_public_private_chat_description">Nuevo grupo público o privado</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privado</string>
<string name="messages_new_message_to">Para</string>
<string name="messages_new_message_subject">Asunto</string>
@@ -1535,6 +1535,8 @@
<string name="messages_create_public_chat">Público</string>
<string name="messages_create_group">Grupo</string>
<string name="messages_create_public_private_chat_description">Nuevo grupo público o privado</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privado</string>
<string name="messages_new_message_to">Para</string>
<string name="messages_new_message_subject">Asunto</string>
@@ -1517,6 +1517,8 @@
<string name="messages_create_public_chat">عمومی</string>
<string name="messages_create_group">گروه</string>
<string name="messages_create_public_private_chat_description">گروه خصوصی یا عمومی جدید</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">خصوصی</string>
<string name="messages_new_message_to">به</string>
<string name="messages_new_message_subject">موضوع</string>
@@ -1498,6 +1498,8 @@
<string name="messages_create_public_chat">Julkinen</string>
<string name="messages_create_group">Ryhmä</string>
<string name="messages_create_public_private_chat_description">Uusi julkinen tai yksityinen ryhmä</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Yksityinen</string>
<string name="messages_new_message_to">Vastaanottaja</string>
<string name="messages_new_message_subject">Aihe</string>
@@ -1461,6 +1461,8 @@
<string name="add_sensitive_content_description_placeholder">Raison (optionnel)</string>
<string name="messages_create_group">Groupe</string>
<string name="messages_create_public_private_chat_description">Nouveau Groupe Public ou Privé</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privé</string>
<string name="messages_new_message_to">À</string>
<string name="messages_new_message_subject">Sujet</string>
@@ -1689,6 +1689,8 @@
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_group">Groupe</string>
<string name="messages_create_public_private_chat_description">Nouveau Groupe Public ou Privé</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_group_create_confirm">Créer</string>
<string name="relay_group_invite_title">Inviter des gens</string>
<string name="relay_group_invite_description">Partager ce code pour que les gens puissent rejoindre ce groupe.</string>
@@ -1859,6 +1859,8 @@
<string name="messages_create_public_chat">सार्वजनिक</string>
<string name="messages_create_group">समूह</string>
<string name="messages_create_public_private_chat_description">नया निजी अथवा सार्वजनिक झुण्ड</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">पुनःप्रसारक समूह</string>
<string name="relay_group_view_inline">पंक्ति में</string>
<string name="relay_group_view_grouped">पुनःप्रसारक अनुसार</string>
@@ -1860,6 +1860,8 @@
<string name="messages_create_public_chat">Nyilvános</string>
<string name="messages_create_group">Csoport</string>
<string name="messages_create_public_private_chat_description">Új nyilvános vagy privát csoport</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">Átjátszócsoportok</string>
<string name="relay_group_view_inline">Beágyazott</string>
<string name="relay_group_view_grouped">Átjátszó szerint</string>
@@ -1463,6 +1463,8 @@ Beri tahu kepada pemilik untuk menambahnya</string>
<string name="messages_create_public_chat">Publik</string>
<string name="messages_create_group">Grup</string>
<string name="messages_create_public_private_chat_description">Grup Publik atau Privat Baru</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privat</string>
<string name="messages_new_message_to">Ke</string>
<string name="messages_new_message_subject">Subjek</string>
@@ -1479,6 +1479,8 @@
<string name="messages_create_public_chat">Pubblico/a</string>
<string name="messages_create_group">Gruppo</string>
<string name="messages_create_public_private_chat_description">Nuovo gruppo pubblico o privato</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privato</string>
<string name="messages_new_message_to">A</string>
<string name="messages_new_message_subject">Soggetto</string>
@@ -1495,6 +1495,8 @@
<string name="messages_create_public_chat">公開</string>
<string name="messages_create_group">グループ</string>
<string name="messages_create_public_private_chat_description">新しい公開またはプライベートグループ</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">プライベート</string>
<string name="messages_new_message_to">宛先</string>
<string name="messages_new_message_subject">件名</string>
@@ -1487,6 +1487,8 @@
<string name="messages_create_public_chat">공개</string>
<string name="messages_create_group">그룹</string>
<string name="messages_create_public_private_chat_description">새 공개 또는 비공개 그룹</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">비공개</string>
<string name="messages_new_message_to">받는 사람</string>
<string name="messages_new_message_subject">제목</string>
@@ -1518,6 +1518,8 @@
<string name="messages_create_public_chat">Publisks</string>
<string name="messages_create_group">Grupa</string>
<string name="messages_create_public_private_chat_description">Jauna publiska vai privāta grupa</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privāts</string>
<string name="messages_new_message_to">Kam</string>
<string name="messages_new_message_subject">Temats</string>
@@ -1728,6 +1728,8 @@
<string name="messages_create_public_chat">Publiek</string>
<string name="messages_create_group">Groep</string>
<string name="messages_create_public_private_chat_description">Nieuwe openbare of besloten groep</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privé</string>
<string name="messages_new_message_to">Aan</string>
<string name="messages_new_message_subject">Onderwerp</string>
@@ -51,6 +51,7 @@
<string name="timestamp_it">Znacznik czasowy</string>
<string name="timestamp_pending">Znacznik czasowy: Oczekujące potwierdzenia</string>
<string name="timestamp_pending_short">OTS: Oczekuje</string>
<string name="one_year_plus">1 rok +</string>
<string name="request_deletion">Poproś o usunięcie</string>
<string name="block_report">Zablokuj / Zgłoś</string>
<string name="block_hide_user"><![CDATA[Zablokuj i ukryj użytkownika]]></string>
@@ -300,9 +301,90 @@
<string name="already_have_an_account">Masz już konto Nostr?</string>
<string name="loading_feed">Wczytywanie zawartości</string>
<string name="loading_account">Ładowanie konta</string>
<string name="concord_redeeming_invite">Realizacja zaproszenia…</string>
<string name="concord_invite_failed">Nie można pobrać tego zaproszenia. Być może link stracił ważność lub transmitery są niedostępne.</string>
<string name="concord_home_title">Kanały Concord</string>
<string name="concord_home_empty">Nie dołączyłeś(aś) jeszcze do kanału Concord. Utwórz kanał lub otwórz link z zaproszeniem.</string>
<string name="concord_channels_empty">Brak kanałów.</string>
<string name="concord_show_all_channels">Pokaż wszystkie kanały</string>
<string name="concord_send_image_title">Wyślij zdjęcie</string>
<string name="concord_open_channel">Otwórz kanał</string>
<string name="concord_edit_banner_hint">Dodaj baner</string>
<string name="concord_channel_create">Nowy kanał</string>
<string name="concord_channel_rename">Zmień nazwę kanału</string>
<string name="concord_channel_rename_save">Zmień nazwę</string>
<string name="concord_channel_name_label">Nazwa kanału</string>
<string name="concord_channel_delete">Usuń kanał</string>
<string name="concord_channel_delete_title">Usunąć kanał?</string>
<string name="concord_channel_delete_message">Usunąć #%1$s? Tej operacji nie można cofnąć i nie będzie można odtworzyć kanału z tym samym identyfikatorem.</string>
<string name="concord_channel_delete_confirm">Usuń</string>
<string name="concord_edit_relays_desc">Miejsce, w którym publikowane i czytane są zaszyfrowane plany tej społeczności.</string>
<string name="concord_typing_one">%1$s pisze…</string>
<string name="concord_typing_two">%1$s i %2$s piszą…</string>
<string name="concord_typing_many">Kilka osób pisze…</string>
<string name="concord_create_title">Nowy kanał Concord</string>
<string name="concord_create_name">Nazwa</string>
<string name="concord_create_about">Informacje (opcjonalne)</string>
<string name="concord_create_relays">Transmitery</string>
<string name="concord_create_icon">URL ikony (opcjonalnie)</string>
<plurals name="concord_channel_count">
<item quantity="one">%1$d kanał</item>
<item quantity="few">%1$d kanałów</item>
<item quantity="many">%1$d kanałów</item>
<item quantity="other">%1$d kanały</item>
</plurals>
<plurals name="concord_member_count">
<item quantity="one">%1$d członek</item>
<item quantity="few">%1$d członków</item>
<item quantity="many">%1$d członków</item>
<item quantity="other">%1$d członkowie</item>
</plurals>
<string name="concord_create_action">Utwórz</string>
<string name="concord_invite_action">Zaproś osoby</string>
<string name="concord_invite_title">Link do zaproszenia</string>
<string name="concord_make_admin">Stwórz administratora</string>
<string name="concord_remove_admin">Usuń administratora</string>
<string name="concord_ban_user">Banuj</string>
<string name="concord_ban_user_title">Wykluczyć z tej społeczności?</string>
<string name="concord_ban_user_body">Ten użytkownik zostanie dodany do listy zablokowanych. Jego wiadomości zostaną ukryte, a przyszłe posty będą odrzucane przez innych członków. Możesz później cofnąć blokadę.</string>
<string name="concord_create_icon_hint">Ustaw ikonę społeczności</string>
<string name="concord_create_relays_desc">Transmitery, które przechowują zaszyfrowane wiadomości tej społeczności. Pozostaw puste, aby użyć własnych.</string>
<string name="concord_edit_title">Edytuj społeczność</string>
<string name="concord_edit_save">Zapisz</string>
<string name="concord_members_title">Członkowie</string>
<string name="concord_members_empty">Nie ma jeszcze żadnych właścicieli, administratorów ani zablokowanych użytkowników.</string>
<string name="concord_members_make_admin">Stwórz administratora</string>
<string name="concord_members_remove_admin">Usuń administratora</string>
<string name="concord_members_ban">Banuj</string>
<string name="concord_members_unban">Odbanuj</string>
<string name="concord_members_remove">Usuń ze społeczności</string>
<string name="concord_members_remove_title">Usunąć członka?</string>
<string name="concord_members_remove_message">Spowoduje to zmianę klucza szyfrującego społeczności, przez co ten użytkownik nie będzie już mógł odczytać żadnych wiadomości wysłanych po tej zmianie. Klucze pozostałych użytkowników zostaną automatycznie zaktualizowane. Czynności tej nie można cofnąć.</string>
<string name="concord_members_remove_confirm">Usuń</string>
<string name="concord_role_owner">Właściciel</string>
<string name="concord_role_admin">Admin</string>
<string name="concord_role_banned">Zbanowany</string>
<string name="concord_invite_card_join">Dołącz do społeczności</string>
<string name="concord_invite_card_subtitle">Zaproszenie dla społeczności Concord</string>
<string name="concord_invite_naddr_label">Zaproszenie do Concord (kliknij link, aby wyświetlić pełną treść zaproszenia i dołączyć)</string>
<string name="concord_server_label">Concord</string>
<string name="concord_view_mode_title">Pokaż społeczność Concord</string>
<string name="concord_view_inline">Inline</string>
<string name="concord_view_grouped">Wg społeczności</string>
<string name="concord_view_inline_desc">Wyświetlaj każdy kanał jako osobną rozmowę, umieszczoną wśród Twoich czatów.</string>
<string name="concord_view_grouped_desc">Zwiń kanały każdej społeczności do jednego wiersza, umieszczając je przy najnowszej wiadomości.</string>
<!-- Reply-mode toggle in the chat composer: reply inline in the timeline vs pull it aside into a thread. -->
<string name="chat_reply_in_chat">W czacie</string>
<string name="chat_reply_in_thread">W wątku</string>
<!-- Title of the minichat (thread) screen opened from a chat message. -->
<string name="chat_minichat_title">Wątek</string>
<!-- Chip on a chat message that opens its thread ("minichat") of kind-1111 replies. -->
<plurals name="chat_minichat_reply_count">
<item quantity="one">%1$d odpowiedź</item>
<item quantity="few">%1$d odpowiedzi</item>
<item quantity="many">%1$d odpowiedzi</item>
<item quantity="other">%1$d odpowiedzi</item>
</plurals>
<string name="chats_history_proto_nip17">zaszyfrowane</string>
<string name="chats_history_proto_nip04">starsza wersja</string>
<string name="chats_reply_searching_history">Szukam oryginalnej wiadomości…</string>
@@ -329,6 +411,7 @@
Świetnie nadają się do tworzenia otwartych społeczności wokół określonych tematów. Niektóre z tych grup są efemeryczne
i dlatego wiadomości czatu znikają z czasem</string>
<string name="public_chat">Czat Publiczny</string>
<string name="new_public_chat">Utwórz nowy czat publiczny</string>
<string name="marmot_group">Grupa MLS</string>
<string name="marmot_group_no_messages_yet">Brak wiadomości</string>
<string name="public_chat_title">Metadane Czatu Publicznego</string>
@@ -1220,6 +1303,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="public_bookmark_presence_indicator">jest zakładką publiczną</string>
<string name="private_bookmark_presence_indicator">jest prywatną zakładką</string>
<string name="private_rumor_mark">Prywatny — widoczny tylko dla oznaczonych uczestników</string>
<string name="private_rumor_info_title">Notatka prywatna</string>
<string name="private_rumor_info_description">Ta notatka została przekazana prywatnie i nie jest podpisana. Widzą ją wyłącznie oznaczeni uczestnicy, a nikt nie może ujawnić osobom postronnym, kto ją napisał.</string>
<string name="private_note">Ustaw jako prywatny: udostępnij wpis wyłącznie wskazanym użytkownikom</string>
<string name="disable_private_note">Upublicznij</string>
<string name="private_note_locked">Odpowiedzi na wpis prywatny zawsze pozostają prywatne</string>
@@ -1227,6 +1312,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="private_note_no_receivers">Nie ma jeszcze odbiorców: ten wpis zobaczysz tylko Ty. Dodaj osoby, którym chcesz go udostępnić.</string>
<string name="notify_add_user">Dodaj</string>
<string name="notify_remove_user">Usuń użytkownika z powiadomień</string>
<string name="notify_mute_user">Powiadomienie. Kliknij, aby wyciszyć powiadomienie od tego użytkownika</string>
<string name="notify_unmute_user">Wyciszone. Kliknij, aby ponownie powiadomić tego użytkownika</string>
<string name="notify_search_and_add_user">Wyszukaj i dodaj użytkownika do powiadomień</string>
<string name="bookmark_absence_indicator">nie ma tu zakładki</string>
<string name="bookmark_remove_action_desc">Usuń zakładkę z listy</string>
@@ -1812,6 +1899,65 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="messages_create_public_chat">Publiczna</string>
<string name="messages_create_group">Grupa</string>
<string name="messages_create_public_private_chat_description">Nowa Grupa Publiczna lub Prywatna</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="new_conversation_title">Rozpocznij rozmowę</string>
<string name="new_conversation_best_for">Najlepsza dla</string>
<string name="new_conversation_pros">Dobra</string>
<string name="new_conversation_cons">Kompromisy</string>
<string name="new_conversation_section_direct">Prywatna</string>
<string name="new_conversation_section_encrypted">Zaszyfrowane grupy</string>
<string name="new_conversation_section_relay">Z uwzględnieniem transmitera</string>
<string name="new_conversation_dm_title">Prywatna Wiadomość</string>
<string name="new_conversation_dm_tagline">Wiadomości bezpośrednie, zaszyfrowane end-to-end</string>
<string name="new_conversation_dm_chip">1:1–5 pomieszczeń</string>
<string name="new_conversation_dm_best">Pojedyncze i małe grupowe czaty z ludźmi, których znasz.</string>
<string name="new_conversation_dm_cta">Nowa wiadomość</string>
<string name="new_conversation_dm_pro_1">1-on-1 &amp; małe grupy</string>
<string name="new_conversation_dm_pro_2">Zaszyfrowane end-to-end</string>
<string name="new_conversation_dm_con_1">Nie można później dodać użytkowników</string>
<string name="new_conversation_marmot_title">Grupa Marmot</string>
<string name="new_conversation_marmot_tagline">Większe grupy prywatne</string>
<string name="new_conversation_marmot_chip">Ograniczone przez urządzenie</string>
<string name="new_conversation_marmot_best">Większe grupy prywatne, które przerosły małe grupy DM.</string>
<string name="new_conversation_marmot_cta">Utwórz Grupę</string>
<string name="new_conversation_marmot_pro_1">Powiększa małe DMs</string>
<string name="new_conversation_marmot_pro_2">Szyfrowany czat grupowy</string>
<string name="new_conversation_marmot_con_1">Ograniczone przez urządzenie — nie pojawi się, jeśli zalogujesz się gdzie indziej</string>
<string name="new_conversation_concord_title">Społeczność Concord</string>
<string name="new_conversation_concord_tagline">Społeczności podzielone według kanałów</string>
<string name="new_conversation_concord_chip">Przestrzeń robocza</string>
<string name="new_conversation_concord_best">Wielkie społeczności, zorganizowane według kanałów lub obszarów działania.</string>
<string name="new_conversation_concord_cta">Utwórz społeczność</string>
<string name="new_conversation_concord_pro_1">Skaluje się do bardzo dużych społeczności</string>
<string name="new_conversation_concord_pro_2">Podział według kanału / obszaru działania</string>
<string name="new_conversation_concord_con_1">Więcej opcji konfiguracji</string>
<string name="new_conversation_public_chat_title">Czat Publiczny</string>
<string name="new_conversation_public_chat_tagline">Wiadomości publiczne na transmiterze</string>
<string name="new_conversation_public_chat_chip">Niemoderowane</string>
<string name="new_conversation_public_chat_best">Otwarte fora publiczne, na których każdy może publikować posty.</string>
<string name="new_conversation_public_chat_cta">Utwórz czat publiczny</string>
<string name="new_conversation_public_chat_pro_1">Każdy może dołączyć</string>
<string name="new_conversation_public_chat_pro_2">Prosty &amp; łatwy do znalezienia</string>
<string name="new_conversation_public_chat_con_1">Tylko publiczne</string>
<string name="new_conversation_public_chat_con_2">Brak moderacji</string>
<string name="new_conversation_relay_group_title">Grupa Transmiterów</string>
<string name="new_conversation_relay_group_tagline">Obsługiwane przez transmiter, publiczne lub prywatne</string>
<string name="new_conversation_relay_group_chip">Moderowane</string>
<string name="new_conversation_relay_group_best">Grupy moderowane przez transmiter, na którym się znajdują.</string>
<string name="new_conversation_relay_group_cta">Pokaż grupy transmiterów</string>
<string name="new_conversation_relay_group_pro_1">Moderowane przez transmiter</string>
<string name="new_conversation_relay_group_pro_2">Publiczne lub prywatne</string>
<string name="new_conversation_relay_group_con_1">Związany z tym jednym transmiterem</string>
<string name="new_conversation_ephemeral_title">Czat efemeryczny</string>
<string name="new_conversation_ephemeral_tagline">Ktokolwiek jest teraz online</string>
<string name="new_conversation_ephemeral_chip">Na żywo</string>
<string name="new_conversation_ephemeral_best">Czat na żywo z każdym, kto jest właśnie online.</string>
<string name="new_conversation_ephemeral_cta">Rozpocznij czat</string>
<string name="new_conversation_ephemeral_pro_1">Porozmawiaj z osobami, które są teraz online</string>
<string name="new_conversation_ephemeral_pro_2">Nic nie zostało zapisane</string>
<string name="new_conversation_ephemeral_con_1">Brak historii</string>
<string name="new_conversation_ephemeral_con_2">W zakresie transmitera</string>
<string name="relay_groups_title">Grupy Transmiterów</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">Wg transmitera</string>
@@ -1858,6 +2004,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="relay_group_threads_title">Wątki</string>
<string name="relay_group_open">Grupa otwarta</string>
<string name="relay_group_channels_empty">Brak grup na tym przekaźniku transmiterze.</string>
<string name="relay_group_channels_not_nip29">Ten transmiter nie informuje o obsłudze grup (NIP-29), więc może ich nie obsługiwać.</string>
<string name="relay_group_relay_not_nip29">Może nie obsługiwać grup (NIP-29)</string>
<string name="relay_group_invite_preparing">Przygotowywanie zaproszenia…</string>
<string name="relay_group_badge_private">Prywatna</string>
<string name="relay_group_badge_invite_only">Tylko na zaproszenie</string>
@@ -2544,8 +2692,23 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="bottom_bar_settings">Dolny pasek nawigacji</string>
<string name="bottom_bar_settings_description">Przeciągnij, aby zmienić kolejność. Przełącz, aby dodać lub usunąć element z dolnego paska. Gdy nie ma żadnych elementów, dolny pasek jest ukryty.</string>
<string name="bottom_bar_settings_available">Dostępne</string>
<string name="bottom_bar_settings_pinned">Pasek dolny</string>
<string name="bottom_bar_settings_pinned_empty">Brak przypiętych elementów. Dolny pasek jest ukryty dopóki nie dodasz co najmniej jednego.</string>
<string name="bottom_bar_settings_reorder">Zmień kolejność</string>
<string name="bottom_bar_settings_expand">Pokaż opcje</string>
<string name="bottom_bar_settings_no_favorites">Brak ulubionych. Oznacz aplikację w przeglądarce, aby dodać ją tutaj.</string>
<string name="bottom_bar_settings_no_groups">Nie dołączyłeś(aś) jeszcze do żadnej grupy.</string>
<string name="bottom_bar_settings_reorder_hint">Przeciągnij, aby zmienić kolejność · kliknij ✕, aby usunąć</string>
<string name="bottom_bar_settings_add">Dodaj</string>
<string name="bottom_bar_settings_added">Dodano</string>
<string name="bottom_bar_settings_remove">Usuń</string>
<string name="bottom_bar_settings_restore_default">Przywróć domyślne ustawienia</string>
<string name="bottom_bar_category_main">Główny</string>
<string name="bottom_bar_category_chats">Czaty &amp; Grupy</string>
<string name="bottom_bar_category_you">Twoje konto</string>
<string name="bottom_bar_category_feeds">Kanały</string>
<string name="bottom_bar_category_apps">Aplikacje &amp; Strony</string>
<string name="bottom_bar_category_other">Inne</string>
<string name="home_tabs_settings">Karty główne</string>
<string name="home_tabs_settings_description">Wybierz, które karty pojawiają się na ekranie głównym. Gdy tylko jedna karta jest aktywna, pasek karty jest ukryty.</string>
<string name="home_tab_everything">Wszystko</string>
@@ -2618,6 +2781,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="add_expiration_date">Dodaj datę wygaśnięcia</string>
<string name="remove_expiration_date">Usuń datę wygaśnięcia</string>
<string name="expiration_date_label">Data wygaśnięcia</string>
<string name="expiration_info_description">Autor poprosił transmitery o usunięcie tego wpisu po upływie %1$s. Zniknie on z kanałów po upływie tego czasu.</string>
<string name="expiration_date_explainer">Post zostanie ukryty przez klientów po tej dacie (NIP-40)</string>
<string name="expiration_date_select">Wybierz datę i godzinę wygaśnięcia</string>
<string name="expiration_expires_in">Wygasa za %1$s</string>
@@ -2803,6 +2967,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="nsite_open">Otwórz</string>
<string name="ots_info_title">Potwierdzenie znacznika czasu</string>
<string name="ots_info_description">Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie.</string>
<string name="ots_info_pending_description">Zgłoszono prośbę o potwierdzenie czasu publikacji tego wpisu, które oczekuje na wpisanie do łańcucha bloków Bitcoin. Zazwyczaj proces ten trwa kilka godzin.</string>
<string name="edit_article">Redaguj artykuł</string>
<string name="edit_post">Edytuj wpis</string>
<string name="proposal_to_edit">Propozycja ulepszenia wpisu</string>
@@ -2819,6 +2984,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="it_s_not_possible_to_react_to_a_draft_note">Nie jest możliwe reagowanie na szkic wpisu</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">Nie jest możliwy zap dla szkicu wpisu</string>
<string name="draft_note">Szkic wpisu</string>
<string name="draft">Szkic</string>
<string name="bounty_label">Nagroda</string>
<string name="load_from_text">Z sms-a</string>
<string name="dvm_looking_for_app">Szukanie aplikacji</string>
<string name="dvm_waiting_status">Wysłano zapytanie, oczekiwanie na odpowiedź</string>
@@ -2941,6 +3108,12 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
<string name="crashreport_found">Znaleziono raport o błędzie</string>
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Czy chcesz wysłać ostatni raport o awarii do Amethyst w DM? Żadne dane osobowe nie będą udostępnione</string>
<string name="crashreport_found_send">Prześlij</string>
<string name="resource_usage_title">Wykorzystanie zasobów aplikacji</string>
<string name="resource_usage_search_keywords">bateria, dane, zużycie, moc, diagnostyka sieci, rejestr</string>
<string name="resource_usage_today">Dzisiaj</string>
<string name="resource_usage_week">Ostatnie 7 dni</string>
<string name="resource_usage_tile_cellular">Komórka dzisiaj</string>
<string name="resource_usage_tile_wifi">Wi-Fi dzisiaj</string>
<string name="this_message_will_disappear_in">Ta wiadomość zniknie za %1$s</string>
<string name="select_signer">Wybierz Sygnatariusza</string>
<string name="in_the_list">Już jest na liście</string>
@@ -1827,6 +1827,8 @@
<string name="messages_create_public_chat">Público</string>
<string name="messages_create_group">Grupo</string>
<string name="messages_create_public_private_chat_description">Novo Grupo Público ou Privado</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">Grupos de relay</string>
<string name="relay_group_view_inline">Em linha</string>
<string name="relay_group_view_grouped">Por relay</string>
@@ -1491,6 +1491,8 @@
<string name="messages_create_public_chat">Público</string>
<string name="messages_create_group">Grupo</string>
<string name="messages_create_public_private_chat_description">Novo Grupo Público ou Privado</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privado</string>
<string name="messages_new_message_to">Para</string>
<string name="messages_new_message_subject">Assunto</string>
@@ -1541,6 +1541,8 @@
<string name="messages_create_public_chat">Публичный</string>
<string name="messages_create_group">Группа</string>
<string name="messages_create_public_private_chat_description">Новая публичная или приватная группа</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Приватный</string>
<string name="messages_new_message_to">Кому</string>
<string name="messages_new_message_subject">Заголовок</string>
@@ -1525,6 +1525,8 @@
<string name="messages_create_public_chat">Публичный</string>
<string name="messages_create_group">Группа</string>
<string name="messages_create_public_private_chat_description">Новая публичная или приватная группа</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Личный</string>
<string name="messages_new_message_to">Кому</string>
<string name="messages_new_message_subject">Тема</string>
@@ -1824,6 +1824,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="messages_create_public_chat">Javno</string>
<string name="messages_create_group">Skupina</string>
<string name="messages_create_public_private_chat_description">Nova javna ali zasebna skupina</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">Skupine relejev</string>
<string name="relay_group_view_inline">V vrstici</string>
<string name="relay_group_view_grouped">Po releju</string>
@@ -1507,6 +1507,8 @@
<string name="messages_create_public_chat">Javno</string>
<string name="messages_create_group">Grupa</string>
<string name="messages_create_public_private_chat_description">Nova javna ili privatna grupa</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Privatno</string>
<string name="messages_new_message_to">Za</string>
<string name="messages_new_message_subject">Tema</string>
@@ -1826,6 +1826,8 @@
<string name="messages_create_public_chat">Publik</string>
<string name="messages_create_group">Grupp</string>
<string name="messages_create_public_private_chat_description">Ny offentlig eller privat grupp</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">Relägrupper</string>
<string name="relay_group_view_inline">Infogad</string>
<string name="relay_group_view_grouped">Efter relä</string>
@@ -1501,6 +1501,8 @@
<string name="messages_create_public_chat">Umma</string>
<string name="messages_create_group">Kikundi</string>
<string name="messages_create_public_private_chat_description">Kikundi Kipya cha Umma au Binafsi</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Binafsi</string>
<string name="messages_new_message_to">Kwa</string>
<string name="messages_new_message_subject">Somo</string>
@@ -1504,6 +1504,8 @@
<string name="messages_create_public_chat">பொது</string>
<string name="messages_create_group">குழு</string>
<string name="messages_create_public_private_chat_description">புதிய பொது அல்லது தனியார் குழு</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">தனியார்</string>
<string name="messages_new_message_to">பெறுநர்</string>
<string name="messages_new_message_subject">பொருள்</string>
@@ -1473,6 +1473,8 @@
<string name="messages_create_public_chat">สาธรณะ</string>
<string name="messages_create_group">กลุ่ม</string>
<string name="messages_create_public_private_chat_description">สร้างกลุ่มสาธารณะ/ส่วนตัว</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">ส่วนตัว</string>
<string name="messages_new_message_to">ถึง</string>
<string name="messages_new_message_subject">ชื่อเรื่อง</string>
@@ -1506,6 +1506,8 @@
<string name="messages_create_public_chat">Herkese Açık</string>
<string name="messages_create_group">Grup</string>
<string name="messages_create_public_private_chat_description">Yeni Herkese Açık veya Gizli Grup</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Özel</string>
<string name="messages_new_message_to">Kime</string>
<string name="messages_new_message_subject">Konu</string>
@@ -1537,6 +1537,8 @@
<string name="messages_create_public_chat">Публічний</string>
<string name="messages_create_group">Група</string>
<string name="messages_create_public_private_chat_description">Нова публічна або приватна група</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Приватний</string>
<string name="messages_new_message_to">Кому</string>
<string name="messages_new_message_subject">Тема</string>
@@ -1506,6 +1506,8 @@
<string name="messages_create_public_chat">Ommaviy</string>
<string name="messages_create_group">Guruh</string>
<string name="messages_create_public_private_chat_description">Yangi ommaviy yoki shaxsiy guruh</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Shaxsiy</string>
<string name="messages_new_message_to">Kimga</string>
<string name="messages_new_message_subject">Mavzu</string>
@@ -1474,6 +1474,8 @@
<string name="messages_create_public_chat">Công khai</string>
<string name="messages_create_group">Nhóm</string>
<string name="messages_create_public_private_chat_description">Nhóm công khai hoặc riêng tư mới</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">Riêng tư</string>
<string name="messages_new_message_to">Đến</string>
<string name="messages_new_message_subject">Chủ đề</string>
@@ -394,7 +394,7 @@
因此聊天信息会随着时间的推移而消失。</string>
<string name="public_chat">公共聊天</string>
<string name="new_public_chat">创建新的公开聊天</string>
<string name="marmot_group">MLS 组</string>
<string name="marmot_group">Marmot 群</string>
<string name="marmot_group_no_messages_yet">尚无消息</string>
<string name="public_chat_title">公开聊天元数据</string>
<string name="public_chat_explainer">公开聊天对所有 Nostr 用户都是可见的,任何人都可以:
@@ -1838,6 +1838,8 @@
<string name="messages_create_public_chat">公开</string>
<string name="messages_create_group">组</string>
<string name="messages_create_public_private_chat_description">新建公开或私人群组</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="relay_groups_title">中继组</string>
<string name="relay_group_view_inline">行内</string>
<string name="relay_group_view_grouped">根据中继</string>
@@ -3738,6 +3740,10 @@
<string name="marmot_group_name_placeholder">输入群名称</string>
<string name="marmot_group_description_placeholder">输入群描述(可选)</string>
<string name="marmot_edit_info_footer">更改将通过 MLS 提交到该群,并传播给所有成员。</string>
<string name="marmot_group_icon">群组图标</string>
<string name="marmot_add_photo">添加照片</string>
<string name="marmot_change_photo">更改照片</string>
<string name="marmot_remove_photo">删除照片</string>
<string name="marmot_group_info_updated">群信息已更新</string>
<string name="marmot_failed_to_update">更新失败: %1$s</string>
<string name="marmot_failed_to_create_group">创建群失败: %1$s</string>
@@ -1507,6 +1507,8 @@
<string name="messages_create_public_chat">公开</string>
<string name="messages_create_group">组</string>
<string name="messages_create_public_private_chat_description">新建公开或私人群组</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">私人</string>
<string name="messages_new_message_to">至</string>
<string name="messages_new_message_subject">主题</string>
@@ -1507,6 +1507,8 @@
<string name="messages_create_public_chat">公开</string>
<string name="messages_create_group">组</string>
<string name="messages_create_public_private_chat_description">新建公开或私人群组</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">私人</string>
<string name="messages_new_message_to">至</string>
<string name="messages_new_message_subject">主题</string>
@@ -1498,6 +1498,8 @@
<string name="messages_create_public_chat">公開</string>
<string name="messages_create_group">群組</string>
<string name="messages_create_public_private_chat_description">新建公開或私人群組</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="messages_new_message">私人</string>
<string name="messages_new_message_to">至</string>
<string name="messages_new_message_subject">主題</string>
+95 -1
View File
@@ -104,6 +104,7 @@
<string name="security_filters">Security Filters</string>
<string name="log_out">Logout</string>
<string name="show_more">Show More</string>
<string name="show_less">Show Less</string>
<string name="lightning_invoice">Lightning Invoice</string>
<string name="invoice_expired">Expired</string>
<string name="clink_lightning_offer">CLINK Offer</string>
@@ -2049,6 +2050,72 @@
<string name="messages_create_public_chat">Public</string>
<string name="messages_create_group">Group</string>
<string name="messages_create_public_private_chat_description">New Public or Private Group</string>
<!-- New conversation chooser (full-screen picker opened from the Messages FAB).
Each row stays lean; the "best for" line and the pros/cons appear only when a row is tapped. -->
<string name="new_conversation_title">Start a conversation</string>
<string name="new_conversation_best_for">Best for</string>
<string name="new_conversation_pros">Good</string>
<string name="new_conversation_cons">Trade-offs</string>
<string name="new_conversation_section_direct">Private</string>
<string name="new_conversation_section_encrypted">Encrypted groups</string>
<string name="new_conversation_section_relay">Relay-aware</string>
<string name="new_conversation_dm_title">Private Message</string>
<string name="new_conversation_dm_tagline">Direct messages, end-to-end encrypted</string>
<string name="new_conversation_dm_chip">1:1–5 rooms</string>
<string name="new_conversation_dm_best">One-on-one and small group chats with people you know.</string>
<string name="new_conversation_dm_cta">New message</string>
<string name="new_conversation_dm_pro_1">1-on-1 &amp; small groups</string>
<string name="new_conversation_dm_pro_2">End-to-end encrypted</string>
<string name="new_conversation_dm_con_1">Can\'t add users later</string>
<string name="new_conversation_marmot_title">Marmot Group</string>
<string name="new_conversation_marmot_tagline">Bigger private groups</string>
<string name="new_conversation_marmot_chip">Device-bound</string>
<string name="new_conversation_marmot_best">Larger private groups that outgrow small-group DMs.</string>
<string name="new_conversation_marmot_cta">Create group</string>
<string name="new_conversation_marmot_pro_1">Scales past small DMs</string>
<string name="new_conversation_marmot_pro_2">Encrypted group chat</string>
<string name="new_conversation_marmot_con_1">Device-bound — won\'t appear if you sign in elsewhere</string>
<string name="new_conversation_concord_title">Concord Community</string>
<string name="new_conversation_concord_tagline">Communities split by channel</string>
<string name="new_conversation_concord_chip">Workspaces</string>
<string name="new_conversation_concord_best">Massive communities, organized by channels or workstreams.</string>
<string name="new_conversation_concord_cta">Create community</string>
<string name="new_conversation_concord_pro_1">Scales to huge communities</string>
<string name="new_conversation_concord_pro_2">Split by channel / workstream</string>
<string name="new_conversation_concord_con_1">More to set up</string>
<string name="new_conversation_public_chat_title">Public Chat</string>
<string name="new_conversation_public_chat_tagline">Public messages on a relay</string>
<string name="new_conversation_public_chat_chip">Unmoderated</string>
<string name="new_conversation_public_chat_best">Open public rooms where anyone can post.</string>
<string name="new_conversation_public_chat_cta">Create public chat</string>
<string name="new_conversation_public_chat_pro_1">Anyone can join</string>
<string name="new_conversation_public_chat_pro_2">Simple &amp; discoverable</string>
<string name="new_conversation_public_chat_con_1">Public only</string>
<string name="new_conversation_public_chat_con_2">No moderation</string>
<string name="new_conversation_relay_group_title">Relay Group</string>
<string name="new_conversation_relay_group_tagline">Relay-run, public or private</string>
<string name="new_conversation_relay_group_chip">Moderated</string>
<string name="new_conversation_relay_group_best">Groups moderated by the relay that hosts them.</string>
<string name="new_conversation_relay_group_cta">Browse relay groups</string>
<string name="new_conversation_relay_group_pro_1">Moderated by the relay</string>
<string name="new_conversation_relay_group_pro_2">Public or private</string>
<string name="new_conversation_relay_group_con_1">Tied to that one relay</string>
<string name="new_conversation_ephemeral_title">Disappearing Chat</string>
<string name="new_conversation_ephemeral_tagline">Whoever\'s online, right now</string>
<string name="new_conversation_ephemeral_chip">Live now</string>
<string name="new_conversation_ephemeral_best">In-the-moment chat with whoever is online right now.</string>
<string name="new_conversation_ephemeral_cta">Start chat</string>
<string name="new_conversation_ephemeral_pro_1">Talk to who\'s online now</string>
<string name="new_conversation_ephemeral_pro_2">Nothing is saved</string>
<string name="new_conversation_ephemeral_con_1">No history</string>
<string name="new_conversation_ephemeral_con_2">Relay-scoped</string>
<string name="relay_groups_title">Relay Groups</string>
<string name="relay_group_view_inline">Inline</string>
<string name="relay_group_view_grouped">By relay</string>
@@ -2822,10 +2889,25 @@
<string name="change_reaction">Change Quick Reactions</string>
<string name="bottom_bar_settings">Bottom Navigation Bar</string>
<string name="bottom_bar_settings_description">Drag to reorder. Toggle to add or remove an item from the bottom bar. With zero items the bottom bar is hidden.</string>
<string name="bottom_bar_settings_description">Drag the pinned items to reorder. Open a category below and toggle an item to add or remove it. Open Browser or a chat type to pin a favorite or a specific group. With zero items the bottom bar is hidden.</string>
<string name="bottom_bar_settings_available">Available</string>
<string name="bottom_bar_settings_pinned">Your bottom bar</string>
<string name="bottom_bar_settings_pinned_empty">No items pinned. The bottom bar is hidden until you add at least one.</string>
<string name="bottom_bar_settings_reorder">Reorder</string>
<string name="bottom_bar_settings_expand">Show options</string>
<string name="bottom_bar_settings_no_favorites">No favorites yet. Star an app in the Browser to add it here.</string>
<string name="bottom_bar_settings_no_groups">No joined groups yet.</string>
<string name="bottom_bar_settings_reorder_hint">Drag to reorder · tap ✕ to remove</string>
<string name="bottom_bar_settings_add">Add</string>
<string name="bottom_bar_settings_added">Added</string>
<string name="bottom_bar_settings_remove">Remove</string>
<string name="bottom_bar_settings_restore_default">Restore Default</string>
<string name="bottom_bar_category_main">Main</string>
<string name="bottom_bar_category_chats">Chats &amp; Groups</string>
<string name="bottom_bar_category_you">You</string>
<string name="bottom_bar_category_feeds">Feeds</string>
<string name="bottom_bar_category_apps">Apps &amp; Web</string>
<string name="bottom_bar_category_other">Other</string>
<string name="home_tabs_settings">Home Tabs</string>
<string name="home_tabs_settings_description">Pick which tabs appear on the Home screen. When only one tab is active the tab bar is hidden.</string>
<string name="home_tab_everything">Everything</string>
@@ -2904,6 +2986,18 @@
<string name="add_content_warning">Add content warning</string>
<string name="remove_content_warning">Remove content warning</string>
<string name="chat_system_created_channel">%1$s created the channel %2$s</string>
<string name="chat_system_created_channel_unnamed">%1$s created the channel</string>
<string name="chat_system_updated_channel">%1$s updated the channel profile</string>
<string name="chat_delivery_details_title">Message Delivery</string>
<string name="close">Close</string>
<string name="chat_delivery_pending">Waiting for a relay to accept this message</string>
<string name="chat_delivery_no_relay_info">No relay information for this message</string>
<string name="chat_delivery_accepted">Accepted by at least one relay</string>
<string name="chat_delivery_delivered_all">Delivered to all recipients\' relays</string>
<string name="add_expiration_date">Add expiration date</string>
<string name="remove_expiration_date">Remove expiration date</string>
<string name="expiration_date_label">Expiration Date</string>
@@ -0,0 +1,44 @@
/*
* 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.navigation
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarCategories
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The settings picker builds its grouped "Available" list from [BottomBarCategories], not from the raw
* catalog. If a newly added [NavBarCatalog] destination isn't placed in a category, it would silently
* vanish from the picker — this pins that every catalog id appears in exactly one category.
*/
class BottomBarCategoriesTest {
@Test
fun everyCatalogItemAppearsInExactlyOneCategory() {
val categorized = BottomBarCategories.flatMap { it.items }
// No duplicates across categories.
assertEquals("an item is listed in more than one category", categorized.size, categorized.toSet().size)
// Exact coverage of the catalog.
assertEquals(NavBarCatalog.keys.toSet(), categorized.toSet())
}
}
@@ -38,6 +38,9 @@ class BottomBarEntrySerializationTest {
listOf(
BottomBarEntry.BuiltIn(NavBarItem.HOME),
BottomBarEntry.Favorite("url:https://example.com"),
BottomBarEntry.PublicChat("25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb"),
BottomBarEntry.RelayGroup("abcd1234", "wss://groups.example.com"),
BottomBarEntry.Concord("f".repeat(64)),
)
@Test
@@ -45,6 +48,10 @@ class BottomBarEntrySerializationTest {
val json = JsonMapper.toJson(sample)
// Stable short names, NOT the fragile fully-qualified class name.
assertTrue("expected stable discriminators, got: $json", json.contains("\"builtIn\"") && json.contains("\"favorite\""))
assertTrue(
"expected group discriminators, got: $json",
json.contains("\"publicChat\"") && json.contains("\"relayGroup\"") && json.contains("\"concord\""),
)
assertEquals(sample, JsonMapper.fromJson<List<BottomBarEntry>>(json))
}
@@ -63,11 +70,17 @@ class BottomBarEntrySerializationTest {
runCatching { JsonMapper.fromJson<List<BottomBarEntry>>(legacy) }
.onSuccess { error("legacy discriminator unexpectedly decoded directly: $it") }
// ...but the migration (rewrite FQN -> short name) recovers the exact same config.
// ...but the migration (rewrite FQN -> short name) recovers the exact same config. The legacy
// format predates the group entries, so the recovered config is just the built-in + favorite.
val expected =
listOf(
BottomBarEntry.BuiltIn(NavBarItem.HOME),
BottomBarEntry.Favorite("url:https://example.com"),
)
val migrated =
legacy
.replace("com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.BuiltIn", "builtIn")
.replace("com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.Favorite", "favorite")
assertEquals(sample, JsonMapper.fromJson<List<BottomBarEntry>>(migrated))
assertEquals(expected, JsonMapper.fromJson<List<BottomBarEntry>>(migrated))
}
}
@@ -0,0 +1,139 @@
/*
* 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.navigation
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.stableKey
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarEditing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BottomBarSettingsStateTest {
private val home = BottomBarEntry.BuiltIn(NavBarItem.HOME)
private val messages = BottomBarEntry.BuiltIn(NavBarItem.MESSAGES)
private val group = BottomBarEntry.RelayGroup("abcd", "wss://relay.example")
private val chat = BottomBarEntry.PublicChat("f".repeat(64))
// --- Pure edit operations ---
@Test
fun togglePinAddsWhenAbsentAndRemovesWhenPresent() {
val once = BottomBarEditing.togglePin(listOf(home), messages)
assertEquals(listOf(home, messages), once)
val twice = BottomBarEditing.togglePin(once, messages)
assertEquals(listOf(home), twice)
}
@Test
fun togglePinIsIdempotentOverTwoCalls() {
val start = listOf(home, group)
val roundTrip = BottomBarEditing.togglePin(BottomBarEditing.togglePin(start, chat), chat)
assertEquals(start, roundTrip)
}
@Test
fun togglePinRemovesByStableIdentityNotReferenceEquality() {
val start = listOf(BottomBarEntry.RelayGroup("abcd", "wss://relay.example"))
// A distinct instance with the same (id, relay) resolves to the same stableKey.
val result = BottomBarEditing.togglePin(start, BottomBarEntry.RelayGroup("abcd", "wss://relay.example"))
assertTrue(result.isEmpty())
}
@Test
fun moveReordersAndClampsOutOfBounds() {
val start = listOf(home, messages, group)
assertEquals(listOf(messages, group, home), BottomBarEditing.move(start, 0, 2))
assertEquals(start, BottomBarEditing.move(start, 1, 1)) // no-op: same index
assertEquals(start, BottomBarEditing.move(start, -1, 2)) // no-op: out of bounds
assertEquals(start, BottomBarEditing.move(start, 0, 9)) // no-op: out of bounds
}
@Test
fun stableKeyIsUniquePerTypeEvenWhenUnderlyingStringMatches() {
val a = BottomBarEntry.PublicChat("x").stableKey
val b = BottomBarEntry.Concord("x").stableKey
val c = BottomBarEntry.Favorite("x").stableKey
assertEquals(3, setOf(a, b, c).size)
}
// --- Holder: persistence semantics ---
@Test
fun togglePinPersistsImmediately() {
var saved: List<BottomBarEntry>? = null
val state = BottomBarSettingsState(listOf(home)) { saved = it }
state.togglePin(messages)
assertEquals(listOf(home, messages), state.pinned)
assertEquals(listOf(home, messages), saved)
}
@Test
fun moveTransientDoesNotPersistUntilCommit() {
var saveCount = 0
val state = BottomBarSettingsState(listOf(home, messages)) { saveCount++ }
state.moveTransient(0, 1)
assertEquals(listOf(messages, home), state.pinned)
assertEquals(0, saveCount) // dragging does not write
state.commit()
assertEquals(1, saveCount) // drag end writes once
}
@Test
fun restoreDefaultPersistsTheDefaults() {
var saved: List<BottomBarEntry>? = null
val state = BottomBarSettingsState(listOf(home)) { saved = it }
state.restoreDefault()
assertEquals(DefaultBottomBarEntries, state.pinned)
assertEquals(state.pinned, saved)
}
@Test
fun syncFromOnlyUpdatesWhenDifferent() {
var saveCount = 0
val state = BottomBarSettingsState(listOf(home)) { saveCount++ }
state.syncFrom(listOf(home)) // equal → no change, and never persists
assertEquals(listOf(home), state.pinned)
state.syncFrom(listOf(home, messages)) // external change adopted
assertEquals(listOf(home, messages), state.pinned)
assertEquals(0, saveCount) // syncFrom must never persist (it would echo-loop)
}
@Test
fun isPinnedReflectsMembership() {
val state = BottomBarSettingsState(listOf(home, group)) {}
assertTrue(state.isPinned(group))
assertFalse(state.isPinned(messages))
}
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -43,7 +44,7 @@ import kotlinx.coroutines.flow.update
* 5 delete, …). The sink lands it in a store keyed by rumor id so the normal
* reaction/reply/delete/OTS/zap machinery wires up automatically.
*/
typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event) -> Unit
typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event, seenOnRelays: Set<NormalizedRelayUrl>) -> Unit
/**
* The result of feeding one wrap to a session's [ConcordCommunitySession.ingest]. It separates
@@ -86,7 +87,7 @@ enum class ConcordIngestOutcome {
class ConcordCommunitySession(
val entry: ConcordCommunityListEntry,
val myPubKey: HexKey,
private val onRumor: ConcordRumorSink = { _, _, _ -> },
private val onRumor: ConcordRumorSink = { _, _, _, _ -> },
) {
private val root = entry.root.hexToByteArray()
private val communityIdBytes = entry.id.hexToByteArray()
@@ -242,7 +243,10 @@ class ConcordCommunitySession(
* bumping on every message re-derives every plane's REQ per message and rate-limits
* the relays (they close the plane subs mid-load, so channels appear empty).
*/
fun ingest(wrap: Event): ConcordIngestOutcome {
fun ingest(
wrap: Event,
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
): ConcordIngestOutcome {
when (wrap.pubKey) {
controlPlaneAddress -> {
lock.withLock {
@@ -285,7 +289,7 @@ class ConcordCommunitySession(
// would be O(history) per message (quadratic over a channel's lifetime). A duplicate
// re-delivery (isNew == false) is a no-op. A full-history sweep (member-roster harvest)
// relies on this staying O(1) per wrap.
if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap))
if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap), seenOnRelays)
// A chat message lands in the feed via [onRumor] → LocalCache, independent of the
// revision; it changes no plane address, so it must NOT bump (see the storm note above).
return ConcordIngestOutcome.NON_STRUCTURAL
@@ -368,11 +372,12 @@ class ConcordCommunitySession(
channelIdHex: HexKey,
key: GroupKey,
wraps: List<Event>,
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
) {
val authors = HashSet<HexKey>()
ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor ->
authors.add(rumor.pubKey.lowercase())
onRumor(entry.id, channelIdHex, rumor)
onRumor(entry.id, channelIdHex, rumor, seenOnRelays)
}
// Every author we just decrypted is observably present (CORD-02 §5), so fold them into the
// roster even if they never posted a Guestbook Join. Atomic so a concurrent add isn't lost.

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