Commit Graph

12608 Commits

Author SHA1 Message Date
Claude a6605e2792 fix(quic): use hostname-aware trust manager on Android
Android's RootTrustManager throws when an app has Network Security Config
domain-specific entries and the 2-arg checkServerTrusted overload is used,
crashing Nest room joins on relays covered by such config. Discover the
3-arg checkServerTrusted(chain, authType, hostname) overload via reflection
and invoke it with the SNI host; fall back to the standard 2-arg form on
plain JVM where that overload doesn't exist.
2026-04-30 16:10:00 +00:00
Claude f759f44eea docs(quartz): trim verbose KDoc on cache + observable layers
Reviews before merge:

- EventInterner: drop stale "used by Event.fromJson" reference (we
  reverted that earlier). Tighten the class doc.
- ObservableEventStore: collapse the 30+ line class KDoc + duplicated
  StoreChange description into one focused doc each.
- EventStoreProjection: trim the 60+ line class doc; reactive
  semantics live on the project() extension. Add explicit caveat
  about in-place addressable updates not re-evaluating filter
  membership.

No behaviour change. All cache + store + projection tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 16:03:50 +00:00
Vitor Pamplona d91ffb6f32 Merge pull request #2662 from vitorpamplona/claude/fix-expiration-test-sqlite-6Vv10
Fix expiration timing in ExpirationTest
2026-04-30 11:57:15 -04:00
Claude f4b94d6c6a feat(quartz): InterningEventStore now interns accepted events on insert
After the inner store accepts an event (insert succeeds), register
the caller's instance with the interner so subsequent reads of the
same id return the same `===` reference. This means an event that
was just inserted and is then queried back resolves to the original
in-memory object, not a freshly-deserialized clone.

Same in transaction { }: every accepted event is interned after the
inner transaction commits. If the body throws or inner rolls back,
nothing is interned (accepted list is discarded).

The decorator still does not *substitute* the caller's event with a
previously-cached one — same-id-different-sig collisions resolve
the new event into the cache without overwriting (intern() is
first-seen-wins). The caller keeps the instance they passed in.

All cache + store + projection tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:52:21 +00:00
Claude 5755ab90b2 refactor(quartz): switch project() builder from channelFlow to flow
channelFlow was overkill for a single-producer chain. The cost was
an extra Channel hop between every projection.snapshot() and the
downstream collector — pure overhead with no concurrency benefit.

flow { } needs the outer collector captured (because inside
changes.onSubscription { ... } and changes.collect { ... } the
implicit `this` is FlowCollector<StoreChange>, not
FlowCollector<ProjectionState<T>>). One `val outer = this` fixes
that.

Backpressure now flows directly: a slow collector suspends emit,
which suspends our changes.collect, which suspends the SharedFlow's
buffer drain — natural propagation, no decoupling Channel in the
middle.

17/17 projection tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:48:36 +00:00
Claude 8a349d0f2e fix(quartz): widen ExpirationTest insert window to dodge isExpired race
The pre-check in SQLiteEventStore.insertEvent rejects events whose
expiration is <= TimeUtils.now(). With expiration = time + 1, the
wall clock can tick past time + 1 between sampling `time` and the
insert call, intermittently failing testDeletingExpiredEvents.

https://claude.ai/code/session_01GoqXtcFnwgyjBict1EURyA
2026-04-30 15:36:05 +00:00
Claude f789fe4f53 refactor(quartz): EventStoreProjection becomes pure state machine; ObservableEventStore.project returns cold Flow
Removes scope ownership from EventStoreProjection. The class is now a
pure state machine — no Job, no AutoCloseable, no embedded
StateFlow:

  class EventStoreProjection(store, filters, nowProvider) {
      suspend fun seed()                         // run the seed query
      fun apply(change: StoreChange): Boolean    // apply one mutation
      fun snapshot(): ProjectionState.Loaded     // current view
  }

