Closes the mobile parity push.
- MobileVoiceJoinSheet: full pre-join camera preview. Dormant-by-default (never auto-fires getUserMedia), explicit user gesture to arm, hard-bound disarm on sheet close (any path). Camera picker popup portaled to document.body so a long device list stays scrollable above the aspect-video preview tile (max-height min(50vh,320px), iOS scroll momentum). iOS Safari-safe: autoPlay playsInline muted + post-await play().
- MobileSettingsScreen: Keybinds + Desktop sections gated on isElectron(); they reuse the existing KeybindsPanel/DesktopPanel which are already mobile-fit. screenMap entries added in MobileShell. (Verify on Electron desktop build at narrow viewport.)
- ToastContainer: real overlap was hiding the voice-fullscreen control bar. Mobile branch now reads isMobile/mobileStack/currentVoiceChannelId and computes the bottom offset across five mobile states (voice-full / pushed+voice / pushed / root+voice / root), all with safe-area-inset-bottom; left-3 right-3 + items-center keeps toasts in the safe-tap zone. Desktop bottom-6 right-6 unchanged.
Specs: docs/systems/mobile-ui.md (Toast Positioning section, screenMap rows, Electron-entry rationale, z-index row); docs/systems/voice.md (Mobile pre-join preview subsection covering lifecycle + camera picker portal).
Adds sticky `lastSelectedSpaceId` to useSpaceStore. Updated on every
setCurrentSpace(non-null) and on loadSpaceDetail success; NOT cleared by
setCurrentSpace(null), so the @me URL effect in AppLayout no longer wipes
the memory. Cleared only when the remembered space is actually removed
(deleteSpace / leaveSpace / removeSpace / removeInstanceSpaces / reset).
Ephemeral — URL drives initial state on reload.
MobileBottomNav.handleTab('spaces') and MobileSpacesScreen's initial
useState now resolve via `currentSpaceId ?? lastSelectedSpaceId`. The
prior Object.keys(lastChannelPerSpace)[0] fallback was wrong twice over:
first-inserted ordering locked the tab to whichever space the user
opened first in their session, and any approach reading currentSpaceId
alone breaks after a /channels/@me detour.
Also fixes a pre-existing render loop in MobileSpacesScreen's
currentSpaceId sync effect — `selectedSpaceId` removed from deps,
functional setState makes the update idempotent.
Spec: docs/systems/mobile-ui.md updates Tab Tap Behavior, MobileScreenHeader,
and LocalStorage Persistence sections.
iOS Safari auto-zooms (and shifts the viewport right) on focus when the
computed font-size is <16px. Single @media (max-width: 767px) block in
globals.css covers all four input tiers, every relevant bare <input>
type, <textarea>, <select>, and contenteditable surfaces. `!important`
is required because Tailwind utilities (text-[15px] on the chat composer,
text-sm on form fields) emit after @layer components and would otherwise
override the input-tier rules.
Replaces ad-hoc per-input `text-base md:text-sm` overrides with a single
global truth. New inputs/contenteditables get the fix for free.
Spec: docs/systems/design-system.md gains an "iOS Auto-Zoom Suppression"
section under Input Tiers.
Commit 7309f44 removed the friend-add trigger because the server now
handles all routing/peering/lookup; the commit message reserved the
modal for 'Connections settings and space-join flows' but neither flow
ever wired it back in. grep across packages/web/src finds only
self-references — pure dead code.
Removing 162 lines of UI plus the stale vi.mock in FriendsPage.test.tsx.
If a per-instance password-re-entry consent UX is ever needed, the
useInstanceConnect hook is the actual abstraction and the modal can be
rebuilt cleanly with current primitives (Modal mobileStyle="fullscreen",
ContextMenuRenderer's bottom-sheet, etc.).
Upload Limit, Stale Uploads, and Media Retention rows now use
flex-wrap with gap-x-3 gap-y-2; input + unit pairs that must stay
together are wrapped in a nested flex group so they migrate as a unit.
Desktop spacing identical (gap-x-3 ≡ original gap-3 when content fits
on one line).
MobileChatScreen DM header now applies parseFederatedUsername and
useCanonicalUserView, matching MobileDmsScreen so federated users render
as 'realname' rather than 'realname@domain'.
Mobile Voice & Audio renamed to Voice & Video across MobileSettingsScreen
and MobileYouScreen — parity with desktop's VoicePanel title.
AudioInputSection and VideoSection picker click-outside now listens for
touchstart alongside mousedown so a single tap dismisses on iOS Safari
(which doesn't synthesize mousedown reliably from touch).
AudioOutputSection feature-detects HTMLMediaElement.setSinkId at module
load and returns null on unsupported platforms (notably iOS Safari).
Split into outer gate + inner body to keep Rules of Hooks intact;
VoicePanel's space-y-5 collapses cleanly with no visible hole.
VideoSection 'Stop preview' CTA gets responsive sizing (mobile:
always-visible 44 px tap target; desktop: original hover-reveal pill via
md: prefixes). The preview <video> gains autoPlay so iOS Safari starts
the stream when srcObject is assigned — manual videoEl.play() after an
awaited getUserMedia loses the user-gesture context on iOS and was
silently rejected by .catch(()=>{}), causing the black-preview bug.
voice.md updated for the iOS hide-when-unsupported policy and the
touch-close contract on device pickers.
AppLayout's auto-channel-redirect (turning /channels/<spaceId> into
/channels/<spaceId>/<firstChannelId>) is desktop-correct but on mobile
catapulted users past the channel sidebar straight into a chat — Join
from a SpaceInviteCard or Spaces-tab tap both inherited this. Guard
the effect with isMobile so /channels/<spaceId> settles at the channel
sidebar overview on mobile.
SpaceInviteCard's join handler now switches to the Spaces tab and seeds
spaceStore.currentSpaceId on mobile before navigating — clears the chat
stack the invite was tapped from and lands the user at the joined space's
channel sidebar instead of stuck behind the originating DM.
MobileSpacesScreen syncs its local selectedSpaceId from currentSpaceId
when external code (the Join handler) seeds the store — covers any future
programmatic space switch too.
MobileInstancePanel gains Registration and Federation entries (parity with
desktop's six sub-tabs). MobileShell registers the matching screenMap
wrappers; the Federation entry surfaces the live approval-count badge via
a new uiStore slot and a wrapper that forwards FederationPanel's
onApprovalCountChange.
MobileShell's deep-link reconstruction now reacts to post-mount pathname
changes (was [] / mount-only) with an idempotency guard that skips the
push when the topmost stack entry already represents the new URL —
prevents the pushMobileScreen → history.pushState → location-effect
double-push.
RegistrationPanel's four portaled modals (CreateInvite, EditInvite,
ReinstateInvite, Redemptions) now render through the shared <Modal>
with mobileStyle="fullscreen", portaled to document.body to escape the
parent settings dialog's backdrop-filter containing block. Desktop
appearance preserved (same maxWidth, same sticky action bar).
useWebSocket's ready handler calls loadMessages(channelId, true) from
two adjacent code paths in the same handler invocation (remote-space
refresh + per-origin cache clearing). Both fired in parallel because
force=true bypassed dedup, producing the duplicate /messages requests
visible in playwright network traces even after the parallel-mount
dedup landed.
Coalescing force=true with an in-flight call is safe: the requests hit
the same endpoint with the same params and would return the same data,
and the force caller still gets a fresh result via the shared Promise.
Live network trace via playwright showed every channel mount firing TWO
parallel GET /channels/:id/messages requests (AppLayout + MessageList
both call loadMessages on mount; neither can be removed because
MessageList is also rendered by VoiceChatPanel which doesn't call it),
and every fast-scroll burst firing N parallel loadMoreMessages calls
(handleScroll closure shares !isLoadingMore=true across the burst).
The hasMore-based guard only deduplicates calls AFTER the first
completes — it does not collapse parallel callers. On a NAT hairpin'd
LAN where some federated TCP connections hang, having N hung parallel
fetches keeps the pagination skeleton stuck while any one is still
waiting on the 30 s api-client timeout.
Fix: in-flight Promise dedup at the chatStore level. Module-level Maps
keyed on channelId; concurrent callers receive the same Promise.
Cleared in finally with self-check so a force-reload mid-flight isn't
dropped. force=true bypasses dedup (WS reconnect intent).
Two cooperating bugs let the pagination skeleton stick at the top of the
chat across channel switches: (1) the browser's post-clamp scroll event
on channel switch fired the load-more block on the new channel, and
(2) useDelayedLoading's threshold timer refreshed displayStart on every
fire, extending the minDisplay window unboundedly when isLoading cycled.
- useDelayedLoading: stamp displayStart only on the false→true show
transition (functional setShow form to avoid stale closures).
- MessageList: suppressNextLoadMoreRef armed in Effect 3, consumed by
the load-more block on the next scroll event, with a 250 ms fallback
disarm so legitimate user scrolls aren't silently dropped. Suppression
scoped to the load-more block only.
- Regression test for the displayStart refresh bug.
- Updated docs/systems/message-list.md history.
Two follow-on bugs from the initial S2S presence rollout:
(1) New friend stuck offline until they reload: presence_update fires only on
transitions, so a remote user already online when their stub is created
locally never receives a relay event seeding their actual status. The
stub defaulted to 'offline' at creation and stayed there until the next
transition. Fix: extend FederationRelayProfileSnapshot +
FederationUserLookupProfile with status. Sender-side buildProfileSnapshot,
getDmParticipants, and lookup endpoint responses populate it for native
users only (replicated stubs hold stale status owned elsewhere).
resolveOrCreateReplicatedUser uses hints.status to seed the new row's
status column. Threaded through every call site (DM participants, group
bootstrap, friend events, ownership transfer). Stub backfill worker also
heals existing rows whose status was stuck at 'offline' from creation.
(2) 'Online' text updates but green avatar dot stays grey on the same page:
spaceStore.updateMemberPresence patches members[] (which feeds space UIs)
but never patches userViews — the cache useCanonicalUserView reads from.
The Avatar in FriendItem reads canonical.status; the text reads
friend.status (socialStore). Two sources, one stale until full
user_updated arrives. Fix: updateMemberPresence now mirrors status into
matching userViews entries, so canonical-view consumers re-render with
fresh status the moment the WS event lands.
RegisterPage step 2 writes the new token to localStorage but defers
initSession (and thus the authStore.token write) until after the avatar
upload, to keep AuthRedirect from yanking the user off /register
mid-upload. The home-origin branch of setTokenForOriginResolver was
reading authStore.token, so getTokenForOrigin('') returned null during
that window — transferStore.startUpload threw "not authenticated" and
the surrounding catch-and-ignore silently dropped the avatar. Align the
resolver with the home api client and read localStorage so both paths
share one source of truth for the home JWT.
Fixes a render bug where a federated user (e.g. axel@nova) appeared with
the federation globe icon and a broken avatar when viewed on his own home
instance. Root cause: `populateFromReady` is first-wins by federatedId and
discards the entire skipped DM payload — including its `members` array —
so when a sibling instance's ready arrived first, the home instance's view
of every shared user was dropped on the floor.
Adds a render-only `userViews` cache that mirrors the `dmAlternatives`
philosophy: information from skipped ready payloads is preserved for
rendering. Every wire surface that delivers a User upserts into the cache
regardless of dedup outcome; render sites read through a Zustand selector
hook to surface the home view when one is loaded. The DM channel ingestion
race is left untouched — the existing no-flapping invariant on origin
reconnect is intentional and load-bearing for failover.
Layered changes:
- `identity.ts`: `normalizeOriginToHost`, `canonicalUserKey`,
`isDeliveryFromHome`, `isFederationGlobeApplicable` — single helpers
for origin/host normalization and the home/stub tier decision.
- `spaceStore.ts`: `userViews` Map, `UserViewEntry` type, `upsertUserView`
action with the home-wins preference rule, prune by `deliveredBy` in
`removeInstanceSpaces` (mirrors `dmAlternatives` cleanup), `reset`
clears.
- `userViewLookup.ts`: `useCanonicalUserView` (Zustand selector hook for
React) + `getCanonicalUserView` (sync getter for non-React paths).
Render reactivity is structural via the selector, not coincidence on
legacy update paths.
- `populateFromReady` upsert pass runs BEFORE the federatedId dedup so
members of skipped DMs still reach the cache.
- WS handlers (dm_message_*, message_*, user_updated, member_joined,
friend_request_*, dm_channel_created, dm_member_added) and REST
hydrators (socialStore, discoverStore, mutuals) feed the cache with
their delivering origin.
- Render-site routing through `useCanonicalUserView` at every audited
user-rendering site (sidebar, header, search, message bubble, reply
chips, profile popout/modal, group settings, voice tiles, mention
chips, member lists, friends, invites). Self-rendering sites compose
alongside via existing `isSelf`/`resolveDisplayIdentity`.
- Globe predicate hoisted to `isFederationGlobeApplicable` and applied
at three sites, gating on `domain !== window.location.host` so we
never show the globe for users whose home IS our own.
Tests: 31 new unit tests across `identity`, `userViews` store, and
`userViewLookup`. Full suite 276/276.
Docs: `client-federation.md` §3 gains a "User View Cache" section
parallel to "DM Origin Failover"; `dm-system.md` notes the new store
action and WS handler upserts.
Bug 3 (federation profile-sync gap — orbit's stale profile data on
nova-Axel after a clear/color-change on nova never propagated)
remains open. The user-view cache routes around it for the common case
(home instance is connected), but the underlying S2S relay gap is its
own diagnosis and follows in a separate branch.
The fallback <div>'s inline style only carried `background` when src was
falsy, so an <img> that 404'd flipped to display:flex via onError but with
no gradient applied — surfacing a colorless letter for any broken avatar
URL. Always paint the gradient + fontSize; keep `display:none` as the only
src-conditional override.
Without a sync-ready gate, a transient GET failure during autoConnectAll
left the local registry Map empty/incomplete while `set()` still computed
`registryUpdatedAt = Date.now()`. The trailing `syncRegistry()` would then
PUT the empty payload with a fresh timestamp; the server's LWW guard
accepted it and legitimate registry rows were wiped — including
remote-instance entries the user never explicitly removed.
Add `_registrySyncReady` flag, set true only after a successful initial
GET. `syncRegistry()` short-circuits while false, so the degraded mode
(GET failed) is display-only: the Map is still populated locally from
\`replicatedInstances\` synthesis (status \`auth_expired\`) so the
Connections UI shows the user's known remotes, but mutations don't push.
On the next session where GET succeeds, localStorage cached tokens
reseed the registry and \`syncRegistry()\` pushes the merged authoritative
state — no data lost, sync deferred until we have a complete picture.
\`reset()\` clears the flag alongside the registry. Spec updated with the
sync-ready gate and degraded-mode behavior. Unit tests cover the gate
on initial fail, mutations during degraded mode, recovery on next
successful GET, and the synthesis fallback for empty replicatedInstances.
The initial-load skeleton was rendered as an early return that replaced
the JSX containing `containerRef` / `contentRef`. On slow loads, the
200ms threshold flipped the skeleton on before messages arrived, so when
messages did arrive every scroll-affecting effect (Effect A, the
ResizeObserver, the load handler, scrollend) re-fired exactly once
against null refs and bailed — and never re-attached because no dep
changed when the skeleton finally cleared. Net: chat opened scrolled to
the top instead of the bottom; saved-anchor restore was equally broken.
Render the skeleton as an absolutely-positioned overlay alongside the
(always-mounted) scroll container so all refs stay live across the
loading transition. Encode the constraint in the spec as the
"ContainerRef invariant" so future loading/empty/error UI doesn't
reintroduce the early-return pattern.
Replaces desktop and web icon binaries with the new brand mark, ships
PNG raster exports under assets/brand/ alongside the existing SVG
sources, adds Alternative Styles artwork variants, and updates the
icon generation script and System Prompt doc. deploy.sh excludes
the new local-only artifacts (*.rtfd, assets/brand, electronbuild.sh,
multi-platform-roadmap.md) so they don't get rsync'd to the live
boxes. Drops ARCHITECTURE_AUDIT.md (obsolete).
- App.tsx and main.tsx ErrorBoundary gate rendererReady() on
VITE_FORCE_BOOT_STALL build env var. When set, the ping is suppressed
so the main-process boot timer fires (exercising the renderer-stalled
recovery path without hand-editing source).
- vite-env.d.ts declares the env var type so TS doesn't complain.
- scripts/smoke-recovery.sh automates scenarios 2 (bad URL → load-failed)
and 4 (forced stall → renderer-stalled), grepping stderr for the
[recovery] entered: ... lines added by the prior commit. Backs up and
restores the user's instance-url.json. Manual scenarios documented in
the script header.
App.tsx useEffect signals boot-completion (success path). ErrorBoundary
componentDidCatch signals it on the caught-error path so the in-app
error UI isn't overridden by native recovery 20s later.
- rendererReady (boot-completion ping)
- getRecoveryState / onRecoveryStateChanged (recovery.html subscribers)
- recoveryAction (button click dispatcher with enum action)
Type declarations kept ambient (no export) to preserve window.backspace
global augmentation — exporting from an ambient .d.ts converts it to a
module and breaks the Window interface extension.
- Uses shared SectionShell/DropdownItem from ./_shared/SettingsPickerPrimitives
rather than redefining local copies (parity with AudioInputSection).
- supportsSinkId is reactive to AudioContext lifecycle via the audioCtxGen
pattern (bumped by AudioManager.onResumed). Defaults to true and only flips
to false when a real context exists AND lacks setSinkId (Safari < 17), so
the picker is never preemptively hidden when the user opens Settings before
joining voice. AudioManager.setOutputDevice + initContext re-apply path
handles the deferred sinkId binding once the context appears.
- AudioInputSection now subscribes to AudioManager.onResumed and bumps an
audioCtxGen state on each 'running' transition. Mic-level meter and
resolved-default hint effects depend on it, so opening Settings before
joining voice and then joining voice activates the meter without needing
to remount the panel. Footer copy updated to match the new behavior.
- SectionShell and DropdownItem extracted to
settingsPanels/_shared/SettingsPickerPrimitives.tsx so Task 6
(AudioOutputSection) can import them instead of triplicating the markup.
The _shared/ folder keeps these settings-internal primitives out of the
broader ui/ namespace.
- Deliberate scope choice: VideoSection.tsx still has its own DropdownItem
copy. Unifying all three is left to a follow-up; touching VideoSection
here would expand scope beyond the audio-device-ux branch.
The previous Task 4 handler installed onended on the *published* mic track,
but that track is a clone of AudioManager's MediaStreamAudioDestinationNode
output — destination-node tracks never end on upstream loss, they just go
silent. The handler also called setInputDevice for silent-recovery and assumed
syncMic would re-publish, but syncMic's dep array does not depend on
streamGeneration, so the recovery never republished.
This commit moves loss detection into AudioManager (where the upstream
getUserMedia track lives) via a new onInputTrackEnded subscription, extracts
republishMicrophone from syncMic into a module-level helper that both the
normal device-change path and the recovery path call directly, and removes
the published-track Microphone branch from RoomEvent.LocalTrackPublished.
The plan and Task 9 spec text are updated to match.
Replaces the full-bleed black-bg logo badge in the top-left @me tile with a
white logo mark on the primary purple (matching toggle switches) when active
or hovered, and `--interactive-muted` grey when idle. Logo bumped to 25px.
Pending bubbles that survive a reload (or post-redeploy refresh) without a
FileSystemFileHandle had no way to recover the original File bytes, so the
Retry button silently no-op'd: resumeUpload set state back to 'paused' with
no surfaced error, leaving the user stuck.
- transferStore: add reactive hasInMemoryFile Set mirroring liveUploadFiles.
setInMemoryRef helper keeps both in sync at every set/delete site
(startUpload, resumeUpload, onSuccess, remove). Persisted shape is unchanged.
- resumeUpload: when no blob is reachable (no in-memory File and no FS handle),
call setError with an actionable "File no longer available — discard and
re-upload" message instead of silently flipping back to 'paused'.
- Message.tsx: compute canRetry reactively from transfersForRow + hasInMemoryFile;
hide the Retry button when retry is infeasible. Discard remains.
- AttachmentProgress: optional error prop surfaces transfer.error.message via
title= on the failed-state ring for hover context.
- Tests: existing 3 resumeUpload tests now assert state==='failed' with the
actionable message; +2 new tests for hasInMemoryFile lifecycle (start/remove,
abort retains).
Adds an admin-driven sweep on top of the existing 24h auto-expire so
operators can see and reap abandoned `.tus/` sessions without waiting.
- storageJanitor: extract `walkTusDir(predicate)` helper, add
`getStaleTusInfo` + `cleanupStaleTusSessions(thresholdMs, dryRun)`;
refactor `cleanupTusStragglers` to delegate while preserving its
janitor-tick `{ removed }` contract.
- StorageStats gains `staleTusSessions` + `staleTusSize` (fixed 1h
display threshold).
- New `POST /api/admin/storage/cleanup-tus` route with
`maxAgeHours` validation (positive finite number, default 1) and
`dryRun` support; admin-gated.
- StoragePanel: 6th overview card "Stale Uploads" + new cleanup
subsection mirroring the media-cleanup pattern (preview-then-clean
with shared result panel styling).
- Tests: 8 new janitor tests covering empty dir, threshold filtering,
dry-run vs live, oldest-mtime tracking, subdir skipping, and the
override path on the existing straggler sweep. New
`routes/admin.test.ts` covers auth/admin gates, validation (zero,
negative, NaN), default `maxAgeHours`, dry-run vs live unlink.
- Docs: `uploads.md` §Janitor expanded to the full lifecycle (cancel
DELETE, discard DELETE, auto-expire, straggler sweep, admin route);
`admin.md` Storage Management updated with the new endpoint and
StorageStats fields.