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.
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.
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).
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
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
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.
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.
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
Hand-raise toggles in Amethyst weren't appearing in nostrnests's UI.
nostrnests's NestsUI v2 routes reads against a fixed five-relay list
(NestsUI-v2/src/lib/const.ts: relay.snort.social, nos.lol, relay.damus.io,
relay.ditto.pub, relay.primal.net) — no outbox model. Its presence query
is just `kinds:[10312], "#a":[roomATag]` over those five relays.
Commit 637174ef already taught computeRelayListToBroadcast to fan a kind
30312 *room* event out to its `relays` tag. But kind 10312 presence (and
chat / reactions, etc.) is a different event type that *links* to the
room via an `a` tag. For those, broadcast was only reaching:
- the broadcaster's outbox relays
- the single firstOrNull() hint baked into the `a` tag
- the relay we happened to receive the room event from
If none of those overlap with nostrnests's fixed five reads, the
hand-raise update is silently dropped.
Extend the `linkedAddressIds` block in computeRelayListToBroadcast: when
the linked address resolves to a MeetingSpaceEvent / MeetingRoomEvent /
LiveActivitiesEvent, also add that linked event's allRelayUrls(). This
covers presence updates and any other room-scoped event that points at
a Nest via `#a`.
https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
Renames `byStableKey: HashMap<String, Slot>` to
`byAddress: HashMap<Address, Slot>` in EventStoreProjection. Address
is a data class on every platform so equals/hashCode are derived
from (kind, pubKeyHex, dTag) — same identity as the synthetic string
key, but typed.
`stableKey(event)` / `stableKey(kind, pubKeyHex, dTag)` become
`addressOf(...)` returning `Address?`. Plain replaceables map to
`Address(kind, pubKey, "")`, addressables to
`Address(kind, pubKey, dTag)`, regular events to null.
`limit` was already used at insertNew() to enforce the cap on
inserts; left unchanged.
15/15 projection tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Hand-raise toggles in Amethyst weren't appearing in nostrnests's UI.
nostrnests's NestsUI v2 routes reads against a fixed five-relay list
(NestsUI-v2/src/lib/const.ts: relay.snort.social, nos.lol, relay.damus.io,
relay.ditto.pub, relay.primal.net) — no outbox model. Its presence query
is just `kinds:[10312], "#a":[roomATag]` over those five relays.
Commit 637174ef already taught computeRelayListToBroadcast to fan a kind
30312 *room* event out to its `relays` tag. But kind 10312 presence (and
chat / reactions, etc.) is a different event type that *links* to the
room via an `a` tag. For those, broadcast was only reaching:
- the broadcaster's outbox relays
- the single firstOrNull() hint baked into the `a` tag
- the relay we happened to receive the room event from
If none of those overlap with nostrnests's fixed five reads, the
hand-raise update is silently dropped.
Extend the `linkedAddressIds` block in computeRelayListToBroadcast: when
the linked address resolves to a MeetingSpaceEvent / MeetingRoomEvent /
LiveActivitiesEvent, also add that linked event's allRelayUrls(). This
covers presence updates and any other room-scoped event that points at
a Nest via `#a`.
https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
Replaces ObservableEventStore.events: SharedFlow<Event> with
SharedFlow<StoreEvent>, where StoreEvent is:
Insert(event)
Delete(rule: DeleteRule)
DeleteRule.Filtered(filters) ← from delete(filter) / delete(filters)
DeleteRule.Expired(asOf) ← from deleteExpiredEvents()
ObservableEventStore now emits Insert for accepted events, and Delete
for every out-of-band removal that went through it. The Expired rule
carries the cutoff timestamp (TimeUtils.now() at call time) so the
projection's in-memory drop matches the store's on-disk drop without
clock skew between collectors.
EventStoreProjection no longer runs its own NIP-40 ticker. Expired
events linger in [items] until the application calls
deleteExpiredEvents() on the observable — at which point the
projection drops everything whose expiration is past `asOf`. The
ticker, expirationTickMs constructor arg, and sweepExpired helper are
all removed.
For DeleteRule.Filtered the projection iterates current slots and
drops any whose event matches any of the rule's filters via
Filter.match — same predicate the store uses.
Tests:
- nip40ExpirationDroppedByTicker → nip40ExpirationDroppedOnStoreSweep:
drives a deleteExpiredEvents() call instead of waiting on a ticker.
- New deleteByFilterRemovesMatchingSlots: confirms delete(filter) on
the observable removes exactly the matching slots from the projection
without touching others.
- 15/15 projection tests + all other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The previous SendBuffer suite (FlowControlEnforcementTest) is entirely
single-threaded — every test calls enqueue and takeChunk sequentially
on the same coroutine, so the race that crashed the audio path in
production (NoSuchElementException from chunks.first() under
concurrent enqueue + takeChunk) stayed invisible. The whole :quic
commonTest tree had no concurrent test at all.
Three new tests run real-thread races on Dispatchers.Default:
- concurrent_enqueue_and_takeChunk_does_not_throw drives multiple
producer coroutines + a consumer coroutine and asserts the buffer
drains cleanly with no exception.
- concurrent_takeChunk_callers_never_double_drain_a_chunk fans out
multiple consumers against a pre-populated buffer; asserts the
sum of bytes handed out equals the bytes enqueued (i.e. no chunk
is double-counted by overlapping head-peel paths).
- concurrent_finish_with_inflight_enqueue_emits_correct_fin races
finish() against in-flight writes and asserts the FIN comes
AFTER every enqueued byte.
Tests pass against the synchronised SendBuffer; running them against
the pre-fix unsynchronised version corrupts state badly enough that
the consumer wedges (an explicit "this is what the bug looked like"
demonstration). With internal synchronisation in place the suite
finishes in <0.2 s.
Documents the concurrent-access contract so a future "let's drop the
sync, it's hot" refactor immediately fails CI.
Reverts the previous round's choice of embedding an ObservableEventStore
inside EventStore. The wrapper now lives strictly outside any specific
store: callers compose `ObservableEventStore(EventStore(...))` (or
`ObservableEventStore(FsEventStore(...))`, or any IEventStore) and use
that as their projection bus.
EventStore is back to a plain IEventStore implementation over
SQLiteEventStore — no `observable`, `events`, or `observe()` on it.
ObservableEventStore gains the `observe(filters, relay, scope)` and
`observe(filter, relay, scope)` convenience methods. NIP-62 vanish
scoping is passed in per-projection (rather than a constructor field
on the wrapper) so the same observable can feed projections with
different relay scopes.
Tests construct `observable = ObservableEventStore(store)` in
`setUp` and route inserts through `observable.insert(...)` so the
projection bus actually publishes them. 14/14 projection + 219/219
other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
QuicConnectionDriver.sendLoop holds the connection mutex while it calls
SendBuffer.takeChunk via QuicConnectionWriter.drainOutbound, but
WtPeerStreamDemux's per-stream `send` callback calls
SendBuffer.enqueue from arbitrary application coroutines without the
connection lock. The two paths concurrently mutate
(chunks, pendingBytes, headOffset, finPending, finSent, sentEnd,
nextOffset). Under load this surfaced as
java.util.NoSuchElementException: ArrayDeque is empty.
at kotlin.collections.ArrayDeque.first(ArrayDeque.kt:102)
at com.vitorpamplona.quic.stream.SendBuffer.takeChunk(SendBuffer.kt:85)
at com.vitorpamplona.quic.connection.QuicConnectionWriterKt.buildApplicationPacket(...)
at com.vitorpamplona.quic.connection.QuicConnectionDriver.sendLoop(...)
The writer saw `pendingBytes > 0` (incremented by an in-flight
enqueue on another thread) before the matching `chunks.addLast`
became visible, fell into the head-peel branch, and tripped on
chunks.first().
Wrap every read and write of SendBuffer state in `synchronized(this)`,
including the cheap `readableBytes` / `sentOffset` / `finPending` /
`finSent` getters used by the writer's pre-flight checks (so they
can't read torn state either). The lock is uncontended in the common
case and short-held in the rare race; we already use synchronized
blocks elsewhere in commonMain (QuicConnectionDriver.kt).
PrimaryTabRow's BadgedBox places the count badge in the top-end corner
of its anchor, which on tight tab labels lands directly on top of the
text. Lay them out as a Row instead so "Audience 3" reads cleanly,
matching the tabbar comment's "Hands · 3" intent.
Two interrelated bugs from the deployed-schema flip:
1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now"
lobby. The lobby filter in NestsUI's skeleton.js requires a `title`
tag on every kind:30312 — events without one are dropped from
`live`/`planned`/`ended`. RoomNameTag still emitted the legacy
`room` name, so our rooms passed our own readers (which tolerate
both names) but were invisible to NestsUI. Flip canonical to
`title`, keep `room` as legacy on read.
2. The mute badge over the avatar disappeared shortly after toggling
mute, even though the broadcaster was still muted. Two causes:
- ParticipantsGrid gated both the muted ring and the MicStateBadge
on `member.publishing`. But per the deployed kind-10312 semantics
`publishing=0` whenever `muted=1` (NestRoomPresencePublisher
mirrors this — `publishingNow = broadcasting && !isMuted`), so
the badge that was supposed to *indicate* mute was hidden
exactly when it became relevant. Show the badge when on stage
and either publishing OR muted; gate the muted ring on `muted`
alone.
- The presence heartbeat captured `micMutedTag` / `publishingTag`
/ `onstageTag` / `handRaised` at LaunchedEffect launch and never
refreshed them. The 500 ms debounced LaunchedEffect did publish
the new mute state, but ~30 s later the heartbeat overwrote it
with the captured pre-toggle values, flipping `publishing`
back to 1 and `muted` back to 0 server-side. Wrap the dynamic
fields in `rememberUpdatedState` so the heartbeat reads the
latest snapshot on every refresh; keep the existing key set
(event/handRaised/onstage) for prompt re-emission on those
transitions.
Tests updated for the new canonical `title` emission; both jvmTest
suites pass.
Splits "events accepted for persistence" from "events accepted for
observation". The new ObservableEventStore wraps any IEventStore and
owns events: SharedFlow<Event>:
- Non-ephemeral events forward to the inner store; success → emit, any
rejection (expired, NIP-09 / NIP-62 tombstone, NIP-01 supersession
loser) → no emit.
- Ephemeral events (kinds 20000-29999) skip persistence entirely but
still emit, so an open EventStoreProjection renders them while alive.
They vanish from any future seed because the DB never had them.
Reverts the previous round of plumbing inside SQLiteEventStore /
FsEventStore: stores no longer carry an inserts SharedFlow. IEventStore
goes back to a clean read/write contract. ObservableEventStore is the
single place that decides what makes it onto the projection bus.
EventStore (the SQLite convenience class) embeds an ObservableEventStore
internally so existing call sites like `store.observe(filter, scope)`
keep working without changes.
EventStoreProjection now collects via Flow.onSubscription so the
collector subscription is established before the seed query runs —
fixes a race where an insert immediately after `ready.await()` could
land in the SharedFlow before the collector was subscribed.
Tests:
- ephemeralEventsAppearInProjection — kind-22000 event reaches items
but isn't queryable from the inner store, and a fresh projection
on the same store gets an empty seed.
- 14/14 projection tests + 219/219 other store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The projection now sits on top of any IEventStore, not just SQLite.
Stores publish a simple `inserts: SharedFlow<Event>` (one event per
successful insert), and the projection itself replays the NIP rules
against that stream:
- NIP-01 supersession with the lexical-id tiebreaker, for both
replaceables (kind 0/3/10000-19999) and addressables (30000-39999).
Both now share a single in-place update path.
- NIP-09 deletions, with the original-author check (cross-author
kind-5s are inert, matching the store).
- NIP-62 right-to-vanish, scoped by the projection's relay arg.
- NIP-40 expiration via a per-projection ticker that drops slots whose
expiration tag has lapsed.
Out-of-band store mutations (`delete(id)`, `clearDB()`, the periodic
`deleteExpiredEvents()` sweep) are no longer visible to projections —
they're maintenance ops; projections re-seed when their scope is
restarted.
Removed from SQLiteEventStore:
- ChangeLogModule + temp-table + AFTER DELETE trigger.
- StoreChange sealed type and the per-mutation drain/publish path.
Both SQLiteEventStore and FsEventStore now satisfy
`IEventStore.inserts`. FsEventStore.insertLocked returns Boolean so
no-op idempotent retries (canonical already on disk) don't re-publish.
Tests:
- EventStoreProjectionTest moves to `store/projection/`, gains four
new cases: NIP-01 out-of-order rejection, NIP-09 cross-author
inertness, NIP-62 cross-author no-op, NIP-40 ticker-driven removal.
- 13/13 projection tests + 232/232 total store tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
emits a different on-the-wire schema than the previous EGG-01 / EGG-09
drafts and Quartz writers. Verified by reading the production
NestsUI bundle. The deployed schema is now canonical:
kind:30312 (room event)
- relay URL → ["streaming", url] (was ["endpoint", url])
- auth URL → ["auth", url] (was ["service", url])
- live status → ["status", "live"] (was "open")
- ended status → ["status", "ended"] (was "closed")
- room name → ["title", name] (was ["room", name])
kind:10112 (user MoQ-server list)
- 3-element entries: ["server", relay, auth]
- first-element name "relay" accepted as a synonym (legacy)
- 2-element entries: derive auth host by replacing leading "moq."
with "moq-auth.", or prepending "moq-auth." otherwise
Implementation
- ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service"
- EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint"
- StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended";
legacy "open"/"closed" still parsed on read
- NestsServersEvent: emit/read 3-element [server, relay, auth] tags
with the legacy-shape tolerances above; expose a NestsServer pair
- Account.nestsServers.flow now produces List<NestsServer> pairs
- NestsServersScreen: rewritten edit-field asks for both URLs,
recommended row shows the nostrnests pair, list rows show both URLs
- NestsScreen first-time setup writes the nostrnests pair, not a
single URL; gate the create FAB on both URLs being parseable
- CreateNestViewModel: drop the resolveServerPair hardcoded mapping —
the saved pair is now authoritative; defaults stay correct for an
empty kind-10112 list
Specs
- EGG-01 rewritten to canonical streaming/auth/live/ended with a
legacy-spelling table; example uses the real nostrnests pair
- EGG-02 references "auth" tag throughout; error taxonomy says "ended"
- EGG-09 rewritten to 3-element server tag with derivation fallback
- New nestsClient/specs/nip-53-proposed.md — a single self-contained
proposed update to upstream NIP-53 covering kind 30312 + kind 10112
with the deployed schema and the JWT-mint flow
All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest,
and nestsClient:jvmTest pass.
Adds a stateful observer that turns a Filter into a StateFlow<List<MutableStateFlow<Event>>>:
- A TEMP table + AFTER DELETE trigger on event_headers logs OLD.id for every row
that leaves the store, regardless of cause (replaceable / addressable
supersession, NIP-09 deletion, NIP-62 vanish, NIP-40 expiration sweep, manual
delete, clearDB).
- SQLiteEventStore drains the log around each writer unit of work and emits a
StoreChange(inserted, removedIds) on a SharedFlow.
- EventStoreProjection seeds itself from the store, then maintains stable
MutableStateFlow handles keyed by (kind:pubkey:dtag) for addressables and by
id otherwise. Addressable updates mutate the handle's value in place — list
reference stable; insert / remove rebuilds the list reference.
- 11 new tests cover seed, insert, replaceable update, addressable update,
NIP-09 deletion, NIP-62 vanish, NIP-40 expiration, manual delete, limit
enforcement, non-matching insert, and close-cancels-listener.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
The previous defaults pointed both `service` and `endpoint` URLs at
`moq.nostrnests.com:4443`. That host only listens on UDP/QUIC for the
WebTransport relay — TCP :4443 is unreachable. OkHttp can't speak HTTP/3,
so the JWT-mint POST hangs and fails with `ConnectException`, leaving
the room screen stuck on "Reconnecting".
The real nostrnests deployment co-locates auth and relay on different
hosts (verified against the production NestsUI bundle):
relay (WebTransport, QUIC only): https://moq.nostrnests.com:4443
auth (JWT mint, regular HTTPS): https://moq-auth.nostrnests.com
Split the defaults accordingly and add a `resolveServerPair` helper that
maps a single saved kind-10112 URL onto the (service, endpoint) pair the
kind-30312 event needs. The helper recognises both the auth-host and
the legacy relay-host the prior defaults wrote, so users upgrading from
the broken version migrate transparently the next time they open the
create-room sheet. Community deployments that genuinely co-locate keep
working via the fallback.
Update the recommended-servers list and the kind-10112 documentation to
match: the stored `server` URL is the moq-auth base (the URL that ends
up in the kind-30312 `service` tag).
Add a navigation row in AllSettingsScreen to open the existing
PaymentTargetsScreen, so users can configure their NIP-A3 payment
targets (kind 10133) from the account settings menu.
Every desktop packaging job (Windows MSI, macOS DMG, Linux DEB) currently
breaks on the smallest blip when fetching VLC from get.videolan.org. The
`ir.mahozad.vlc-setup` plugin registers `vlcDownload` / `upxDownload`
tasks that extend `de.undercouch.gradle.tasks.download.Download`, but it
does not configure retries or a generous read timeout, so a single
`SocketTimeoutException` aborts the build.
Recent main runs all failed at the same step:
> Task :desktopApp:vlcDownload FAILED
> A failure occurred while executing ...DefaultWorkAction
> java.net.SocketTimeoutException: Read timed out
Configure all `Download` tasks in this project with:
- 5 attempts total (initial + 4 retries),
- 30 s connect timeout,
- 5 min read timeout (VLC archives are 40-90 MB and the mirror can be
slow under load),
- `tempAndMove` so a partial download from one attempt cannot poison
the next.
This is a CI-only change \u2014 it does not alter what gets bundled, just
makes the fetch resilient.
The profile 'Last seen' line was rendered by feeding timeAgo() output
into the 'Last seen %1$s ago' template. timeAgo() returns a bare
absolute date (e.g. 'Jan 14' or 'Jul 27, 2024') for anything older
than a month, so the result was nonsensical:
- 'Last seen Jan 14 ago'
- 'Last seen Jul 27, 2024 ago'
Replace the call with a new lastSeenSentence() helper that always
returns a self-contained, grammatical sentence:
- 'Last seen 5 minutes ago'
- 'Last seen 2 hours ago'
- 'Last seen 3 days ago'
- 'Last seen 2 weeks ago'
- 'Last seen on Jul 27, 2024 (9 months ago)'
- 'Last seen on Jan 14, 2024 (1 year ago)'
Anything older than a week now also shows the absolute date alongside
a coarse relative duration, so users can see exactly when the activity
happened without giving up the human-readable 'X ago' framing.
Adds plural-aware duration_minutes/hours/days/weeks/months/years
resources plus last_seen_on_date / last_seen_just_now / last_seen_never
helper strings. The existing 'last_seen' string keeps its placeholder
shape so existing translations continue to work for the recent-duration
case.
Adds 124 missing translations across cs-rCZ, pt-rBR, sv-rSE, and de-rDE
covering the new Nests (NIP-53 audio-rooms) UI, AI-suggested alt-text
hints, tracked broadcasts setting, and meeting/live-stream tags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>