ObservableEventStore.observe(filter, scope) is renamed to
ObservableEventStore.project(filter) and now returns a cold
Flow<ProjectionState<T>>. Each collection allocates its own state
machine, runs its own seed, and unsubscribes when the collector
cancels. The flow is built using channelFlow + the state machine —
the class stays as the load-bearing primitive.

Standard caller pattern:

  val state = observable.project<TextNoteEvent>(filter)
      .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000),
               ProjectionState.Loading)

Tradeoffs:
- Cold flow → multiple collectors each re-seed. ViewModel pattern
  with stateIn shares one collection across observers (the standard
  Android approach).
- close() goes away. Cancelling the collecting scope tears down the
  projection.
- closeStopsListening test renamed to cancellingScopeStopsListening
  and now verifies the StateFlow's value freezes after its scope is
  cancelled.

17/17 projection + 240/240 store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:35:30 +00:00
Claude 8517bae7a4 feat(quartz): replace items + ready with sealed ProjectionState
EventStoreProjection now exposes `state: StateFlow<ProjectionState<T>>`
instead of separate `items` + `ready` signals. ProjectionState is a
sealed interface with two cases:

  data object Loading                                  // initial state
  data class Loaded(items: List<MutableStateFlow<T>>)  // post-seed view

This lets the UI distinguish "still seeding" from "seeded but empty"
— the previous `emptyList()` initial value conflated the two. Future
extension to `Failed(throwable)` is a single case addition.

The `ready: CompletableDeferred<Unit>` signal is gone; callers use
`state.first { it is Loaded }` (or the test `awaitReady()` helper).

Test fixtures gain two private extensions on EventStoreProjection<T>:
- val items: List<MutableStateFlow<T>> — terse access to the loaded
  list (returns empty if still seeding).
- suspend awaitReady() — replaces ready.await().

awaitItems(predicate) now matches against the Loaded state. 17/17
projection + 240/240 store + interner tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:20:45 +00:00
Claude f23537a49c refactor(quartz): rename InternedEventStore → InterningEventStore
Past-participle "Interned" suggested an attribute of the events
flowing through; present-participle "Interning" describes what the
decorator actively does. Read clearer.

File renamed, no consumers needed updating (this commit is the only
external reference, since callers compose the decorator inline).
All 240 store + projection + interner tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:02:30 +00:00
Claude ecbb7ea10c refactor(quartz): tighten projection package layout + relay flows from store
Three changes wrapped together:

1. relay moves up to IEventStore. The interface gains an abstract
   `val relay: NormalizedRelayUrl?` and SQLiteEventStore, EventStore,
   FsEventStore, InternedEventStore, and ObservableEventStore all
   override it (decorators forward inner.relay). EventStoreProjection
   loses its `relay` ctor param and uses `store.relay` for NIP-62
   vanish scoping; ObservableEventStore.observe overloads lose their
   relay arg. The relay was always a property of the store anyway —
   threading it through projection construction was redundant.

2. StoreChange inlines into ObservableEventStore. The sealed type is
   now ObservableEventStore.StoreChange (with Insert / DeleteByFilter /
   DeleteExpired as nested cases) since it's strictly a contract of
   the change stream, never used independently. StoreChange.kt deleted.

3. Package reshuffle:
     store/projection/ObservableEventStore.kt → store/ObservableEventStore.kt
     store/projection/EventStoreProjection.kt → cache/projection/EventStoreProjection.kt
     store/projection/StoreChange.kt → inlined into ObservableEventStore

   ObservableEventStore is a store decorator and lives next to
   IEventStore. EventStoreProjection is a cache primitive (alongside
   EventInterner / InternedEventStore) and lives under cache/. The
   `observe(filters, scope)` convenience moved from a method on
   ObservableEventStore to a top-level extension function in
   cache/projection/, which avoids the otherwise-circular
   store → cache/projection → store dependency.

7/7 interner + 17/17 projection + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:51:16 +00:00
Claude e126ca679a refactor(quartz): move interning classes into nip01Core/cache/interning
Groups EventInterner + InternedEventStore + the platform actuals + the
test under a focused interning sub-package. Leaves nip01Core/cache/ as
the namespace for cache primitives generally; future cache types
(LargeWeakCache, etc.) get sibling sub-packages.

