Commit Graph

13455 Commits

Author SHA1 Message Date
davotoula e21794e42a Manual testing fixes:
- quartz: Event.threadRootIdOrSelf() now falls back to markedReply()?.eventId
when both markedRoot() and unmarkedRoot() are absent
- amethyst: After upstream PR #2855 split the notifications feed into
notificationsFollowing and notificationsEveryone, the hiddenUsers.flow
collector still only invalidated the original notifications feed. Extends
it to invalidate all three.
- amethyst: CardFeedContentState.refreshSuspended() takes an additive-only
path when lastNotes is populated — it only adds cards for new admissions,
never removing cards for notes that no longer pass the filter. Re-muting
mid-session correctly rejected muted-thread reactions in the filter, but
the existing cards stayed in the UI as "Show Anyway" placeholders. Calls
clear() before invalidateData() on each notification feed so the refresh
hits the full-rebuild branch and removals propagate.
2026-05-13 09:06:53 +02:00
davotoula ff780bcb54 Code review:
- consolidate thread-root resolution
- consolidate mute-state writes
2026-05-13 09:06:53 +02:00
davotoula a99488779a Amethyst:
- drop muted-thread reactions/zaps from notifications feed
- SecurityFiltersScreen lists muted threads with unmute action
- MutedThreadsFeedFilter for settings screen
- add Mute thread entry to long-press dropdown
- add Mute thread entry to quick-action sheet
- add mute-thread string resources
- AccountViewModel.muteThread/unmuteThread/isThreadMutedFor
- drop muted-thread events before Android push dispatch
- FilterByListParams drops muted-thread notes
- Account.isAcceptable drops muted-thread notes
- Account.muteThread/unmuteThread + resolveThreadRoot + isThreadMuted
- MuteListDecryptionCache exposes mutedThreadIdSet helper
- MuteListState supports hideThread/showThread
- HiddenUsersState exposes muted-thread root ids
2026-05-13 09:06:53 +02:00
davotoula bac3710deb Commons:
- Note.isHiddenFor drops notes in muted threads
- LiveHiddenUsers carries mutedThreads + isThreadMuted predicate
2026-05-13 09:06:53 +02:00
David bbb4e39465 Quartz:
- add mutedThreads/mutedThreadIdSet accessors
- MuteTag sealed interface recognizes EventTag
- add EventTag for NIP-51 mute-list e tags
- MuteListEvent round-trip and legacy-tag migration coverage
2026-05-13 09:06:53 +02:00
Vitor Pamplona 1d503dc6ac Adds android.util.Log mock for quic tests
Mirrors the quartz commonTest stub so :quic:testAndroidHostTest no longer
throws RuntimeException("Stub!") through PlatformLog.android on the
MAX_STREAMS_UNI emission path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:19:48 -04:00
Vitor Pamplona 6ab077361f updates dependencies 2026-05-12 22:14:06 -04:00
Vitor Pamplona 65c72605a0 function names cannot have @ in iOS 2026-05-12 22:07:23 -04:00
Vitor Pamplona 5c39e3c85b Removes more warnings 2026-05-12 22:05:38 -04:00
Vitor Pamplona c51c5f665b fix(quartz): make commonMain compile for Kotlin/Native (iOS)
@Volatile resolves via kotlin.jvm on JVM but needs an explicit
kotlin.concurrent.Volatile import on Native; Dispatchers.IO is internal
on Native, so the in-process WebSocket switches to Dispatchers.Default
(no blocking I/O on that path); and Native's LinkedHashMap is final
without removeEldestEntry, so Nip98AuthVerifier's replay cache now caps
itself with an explicit insertion-order eviction loop (access-order was
unused — the cache is write-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:03:19 -04:00
Vitor Pamplona 2eec53472a Adds a log mock 2026-05-12 19:40:28 -04:00
Vitor Pamplona da32b33684 fixes warnings 2026-05-12 19:32:11 -04:00
Vitor Pamplona 3751d6dc28 Fixes warnings 2026-05-12 19:27:12 -04:00
Vitor Pamplona 349f7a7989 fix(quartz): make IngestQueue writer-start KMP-safe via AtomicBoolean
Replaces JVM-only @Volatile + synchronized double-checked locking with
kotlin.concurrent.atomics.AtomicBoolean.compareAndSet so the file builds
on all commonMain targets, mirroring the pattern in BasicRelayClient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:21:14 -04:00
Vitor Pamplona 00c5c32041 merge 2026-05-12 19:05:59 -04:00
Vitor Pamplona 14342e12c2 refactor(geode): simplify RelayEngine + trim StaticConfig comments
RelayEngine (224 → 185 lines):
  - Extract BanStore <-> RuntimeConfigData mapping helpers
    (seedInto, snapshotOf) into geode.config — bidirectional
    conversion now has names instead of being inline boilerplate
    repeated between the init block and snapshot().
  - Collapse the standalone init block into a .apply on the
    banStore declaration. Method reference ::snapshot for the
    onMutation hook.
  - Drop the effectiveAtBoot field's 12-line KDoc (rename to `boot`
    locally, terse). Trim verbose property KDocs to the load-bearing
    facts (why-not-what).

