mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
Merge remote-tracking branch 'origin/main' into claude/bitchat-ephemeral-interop-8epkek
This commit is contained in:
@@ -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
|
||||
|
||||
+4
@@ -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()
|
||||
}
|
||||
|
||||
+253
@@ -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) }
|
||||
|
||||
|
||||
+49
-53
@@ -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. */
|
||||
|
||||
+36
-49
@@ -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
|
||||
}
|
||||
|
||||
+42
-2
@@ -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 }
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.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,
|
||||
)
|
||||
}
|
||||
+94
@@ -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,
|
||||
|
||||
+9
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+82
-248
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+405
@@ -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")
|
||||
}
|
||||
+33
@@ -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,
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+393
@@ -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,
|
||||
)
|
||||
}
|
||||
+232
@@ -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()
|
||||
}
|
||||
}
|
||||
+42
-22
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+602
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+110
-146
@@ -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)
|
||||
|
||||
+374
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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
|
||||
|
||||
+29
@@ -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,
|
||||
)
|
||||
|
||||
+139
@@ -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
|
||||
+276
-91
@@ -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")
|
||||
}
|
||||
|
||||
+189
@@ -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)
|
||||
}
|
||||
}
|
||||
+92
@@ -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")
|
||||
}
|
||||
}
|
||||
-56
@@ -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,
|
||||
)
|
||||
}
|
||||
+87
@@ -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()
|
||||
}
|
||||
+47
-36
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-307
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
-14
@@ -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(
|
||||
|
||||
+2
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+16
-5
@@ -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(
|
||||
|
||||
+1
@@ -566,5 +566,6 @@ private fun SuccessfulUploads.toConcordImeta(): IMetaTag? {
|
||||
blurhash = result.fileHeader.blurHash?.blurhash,
|
||||
cipher = cipher,
|
||||
originalHash = result.hashBeforeEncryption,
|
||||
thumbhash = result.fileHeader.thumbHash?.thumbhash,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+10
@@ -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,
|
||||
) {
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
) {
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+61
-15
@@ -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
|
||||
|
||||
+17
-124
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+413
@@ -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())
|
||||
}
|
||||
}
|
||||
+671
-268
File diff suppressed because it is too large
Load Diff
+108
@@ -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 & 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 & ł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 & 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 & 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>
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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>
|
||||
|
||||
+44
@@ -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())
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+139
@@ -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))
|
||||
}
|
||||
}
|
||||
+10
-5
@@ -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
Reference in New Issue
Block a user