Files moved:
  cache/EventInterner.kt          → cache/interning/EventInterner.kt
  cache/InternedEventStore.kt     → cache/interning/InternedEventStore.kt
  cache/EventInterner.jvmAndroid  → cache/interning/EventInterner.jvmAndroid
  cache/EventInterner.apple       → cache/interning/EventInterner.apple
  cache/EventInterner.linux       → cache/interning/EventInterner.linux
  cache/EventInternerTest.kt      → cache/interning/EventInternerTest.kt

No consumers to update — the previous decorator refactor already
removed the interner from all the store ctors. 7/7 interner +
240/240 store + projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:40:15 +00:00
Vitor Pamplona 7a9896a788 Merge pull request #2661 from vitorpamplona/claude/fix-nests-speaker-test-601LZ
Fix race condition in ReconnectingNestsSpeakerTest broadcast handle access
2026-04-30 10:38:50 -04:00
Claude 3792c98ec5 refactor(quartz): rename StoreEvent → StoreChange, events flow → changes
The "Event" name was overloaded — the store-mutation type lived
right next to Nostr Event, so StoreEvent.Insert(event: Event) read
awkwardly. Renaming to StoreChange disambiguates and matches
reactive vocabulary ("store changes").

  - StoreEvent → StoreChange (file, sealed type, all references).
  - ObservableEventStore.events → ObservableEventStore.changes.
  - Internal field _events → _changes.
  - KDoc cross-references updated.

Case names (Insert, DeleteByFilter, DeleteExpired) unchanged. All
240 store + projection + interner tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:38:23 +00:00
Claude dd9a530305 fix(nests): publish handles list under AtomicInteger barrier in speaker test
ScriptedSpeaker mutated `handles` AFTER incrementing `_startCount`,
so a reader that polled startCount on a different thread (the test
runs on the runBlocking thread while the broadcast pump runs on
Dispatchers.Default) could see startCount==1 via the volatile
AtomicInteger read before the subsequent list write became visible,
then fail `assertEquals(1, first.handles.size)` with a stale 0.

Also `mutableListOf` itself is not thread-safe — concurrent reads
during a write are undefined.

Reorder so the list append happens BEFORE the increment (the
volatile write now publishes the list mutation under the same
happens-before edge tests already rely on for startCount), and
swap the backing store for CopyOnWriteArrayList so concurrent
reads of `handles[0]` are well-defined.

Observed as a flake on Ubuntu CI; passes locally.
2026-04-30 14:34:53 +00:00
Claude 3fc4781d12 refactor(quartz): encapsulate interning as InternedEventStore decorator
Replaces the ctor-injected interner inside SQLiteEventStore /
QueryBuilder / EventStore / FsEventStore with a standalone
InternedEventStore decorator in nip01Core/cache/.

Composition is now explicit at the call site:

  val sqlite = EventStore(...)
  val cached = InternedEventStore(sqlite)
  val observable = ObservableEventStore(cached)

Each layer has one job: EventStore persists, InternedEventStore
canonicalises read results, ObservableEventStore publishes the bus.
Stores no longer carry an interner field; passing one through three
layers of constructor params is gone.

InternedEventStore wraps every IEventStore.query variant (list +
streaming, single filter + multi) and pipes results through
interner.intern. Writes (insert, transaction, delete*,
deleteExpiredEvents) and counts pass through untouched.

assertQuery test helpers generalized from EventStore /
SQLiteEventStore to IEventStore so the decorator works with the
existing fixtures.

BaseDBTest reverts to plain EventStore — the basic store tests
don't read through the projection / interning layer, so they don't
need decoration. Tests that DO want canonical-instance reads can
wrap explicitly: `InternedEventStore(eventStore)`.