StaticConfig (255 → 182 lines):
  - Drop resolveInfo's unused `advertisedUrl` parameter and the
    workaround comment that justified keeping it for "future
    fields". YAGNI — add back when actually needed. Two callers
    updated.
  - Trim trivial KDocs (fromToml/fromFile, field names that
    document themselves like require_auth, reject_future_seconds,
    supported_nips, database.file).
  - Collapse NetworkSection's three thread-pool KDocs (~16 lines)
    into a single section-level KDoc.
  - Compact parallel_verify, AdminSection, AdminSection.state_file,
    and advertisedUrl companion KDocs while keeping the
    load-bearing facts (invariants, strfry interop strings,
    surprising defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:01:36 -04:00
Vitor Pamplona 72e7f37b61 refactor(geode): move Nip86Server (+ adminPubkeys) into RelayEngine
KtorRelay was carrying state that wasn't HTTP-specific: the
Nip86Server, its InfoHolder adapter, and the adminPubkeys allow-list.
Push them into RelayEngine — it already owns the info doc, ban store,
and event store the Nip86Server consults; the allow-list is a
relay-level "who is admin?" decision, not a transport-level one.

RelayEngine gains adminPubkeys: Set<HexKey> = emptySet() and exposes
nip86Server as a public property. Future non-HTTP admin transports
(in-process tools, hypothetical NIP-86-over-WS) read it directly
without re-deriving the wiring.

KtorRelay shrinks to mostly Ktor structures — routing block, engine
config, lifecycle. It just builds the Nip86HttpHandler around
relay.nip86Server.

Main.kt and Nip86EndToEndTest pass adminPubkeys to RelayEngine
instead of KtorRelay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:44:50 -04:00
davotoula b20377cf0e i18n: translate nest-room moderation + split-notifications strings into cs, de, pt-BR, sv 2026-05-13 00:34:42 +02:00
Vitor Pamplona 445fbf8cda refactor(quartz, geode): derive NIP-86 publicUrl from relay.url; drop isEnabled branch
Two simplifications:

1. Derive admin URL from RelayEngine.url. NIP-86 spec mandates that
   the admin endpoint is "the same URI as ws(s)://, called via
   http(s)://" — so KtorRelay derives the NIP-98 binding URL by
   calling relay.url.toHttp() instead of accepting publicUrl as a
   separate config. Single source of truth (info.relay_url),
   accidental misconfiguration impossible, no Host-header fallback.
   StaticConfig.AdminSection.public_url is gone.

2. Uniform code path for admin-enabled vs disabled. Empty allow-list
   isn't a special case anywhere: Nip86Server.isAuthorized returns
   false for everyone, dispatch rejects, Nip86HttpHandler returns
   NotAdmin → 403. KtorRelay always assembles the Nip86HttpRoute
   and registers the POST endpoint; Nip86Server.isEnabled() and the
   "is admin on?" branches in the handler and route are deleted.

Nip86EndToEndTest's admin-disabled case is now a behavioral test:
sign a valid token, expect 403 NotAdmin (was 405 with the no-route
variant, was 403 with the fake-disabled-route variant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:19:24 -04:00
David Kaspar 4e199987e9 Merge pull request #2859 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-12 23:57:42 +02:00
Vitor Pamplona a53b3339b3 refactor(quartz): canonical NIP-86 HTTP flow in Nip86HttpHandler
Move the NIP-86-over-HTTP orchestration (NIP-98 verify → admin
allow-list gate → parse → dispatch → serialize) into quartz so any
relay implementation that wants the standard transport can plug its
HTTP framework in without re-deriving the sequence — and without
accidentally skipping NIP-98 verification or the admin check.

  • Nip86Server (quartz) gains allowList + isEnabled() + isAuthorized();
    dispatch becomes dispatch(pubkey, req) and enforces the allow-list
    itself, so in-process callers can't bypass it either.
  • Nip86HttpHandler (new, quartz) takes only primitives (authHeader,
    url, body) and returns a sealed Response — Disabled, PayloadTooLarge,
    MissingAuth, BadAuth, NotAdmin, BadRequest, Ok(pubkey, req, resp,
    json). Constants for the 1 MiB body cap, application/nostr+json+rpc
    content type, and WWW-Authenticate scheme live here.
  • Nip86HttpRoute (geode) shrinks to a Ktor adapter: bounded read,
    extract URL/auth, call handler, map Response → Ktor status codes.
    Audit logging stays in geode — not a quartz concern.

External relay authors: import quartz, build a Nip86HttpHandler with
their Nip86Server + Nip98AuthVerifier, and write a ~30-line adapter
for their framework. The full security-critical flow is in the box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:38:40 -04:00
Vitor Pamplona e4e9ae7043 refactor(quartz): NIP-86 bans purge matching events via onBan(filter) hook
Nip86Server used to take an optional IEventStore and call store.delete()
only on banevent — banpubkey and disallowkind would update the BanStore
but leave existing matching events served by REQ. That's inconsistent
(an operator who bans a spam pubkey expects the spam to be gone) and
a real safety issue (banning a CSAM event wouldn't actually remove
existing copies if the store was null).

Replace the IEventStore param with onBan: suspend (Filter) -> Unit. The
dispatcher fires it after each ban method with the Filter that selects
the now-banned events:
  banpubkey   → Filter(authors = [pk])
  banevent    → Filter(ids = [id])
  disallowkind→ Filter(kinds = [k])

KtorRelay wires onBan = { f -> relay.store.delete(f) }, so the existing
SQLite delete-by-filter path handles every shape without the caller
having to know which field to populate. The default no-op keeps the
unit test for the dispatcher dependency-free.

withInt is now suspend so disallowKind's handler can call the suspending
onBan inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:55:38 -04:00
Vitor Pamplona a010b37d6c refactor(geode): inject RuntimeConfig into RelayEngine, mirroring EventStore
RelayEngine used to take three params that were really RuntimeConfig
construction details — info: RelayInfo, stateFile: File?,
seedAuthorization: AuthorizationSeed — and assembled the RuntimeConfig
internally. Replace that with a single runtimeConfig: RuntimeConfig
param (with an in-memory default), matching how store: IEventStore is
already passed in. Main.kt builds the RuntimeConfig from StaticConfig
at the assembly layer where the TOML→runtime mapping is most explicit.

AuthorizationSeed is dropped — it only existed to shuttle data into
the now-removed seedAuthorization param. Callers build RuntimeConfigData
directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:14 -04:00
Vitor Pamplona f4bcf38caf refactor(geode): drop unused KtorRelay knobs (maxFrameBytes, maxAdminBodyBytes)
Ktor's WebSocket layer is unbounded by default, so [limits].max_ws_frame_bytes
was a strfry-parity tuning knob nobody actually used — drop the whole
LimitsSection from StaticConfig and the matching constructor param from
KtorRelay. The negentropy-large-corpus plan note is updated accordingly.

maxAdminBodyBytes is a real defense-in-depth cap (NIP-98 forces us to
read the body for the payload sha256 before we can authenticate, so an
unbounded read is a pre-auth DoS vector). Keep the cap, but stop
exposing it as an operator knob — it was never plumbed through to
StaticConfig and 1 MiB is ~1000× any plausible NIP-86 RPC payload.
Hardcoded at the Nip86HttpRoute construction site with the rationale
inline. Nip86HttpRoute.maxBodyBytes stays so tests can drive the 413
boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:14:15 -04:00
Vitor Pamplona 47fdd8638e refactor(geode): extract NIP-11 GET into Nip11HttpRoute
KtorRelay was resolving NIP-11 inline in the routing block while NIP-86
already had a dedicated Nip86HttpRoute with a handle(call) entry point.
Lift NIP-11 to the same shape so both endpoints look symmetric and the
routing block is just dispatch. The `liveJson` callback (rather than a
snapshot string) keeps NIP-86 changerelay* admin mutations visible on
the next GET without re-wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:58:05 -04:00
Vitor Pamplona b0ad540453 refactor(geode): split config into StaticConfig + RuntimeConfig with seeding
Rename RelayConfig → StaticConfig and the persistence/RelayStateStore →
config/RuntimeConfig so the two configuration tiers — boot-time TOML
versus operator-mutable runtime state — are obvious from the class
names. The `persistence/` package is gone; everything related to config
lives in `config/`.

For overlapping fields (NIP-11 info doc, pubkey / kind allow-deny
lists), StaticConfig now seeds RuntimeConfig on first boot via the new
RuntimeConfig(seed = …) constructor and an AuthorizationSeed type. The
runtime file wins from then on: later edits to [authorization] in the
TOML are ignored once an admin RPC has written the snapshot. As a
consequence, KindAllowDenyPolicy and PubkeyAllowDenyPolicy are no
longer stacked in geode's policy chain — BanListPolicy already reads
the same lists from the BanStore, and double-stacking would silently
diverge after the first admin mutation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:51:19 -04:00
Crowdin Bot 3b9fa3ec98 New Crowdin translations by GitHub Action 2026-05-12 18:52:42 +00:00
Vitor Pamplona 9c0873b178 fix(quartz): import RelayEngine.preload extension in jvmAndroid tests
Follow-up to the previous geode rename — quartz's jvmAndroid tests
extend the geode testFixtures `RelayClientTest` and were calling
`defaultRelay.preload(...)`. After moving `preload` off `RelayEngine`
and into an extension function in `geode.testing`, these tests need
the import explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:49:01 -04:00
Vitor Pamplona 9f7deee8f3 refactor(geode): rename relay classes for clarity, move test helpers off public API
Renames in the geode module to make naming honest:
- Relay -> RelayEngine (the transport-agnostic core)
- LocalRelayServer -> KtorRelay (Ktor/CIO transport; defaults to 127.0.0.1
  but supports public deployment, so "Local" was misleading)
- RelayHub -> InProcessRelays (URL-keyed registry that doubles as a
  WebsocketBuilder for in-JVM tests; "Hub"/"Pool" implied substitutable
  resources, but each URL maps to a distinct relay)

Also moves the test-only helpers `preload` and `publish` out of
RelayEngine and into a testFixtures extension file. As part of that,
`publish` now waits for the relay's OK reply before returning so that
publish-then-subscribe is deterministic — previously the fire-and-forget
submit let a subscription register after publish returned but before the
ingest queue fanned out, causing ephemeral kinds (not persisted) to leak
to late subscribers. Fixes the flaky
`Nip01ComplianceTest.ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:44:33 -04:00
Vitor Pamplona dd12938a0f Merge pull request #2858 from vitorpamplona/claude/review-participant-dropdown-V0eJB
Refactor participant sheet: extract sub-composables, add confirmations
2026-05-12 09:34:56 -04:00
Claude 9fc0dda144 refactor(nests): decompose ParticipantHostActionsSheet, tighten internals
Self-review cleanup of the previous fix commit. No behavior change:

  1. Removed nullability from the `nestViewModel` parameter — the only
     call site always passes it, and the previous chain
     `nestViewModel?.uiState?.collectAsState()?.value` was a
     conditional composable that only worked by accident.
  2. Replaced the hand-rolled `targetCanSpeakByRole` with
     `ParticipantTag.canSpeak()` so future role additions stay
     consistent.
  3. Split the monolithic sheet into ParticipantSheetHeader,
     AudienceActions, HostActions, ParticipantZapDialog and
     DestructiveConfirmDialog so each piece recomposes on its own
     state and has a single responsibility.
  4. Factored the `broadcast(...) + toast + onDismiss()` triplet into
     a `hostAction(...)` helper inside HostActions and a top-level
     `broadcast(...)` utility for the destructive dialogs.
  5. Collapsed the 13 pre-resolved toast strings into a
     `ParticipantToasts` data class produced once by
     `rememberParticipantToasts(displayName)`.
  6. Made displayName reactive via `observeUserInfo` so the header
     and toast text always agree (previously the toast used a
     snapshot while the header observed the metadata flow).
  7. Replaced the deeply qualified types in the signature with
     normal imports.
  8. Consolidated three `rememberSaveable<Boolean>` dialog flags
     into a single `SheetDialog` enum with a name-based Saver, so
     the dialog states are mutually exclusive by construction.

https://claude.ai/code/session_013Aj1h3epWkvucKFLdunjcv
2026-05-12 13:26:14 +00:00
Vitor Pamplona 925d9de71b Merge pull request #2857 from vitorpamplona/claude/fix-notecompose-scroll-jitter-8lw3R
Optimize Compose state management and flow subscriptions
2026-05-12 09:24:25 -04:00
Claude e56e4f79a9 perf(observeNoteModifications): sample(500) to absorb edit bursts
A heavily-edited note can fire `edits.stateFlow` hundreds of times
during initial relay sync; without throttling, each emission still
hits the IO scan even though `distinctUntilChanged` would collapse
most of them downstream. `sample(500)` keeps only the most recent
state per ~half second, so the IO `findLatestModificationForNote`
runs at most ~twice a second per note instead of once per arrival.
2026-05-12 13:21:01 +00:00
David Kaspar a9d2919dd1 Merge pull request #2856 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-12 12:51:33 +02:00
Crowdin Bot 674b4a85f5 New Crowdin translations by GitHub Action 2026-05-12 10:11:17 +00:00
Vitor Pamplona 54001267e3 Merge pull request #2855 from davotoula/feat/split-notifications-197
Split notifications: Following vs Everyone
2026-05-12 06:09:21 -04:00
davotoula 9b900b22f8 test(notifications): cover NotificationFeedFilter modeOverride wiring
Adds an instrumented test for the split-notifications feature
2026-05-12 09:56:11 +02:00
davotoula b91e84b416 Code review:
- Gate the new notificationsFollowing / notificationsEveryone fan-out in
  updateFeedsWith / deleteNotes on splitNotificationsEnabled.value so the
  default-off case stops doing two extra filter scans per event bundle.
- Lazy-init NotificationFeedFilter.overrideFollowLists so the
  SharingStarted.Eagerly topNavFilter pipeline only starts when the
  split UI actually opens the filter.
- Switch SplitNotificationsScaffold to rememberForeverPagerState so the
  active tab survives recomposition, matching HomeScreen/DiscoverScreen.
- Promote the duplicated "Notification" last-read key string to a
  NOTIFICATION_LAST_READ_KEY const.
- Move the opaque-background fix into SummaryBar itself so callers don't
  need a band-aid Modifier.background on their topBar Column.
- Trim narrating / WHAT comments; keep only the deep-link scroll WHY.
2026-05-12 09:16:46 +02:00
davotoula 972bce75c5 feat(notifications): opt-in Following / Everyone tab split (#197)
Adds an opt-in toggle under Settings → Notifications that splits the
notifications screen into two pinned-mode tabs — Following (kind:3
follow list only) and Everyone (global). The bottom-bar unread dot
glows only for Following so it behaves like the DM-style indicator
the bounty asks for. Default is off; existing single-feed behavior
and the FeedFilterSpinner are unchanged when the toggle is off.
2026-05-12 09:16:19 +02:00
Claude 24a87602a7 fix(nests): tighten participant context sheet bugs and UX gaps
Fixes ten issues found while reviewing the per-participant ModalBottomSheet
on the Nest full-screen UI:

  1. demoteToListener used to fall through to setRole(PARTICIPANT) when the
     target had no participant row, silently adding a pure-audience listener
     as PARTICIPANT to the kind-30312. Now returns null in that case and
     when the target is already PARTICIPANT.
  2. Kick and Force-mute now require an AlertDialog confirmation before
     firing, matching the host's own Leave-room flow.
  3. Sheet header switched from a raw hex stub to UsernameDisplay so the
     user knows who they're acting on.
  4. Local Hush is hidden when the target isn't currently broadcasting —
     the volume gate attaches to an active subscription, so a hush against
     a non-speaker was a silent no-op.
  5. Follow/Mute/Promote/Demote/Force-mute/Kick now emit success toasts
     (launchSigner already surfaces errors).
  6. isFollowing and isHidden moved to collectAsState so labels update
     live if the kind-3 / mute-list publish lands while the sheet is open.
  7. Force-mute row carries a "May be ignored by clients that don't honor
     the verb" subtitle (the verb is an Amethyst extension over nostrnests).
  8. Tap on a stage avatar now opens the same sheet as long-press — the
     audience grid already worked this way; the stage was long-press only.
  9. Hand-raise queue avatars are now click+long-press → sheet, so a host
     can profile / kick a raised-hand audience member without switching tabs.
 10. Promote-to-Speaker / Promote-to-Moderator / Demote-to-Listener rows
     are hidden when the target already has that role.

https://claude.ai/code/session_013Aj1h3epWkvucKFLdunjcv
2026-05-12 02:12:02 +00:00
Claude c92a9df6ea perf(observeEdits): filter modification updates at the flow level
`observeEdits` in NoteCompose previously listened to the raw
`note.flow().edits.stateFlow` and re-ran `findModificationEventsForNote`
(an IO scan) inside a `LaunchedEffect` on every emission — even when
the emission didn't change the actual list of modifications. That
mutated `editState` repeatedly with the same value during scroll on
any active TextNote.

Add `observeNoteModifications` in EventObservers.kt: it does the IO
resolution via `mapLatest { LocalCache.findLatestModificationForNote(note) }`
on Dispatchers.IO, then `distinctUntilChanged()` — so the State only
updates (and the consumer's LaunchedEffect only re-keys) when the
modification list truly changes. The State is `null` until the first
IO resolution completes; consumers treat that as "still loading" and
don't flip the UI to "no edits" prematurely.

Drop the now-unused `observeNoteEdits` and the
`AccountViewModel.findModificationEventsForNote` wrapper.
2026-05-12 02:11:53 +00:00
Claude 1f578eaff8 feat(TimeAgo): single shared ticker so on-screen ages stay fresh
`TimeAgo`, `NormalTimeAgo`, `ChatTimeAgo`, and the chatroom header's
private `TimeAgo` previously formatted the timestamp once inside
`remember(time)` and never refreshed — a note shown when it was 59 s
old would keep saying "59s" forever, even when it had been minutes.

Introduce a single app-wide ticker:

* `LocalNowSeconds` is a `CompositionLocal<State<Long>>` whose value
  is refreshed every 30 s by a single `produceState` coroutine inside
  `NowProvider` (mounted once at the app root in `MainActivity`).
* Each `TimeAgo` composable reads the ticker inside `derivedStateOf`,
  so it only triggers a Text recomposition when the formatted string
  actually crosses a threshold (e.g. 1m → 2m). Ticks that don't change
  the displayed string are filtered by `derivedStateOf` equality.

One coroutine total, no per-item timers, and recompositions are
proportional to "strings that actually need to update" rather than
"items on screen × ticks/second".
2026-05-12 01:53:14 +00:00
Claude 4d9479fa1b Revert "refactor(RichTextViewer): move isMarkdown onto RichTextViewerState"
This reverts commit 7e2e3304e1.
2026-05-12 01:09:05 +00:00
Vitor Pamplona 4364bc1023 Merge pull request #2852 from vitorpamplona/claude/fix-promote-speaker-menu-L9mT7
Fix coroutine scope for async broadcast operations in room actions
2026-05-11 18:55:32 -04:00
Vitor Pamplona f91ac72465 Merge pull request #2850 from greenart7c3/claude/fix-home-settings-loading-iKfkV
Add preferences for home feed tab visibility
2026-05-11 18:55:06 -04:00
David Kaspar 2ed6247b93 Merge pull request #2853 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-11 23:48:17 +02:00
Crowdin Bot c531c77516 New Crowdin translations by GitHub Action 2026-05-11 21:44:59 +00:00
davotoula 596fa93fed i18n: translate cast / report-warning-threshold strings into cs, de, pt-BR, sv
Also drop DLNA from the cast description (functionality removed) in English and Polish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:41:12 +02:00
Claude c0710b794f refactor(nests): route host actions through AccountViewModel.launchSigner
Replace the raw viewModelScope.launch + runCatching with launchSigner,
matching the rest of the codebase. Same survival-past-dismiss
guarantee, plus signer errors (read-only key, NIP-46 unauthorized /
timeout, signer not found) now surface as toasts instead of being
silently swallowed.
2026-05-11 21:02:57 +00:00
Vitor Pamplona e0335d4340 Merge pull request #2851 from vitorpamplona/claude/fix-nest-profile-shape-dRMiY
Fix avatar oval distortion in narrow grid cells
2026-05-11 16:59:27 -04:00