7/7 interner + 240/240 store + projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:30:48 +00:00
Vitor Pamplona 5a81171b58 Merge pull request #2660 from vitorpamplona/claude/fix-feed-icons-activation-Qxdem
Preload bottom bar feeds reactively based on user's pinned items
2026-04-30 10:30:13 -04:00
Vitor Pamplona 79e4a841e7 Merge pull request #2653 from vitorpamplona/claude/filter-empty-rooms-jRDGd
Improve Nests feed filtering to check for active speakers
2026-04-30 10:25:36 -04:00
Claude 03e9c9d2b2 refactor(quartz): move EventInterner into nip01Core/cache package
Splits the cache primitives off into their own package. Files moved:

  nip01Core/core/EventInterner.kt          → nip01Core/cache/EventInterner.kt
  nip01Core/core/EventInterner.jvmAndroid  → nip01Core/cache/EventInterner.jvmAndroid
  nip01Core/core/EventInterner.apple       → nip01Core/cache/EventInterner.apple
  nip01Core/core/EventInterner.linux       → nip01Core/cache/EventInterner.linux
  nip01Core/core/EventInternerTest.kt      → nip01Core/cache/EventInternerTest.kt

Imports updated in SQLiteEventStore, QueryBuilder, EventStore (sqlite),
FsEventStore, BaseDBTest. Each platform actual now explicitly imports
nip01Core.core.Event / HexKey since we crossed package boundaries.

7/7 interner + 240/240 store + projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:19:22 +00:00
Claude 612f2aa31e fix(nests): prune stale presence entries from LiveActivitiesChannel
Presence entries are valid for ~10 min (PRESENCE_FRESHNESS_WINDOW_SECONDS in
NestsFeedFilter). Without explicit cleanup, presenceNotes would grow unbounded:
every author who ever heartbeats in a room leaves an entry there forever.
The freshness check kept the filter result correct, but memory only ever grew.

Add LiveActivitiesChannel.pruneStalePresence(cutoff) and call it from
LocalCache.pruneOldMessagesChannel alongside the existing notes prune. Cutoff is
2x the freshness window (20 min) so a presence still inside any feed's window
can never be reaped.
2026-04-30 14:19:21 +00:00
Claude 566133750b refactor(nests): keep room presence out of channel.notes entirely
Presence (kind-10312) was being stored in both `channel.notes` and the
`presenceNotes` index. The mixed-kind `notes` map is dominated by chat
in active rooms, and only HomeLiveFilter still read presence from it --
which is now migrated to scan presenceNotes directly.

- LocalCache.consume(MeetingRoomPresenceEvent): drop the `channel.addNote`
  call; only addPresenceNote, plus addRelay so the channel's relay-counter
  still tracks where presence arrived from.
- LiveActivitiesChannel: addPresenceNote / removePresenceNote emit on
  flowSet.notes so reactive observers (NestsFeedLoaded) still update.
- HomeLiveFilter.shouldIncludeChannel: scan presenceNotes separately for
  follow-broadcast detection in audio rooms (chat scan unchanged).
- HomeLiveFilter.followsThatParticipateOn: also count presenceNotes
  authors so audio-room hosts/speakers factor into the participation
  sort even when they haven't chatted.
- ChannelFeedFilter: delete the isChatEvent workaround that was excluding
  presence from the chat feed -- presence no longer lands there.
2026-04-30 14:19:21 +00:00
Claude 7845072f20 fix(nests): evict author from prior room when presence moves to a new room
kind-10312 is replaceable per author, but the room a presence points to
(`a`-tag) can change when a speaker hops between rooms. The replaceable
cache only swaps the addressable's content -- it doesn't know which channel
the old version was attached to, so the prior room kept the stale entry in
both `notes` and the new `presenceNotes` index. NestsFeedFilter would then
falsely surface the prior room as "live" via that author until the entry
dropped out of the freshness window.

Capture the prior room from the existing addressable before consumeBaseReplaceable
swaps it. When the new event is a true replacement (createdAt > prior) and
the room differs, drop the author from the prior channel's presenceNotes
and remove the prior version note from its main notes index.
2026-04-30 14:18:37 +00:00
Claude d87fc36d21 perf(nests): index room presence separately from chat for O(speakers) scans
LiveActivitiesChannel.notes is mixed-kind (chat, zaps, raids, clips, presence),
and chat dominates by volume in active rooms. The Nests feed filter scanned it
twice per room per recompute -- once for any-fresh-presence, once for fresh
on-stage presence -- doing an `is MeetingRoomPresenceEvent` cast on every chat
message just to find the speakers.

Add a presenceNotes index on LiveActivitiesChannel keyed by author pubkey
(presence is replaceable per author, so the key auto-collapses heartbeats).
Populate it from LocalCache.consume(MeetingRoomPresenceEvent). Switch
NestsFeedFilter to a single hasFreshSpeakers() pass over the index, dropping
the now-redundant isLiveByPresence() check (hasFreshSpeakers implies it).
Migrate NestsFeedLoaded's latest-presence flow to the same index.
2026-04-30 14:18:37 +00:00
Claude 7c61eaf4e3 feat(nests): hide rooms with no fresh speakers from drawer feed
Adds hasFreshSpeakers gate to NestsFeedFilter so OPEN/PRIVATE rooms
whose live speaker slate is empty are dropped — a room with no fresh
kind-10312 presence carrying onstage=1 has effectively ended even if
its kind-30312 status still says live.
2026-04-30 14:18:37 +00:00
Claude 7f613d6003 refactor(quartz): intern only on store reads, inject per-store interner
The previous commit interned at Event.fromJson, which is too early —
the deserializer can produce events with manipulated id fields that
don't match their content. Move interning to the post-validation
boundary: events are only canonicalised when read back from durable
storage, where they were valid when written.

- Revert Event.fromJson to plain OptimizedJsonMapper.fromJson.
- Revert ObservableEventStore.insert interning. Substituting the
  caller's event for a previously-cached one with the same id but
  potentially different sig was a write-side surprise; we leave the
  caller's instance alone.
- Intern at SQLiteStatement.toEvent (read deserialization).
- Intern at FsEventStore.readEvent (FS reads).

The interner is also now constructor-injected through every store
layer (SQLiteEventStore, EventStore, QueryBuilder, FsEventStore)
defaulting to EventInterner.Default. BaseDBTest gives each parallel
forEachDB store its own EventInterner — without this, parallel test
runs that sign "same id, different sig" events through the shared
Default would see the first run's sig leak across all DBs.

All 240 store + projection + interner tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 13:45:01 +00:00
Claude e909866d26 feat(quartz): add EventInterner so deserialized events share canonical instances
Process-wide interner that canonicalises Event instances by id —
whenever the same event id is decoded twice (relay duplicates,
projection seeds, FS re-reads, etc.), every consumer sees the same
object reference. Backed by weak references so entries vanish once
no live consumer (projection slot, UI state) holds the event.

Shape:
- expect class EventInterner in nip01Core/core/, with a Default
  process-wide instance.
- jvmAndroid actual: ConcurrentHashMap<HexKey, WeakReference<Event>>
  pre-sized to 5_000 (load factor 0.75). On-access cleanup atomically
  removes dead entries via map.remove(key, ref). intern() uses
  putIfAbsent + retry-on-dead-canonical to be race-safe.
- apple / linux actuals: passthrough (no canonicalisation). Kotlin/
  Native has weak refs but no built-in concurrent map; rather than
  ship a half-baked impl on Apple targets we skip canonicalisation
  there. Can be revisited if iOS wants the memory savings.

Wire-up:
- Event.fromJson now routes through EventInterner.Default.intern,
  so every deserialised event becomes canonical for free. In-process
  events (signer.sign(...) etc.) aren't auto-interned — the
  canonicaliser pays off for events that re-occur from multiple
  sources, which signed-locally events don't.

Tests (jvmTest):
- internReturnsFirstInstance / internCollapsesDuplicates:
  identity is preserved across equivalent decodes.
- getReturnsLiveEntry / getReturnsNullForUnknownId.
- getEvictsDeadEntries: weak ref cleared by GC, on-access
  cleanup drops the entry.
- draftChurnDoesNotLeak: 100 distinct drafts churn through the
  interner with no strong refs; map shrinks back to ~0 after GC.
- defaultInstanceIsShared: the global Default interner is process-wide.
- 7/7 pass; 240/240 store + projection tests still green.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 13:27:33 +00:00
Vitor Pamplona 4f45de9b86 Merge pull request #2654 from vitorpamplona/claude/add-tag-filters-YR1wO
feat(home): user-configurable home tabs (new threads, conversations, everything)
2026-04-30 08:51:02 -04:00
Vitor Pamplona bc1a5d98d6 Merge pull request #2655 from vitorpamplona/claude/mute-unmute-icons-Lcxuc
feat(nests): use mic icons for talk / stop talking
2026-04-30 08:49:39 -04:00
Vitor Pamplona 89aca22897 Merge pull request #2657 from davotoula/sonar-unused-lambda-param
Replace unused lambda parameters with `_`
2026-04-30 08:47:38 -04:00
Vitor Pamplona 0ad45dadf6 Merge pull request #2658 from davotoula/fix/identity-claim-tag-parse
fix(nip39): reject identity claim tags without a platform separator
2026-04-30 08:47:24 -04:00
Vitor Pamplona ea5d020b05 Merge pull request #2659 from davotoula/fix/strictmode-cleartext-localhost
fix(android): silence StrictMode cleartext violations for 127.0.0.1 (Tor SOCKS)
2026-04-30 08:47:06 -04:00
David Kaspar 13b0332a13 Merge pull request #2650 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-30 14:40:11 +02:00
davotoula 6b709981de fix(android): silence StrictMode cleartext violations for Tor SOCKS on 127.0.0.1
Add a network_security_config.xml that keeps cleartext globally permitted
(so user-configured ws:// relays still work) but adds an explicit
domain-config for 127.0.0.1 and localhost. This stops StrictMode's
detectCleartextNetwork from flooding logcat with CleartextNetworkViolation
stacks each time the app talks to the local Tor SOCKS proxy
(220+ per benchmark session previously).

Verified on a playBenchmark build: zero CleartextNetworkViolation lines
to 127.0.0.1 even though the app continued attempting Tor connections
on port 9050.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:48:33 +02:00
davotoula 0c2614fbf5 fix(nip39): reject identity claim tags without a platform separator
Malformed `i` tags whose platform-identity field has no `:` (observed in
production logcat as e.g. `["i", " ", " "]`) made `create()` throw
`IndexOutOfBoundsException` from destructuring `split(':')`. The exception
was caught but each event spammed multiple stack traces. Validate the
separator up front in `parse()` and return null silently. Also fix the
diagnostic `joinToString { "," }` typo so the log shows real tag contents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:21:23 +02:00
davotoula 398652ef47 replace more unused lambda parameters with _ 2026-04-30 10:29:27 +02:00
Crowdin Bot 00386c242f New Crowdin translations by GitHub Action 2026-04-30 07:58:07 +00:00
davotoula 212fdf739c updated translations cz,se,de,pt 2026-04-30 09:54:46 +02:00
Claude 37bbb45090 fix(bottombar): preload feeds for user-pinned icons, not the default 5
LoggedInPage hardcoded the Home/Messages/Video/Discover relay subscriptions,
so users who customised the bottom bar still paid bandwidth for the four
defaults while the icons they actually pinned never preloaded. Drive the
preloaders from uiSettingsFlow.bottomBarItems so subscriptions track the
chosen list reactively.
2026-04-30 03:47:58 +00:00
Claude bfd3ddaca6 feat(home): user-configurable home tabs (new threads, conversations, everything)
Adds a Home Tabs settings screen letting users pick which tabs appear on Home:
New Threads, Conversations, and a new combined Everything tab. The tab row is
hidden whenever only one tab is active, so users can keep a single feed view.
At least one tab always remains active.
2026-04-30 03:29:41 +00:00
Claude 6da9af2a81 feat(nests): use mic icons for talk / stop talking
The text Talk / Stop Talking buttons made it easy to misread the
broadcast state. Swap them for large filled mic-icon buttons so the
mic state is unmistakable: MicOff in primary color when idle (tap to
go live), Mic in error color when broadcasting (mic is open, tap to
stop).
2026-04-30 03:28:53 +00:00
Claude ae6343cd73 refactor(quartz): simplify EventStoreProjection (-50 lines, -1 bug)
Review of the projection found one real bug and four redundancies:

Bug fix: handleInsert tracked `retained` inline as it walked filters,
flipping it to false when the slot self-evicted from a filter's cap.
But if filter A retained the slot (cap=null or with room) and filter B
later cap-evicted it, retained ended up false even though the slot was
still in A's set — leaking a slot into a per-filter set with no
byId/byAddress entry. Fix: compute retention after the loop with
`perFilter.values.any { it.contains(slot) }`.

Simplifications:
- Drop the redundant `ordered: SortedSet<Slot>` field. It was only
  read by publish(); it's the deduped sorted union of perFilter
  values. Compute it lazily at publish time instead. Removes the
  field, all the ordered.add/ordered.remove calls, and one helper.
- Collapse `dropSlotIndexes` and `removeSlot` paths into
  `removeIndexes` (id+address) + `removeSlot` (also clears per-filter
  sets). The eviction loop calls removeIndexes; everywhere else
  calls removeSlot.
- Drop the defensive `byAddress[address] === slot` identity check —
  with byId as the primary index, removeSlot is only called once per
  slot, and rekey only ever moves byId, never byAddress.
- Pull the iterate-and-drop pattern into a single `dropWhere(predicate)`
  inline helper. handleVanish, applyDeleteByFilter, and
  applyDeleteExpired all collapse to one-liners.
- Apply now returns Boolean from each branch and publish() runs once
  at the end, instead of three separate `if-publish` blocks.

Net: 233 → 142 lines. Same behaviour, plus the retained bug fix.
17/17 projection tests + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 00:03:03 +00:00
Vitor Pamplona 9bc1e3d3ce Merge pull request #2652 from vitorpamplona/claude/add-audience-to-stage-HhJgJ
Add tap gesture support for audience member promotion
2026-04-29 19:53:20 -04:00
Claude 544d26d9ec refactor(quartz): tidy projection package, flatten StoreEvent, per-filter limits
Five changes wrapped together:

1. Move ObservableEventStore + StoreEvent from store/observable/ into
   store/projection/. One package owns the projection bus + projection
   itself; consumers import only `store.projection.*`.

2. Flatten DeleteRule into the StoreEvent sealed type:
     StoreEvent.Insert(event)
     StoreEvent.DeleteByFilter(filters)
     StoreEvent.DeleteExpired(asOf?)
   ObservableEventStore.delete(filter|filters) emits DeleteByFilter;
   deleteExpiredEvents() emits DeleteExpired with the pinned cutoff.

3. Use event.address() for AddressableEvent and event.isExpirationBefore(t)
   for NIP-40 checks; drop the local addressOf(kind, pubKey, dTag) and
   isExpiredAt helpers. Plain replaceables (kind 0/3, 10000-19999) still
   build Address(kind, pubKey, "") manually since they don't implement
   AddressableEvent. NIP-09 delete-by-address now looks up the projection's
   byAddress index with the deletion's Address directly (it's already an
   Address, no conversion needed).

4. Per-filter limit. Each filter retains its own capped TreeSet<Slot>
   (sorted created_at DESC, id ASC). A slot is "live" iff at least one
   filter retains it. The projection's items is the deduped union, so
   when filter A and filter B match disjoint events the union can
   exceed any single filter's limit. New tests confirm both behaviours:
   per-filter eviction + union-larger-than-cap.

5. Drop the redundant + 1 in expiration checks; use isExpirationBefore
   directly with TimeUtils.now() for arrival-time guards. The sweep
   path subtracts 1 because the store's SQL uses strict `<` while the
   helper is `<=`.

17/17 projection tests + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-29 22:43:46 +00:00
Claude 208d9a8f7f feat(nests): tap an audience avatar to open the participant sheet
The host's primary path to add an audience member to the stage was the
long-press → "Promote to Speaker" row, which is hard to discover and was
also being swallowed by ClickableUserPicture (which ignores onLongClick
when onClick is null). Wire a tap handler on audience cells so a single
tap opens the same per-participant sheet, surfacing Promote to Speaker
for hosts and View Profile / Follow / Mute for everyone else.
2026-04-29 22:43:23 +00:00
Vitor Pamplona d44baa7e8f Merge pull request #2651 from vitorpamplona/claude/fix-windows-test-failure-dY1Jv
Fix race condition in MoQ subscription registration
2026-04-29 18:35:56 -04:00
Vitor Pamplona c8327e829f Merge pull request #2647 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-29 18:26:59 -04:00
Vitor Pamplona 31a2705173 Merge pull request #2649 from vitorpamplona/claude/fix-hand-raise-sync-Yf4b4
Fix stage presence sync and relay propagation in meeting rooms
2026-04-29 18:26:21 -04:00
Vitor Pamplona 997f52a31a Merge branch 'claude/fix-hand-raise-sync-Yf4b4' of https://github.com/vitorpamplona/amethyst into claude/fix-hand-raise-sync-Yf4b4 2026-04-29 18:19:22 -04:00
Claude 19b534a9e2 fix(nestsClient): register inbound moq-lite subscription before sending Ok
The publisher's inbound-bidi handler wrote SubscribeOk to the bidi
before calling registerInboundSubscription. The peer's first
publisher.send() after observing Ok could race the registration on
dispatchers that resume the peer's continuation before the handler's
(notably Windows under Dispatchers.Default), causing send to observe
an empty inboundSubs and return false. Reordering makes the peer's
view of Ok a happens-after of the registration.

Fixes the Windows-only failure in
MoqLiteSessionTest.publisher_acks_subscribe_and_pushes_group_data_on_uni_stream.
2026-04-29 22:14:23 +00:00
Claude 8d228db440 fix(nests): preserve relays tag on participant updates + sync action bar with stage grid
Two related bugs the user hit while testing against nostrnests:

1. Approving a hand-raise made the Hands tab disappear (the host's
   local app saw the role grant) but the audience member did not
   appear on stage anywhere else.

   Cause: RoomParticipantActions.rebuild() rebuilt the kind-30312
   without the original room's `relays` tag. The recently-added
   broadcast fan-out path (Account.computeRelayListToBroadcast) keys
   off `event.allRelayUrls()` for kind 30312, so a republished room
   event with no relays tag only reaches the host's outbox — not
   nostrnests's fixed five reads or the audience member subscribing
   on those reads. The audience member never sees their SPEAKER tag
   and stays absent from the participant list.

   Fix: copy `original.relays()` into the rebuild so every republish
   (approve, demote, kick → re-broadcast) keeps the relay routing
   that lets the room reach its full audience.

2. Tapping "Leave Stage" as the host emptied the StageGrid
   ("Waiting for speakers…") but the action bar still showed the
   Talk + Leave Stage cluster.

   Cause: two different definitions of "on stage" were in play.
   StageGrid uses ParticipantGrid (role + presence.onstage flag),
   so flipping onStageNow=false drops the host out. The action bar
   gated on the role-only `onStage: List<ParticipantTag>` (the
   host's HOST tag never goes away), so the cluster stayed.

   Fix: derive isOnStageMe from participantGrid.onStage so both
   surfaces share the same "stepped off" semantics. The host who
   taps Leave Stage now drops to the audience-style mute toggle
   and can rejoin (kind-10312 onstage flips back to 1) without
   the controls lying about their state.

https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
2026-04-29 22:13:36 +00:00
Vitor Pamplona 9628d17f5f Improves the relay list used for linked Notes to include their relay urls 2026-04-29 21:48:32 +00:00