Commit Graph
747 Commits
Author SHA1 Message Date
Jannis Braun dacb6df871 test(sounds): tighten encodeStreamWatch round-trip assertion to verify Uint8Array contract 2026-04-28 14:23:54 +02:00
Jannis Braun 6a4eac13a3 feat(sounds): add stream_watch data-channel protocol helpers 2026-04-28 14:19:41 +02:00
Jannis Braun 40dd2ab52f feat(sounds): add shouldPlayMessageSound filter (DM + federation-aware mention) 2026-04-28 14:13:56 +02:00
Jannis Braun eede7aeb38 chore(branding): clean up product description strings
Strip the desktop package description to "Backspace" so Windows Task
Manager shows the bare product name instead of the long tagline.
Unify the web meta description and PWA manifest description on a single
positioning line that names Discord and TeamSpeak as the comparison
targets — improves link-preview copy and SEO surface.
2026-04-28 11:39:08 +02:00
Jannis Braun 4748df8b24 fix(camera): dormant-by-default preview, never trigger camera on tab open
Voice & Video tab no longer fires getUserMedia on mount. Uses
navigator.permissions.query({name:'camera'}) for state detection,
which is passive (no LED activation). Preview only opens when the
user explicitly clicks the dormant tile or the prompt-state CTA.
In-call mode unchanged (attaches existing LK track, no extra LED).

Spec and plan updated to reflect the corrected design — the
"auto-start preview, no Test Camera toggle" rationale was wrong;
macOS holds the camera LED on for ~2s after release, so any
incidental getUserMedia call (probe, transient mount) flashes the
LED in the user's face even when they aren't on the Voice & Video
panel. Privacy-correct UX: never light the LED without an explicit
user action.
2026-04-27 21:13:58 +02:00
Jannis Braun 2bec5b9d7f feat(settings): rename Voice tab to 'Voice & Video' to reflect added video section 2026-04-27 20:55:42 +02:00
Jannis Braun 93e1478346 feat(voice-panel): mount Video section between Volume and Voice Processing 2026-04-27 20:47:19 +02:00
Jannis Braun 47653ab14a feat(video-section): tab-visibility cleanup and currently-using subline 2026-04-27 20:46:04 +02:00
Jannis Braun 37caf30b84 feat(video-section): in-call preview attaches to LK track
Two-mode preview: in-call attaches to the LiveKit local camera track
(no double-capture); pre-call uses getUserMedia. Modes transition
reactively on isCameraOn changes.
2026-04-27 20:43:40 +02:00
Jannis Braun 4c6bc9e822 feat(video-section): pre-call preview tile auto-starts getUserMedia 2026-04-27 20:41:03 +02:00
Jannis Braun 9b402ed291 feat(video-section): enumerate cameras and render dropdown
Empty labels fall back to 'Camera N'; duplicate labels are
disambiguated with ' (1)', ' (2)' suffixes by enumeration order.
'No cameras detected' subline appears when enumeration is empty.
2026-04-27 20:39:29 +02:00
Jannis Braun 5494460834 feat(video-section): scaffold component with permission-probe state machine 2026-04-27 20:35:29 +02:00
Jannis Braun c9b08cdfd2 feat(devices): sweep stale persisted device ids on mount and devicechange 2026-04-27 20:30:54 +02:00
Jannis Braun 786823946a feat(camera): handle camera-track end with branched toast (unplug vs permission revoke)
LocalTrackPublished registers a one-shot onended on the camera track's
MediaStreamTrack. The handler:
  - skips when consumeIntentionalCameraOff() flag is set (user-initiated)
  - re-probes getUserMedia to distinguish NotAllowedError (permission
    revoked) from NotFoundError (disconnected) from other errors
  - tears down camera state via the unified path

Also reset _intentionalCameraOff in voiceActions if setCameraEnabled(false)
rejects, so a failed disable doesn't poison the next genuine unplug.
2026-04-27 20:29:32 +02:00
Jannis Braun e8534339fb feat(camera): syncCamera effect hot-swaps publication on cameraDeviceId change
Uses room.switchActiveDevice('videoinput', id) to swap the underlying
MediaStreamTrack without re-publishing. Compares against the published
track's actual getSettings().deviceId so null→explicit-same-device is a
no-op. Rolls back on failure with a 'Could not switch camera' toast.
2026-04-27 20:23:00 +02:00
Jannis Braun 2e41edbc99 feat(camera): use cameraDeviceId from voiceStore on camera enable 2026-04-27 20:17:12 +02:00
Jannis Braun 51ad54fda5 refactor(camera): unify toggle paths through handleCameraAction
- Voice-bar and mobile camera buttons now use the canonical handler
  (fixes mobile no-op and voice-bar wrong-preset bugs)
- Remove dead useLiveKit.toggleCamera
- Add _intentionalCameraOff flag with mark/consume helpers
2026-04-27 20:16:14 +02:00
Jannis Braun 486448607e feat(voice-store): add pruneStaleDevices to clear stale persisted device ids 2026-04-27 20:08:42 +02:00
Jannis Braun 770a77a473 feat(voice-store): add cameraDeviceId persisted field 2026-04-27 20:07:23 +02:00
Jannis Braun 8ba644fa44 fix(message-list): pagination flag and scroll restore leak across channel switches
Two bugs in handleScroll's loadMoreMessages flow surfaced after the
smooth-scroll race fix.

(1) isLoadingMore stuck across channels. setIsLoadingMore(true) → await
loadMoreMessages → setIsLoadingMore(false) was unguarded. If the user
switched channels during the await, the new channel inherited the flag (same
component instance, same useState slot) and rendered the pagination skeleton
even with no load in flight. Cleared only when the original await resolved
or the component remounted (e.g., navigating to Friends and back).

(2) Wrong-channel scroll restore. The post-await rAF set
container.scrollTop = container.scrollHeight - prevScrollHeight against the
new channel's container with the old channel's prevScrollHeight, yanking
the new channel to a wrong position.

Fix:
- try/finally around the await so setIsLoadingMore(false) always runs.
- currentChannelIdRef tracks the live channelId; capture requestChannelId at
  load start and compare both before scheduling the rAF and inside the rAF
  callback (the 16ms frame gap is enough for a switch).
- Belt-and-suspenders: setIsLoadingMore(false) in the channel-switch effect
  covers the case where the await never resolves (network hang). Without it,
  a stuck await would leave the new channel inheriting the flag indefinitely.

No request cancellation — out of scope; AbortController plumbing through
chatStore is a bigger refactor and the channelId guard already silently
drops stale results.

Spec updated. Smooth-scroll fix from the previous commit untouched.
2026-04-27 18:11:31 +02:00
Jannis Braun b6b830568c fix(message-list): close smooth-scroll-to-bottom race against late-loading media
Smooth scrolls toward the bottom (new-message arrival in Effect A and the
Jump-to-Present click) animate scrollTop over many frames. Each intermediate
handleScroll measurement saw a large distanceFromBottom and flipped
isAtBottomRef to false, closing the Effect B/C gates. Lazy media (avatars,
embeds, Spotify thumbs) finishing mid-animation grew scrollHeight while the
gate was closed, so the smooth scroll landed at its originally-computed
target — leaving the user above the new bottom by ~the height of what loaded.

Fix: typed smoothScrollIntentRef ('bottom' | 'message' | null) with an 800ms
deadline. handleScroll suppresses the at-bottom flip while intent is 'bottom'
and the user hasn't wheeled past the 5000px nearBottom threshold. Effect D
fires a final defensive instant pin via native scrollend (Chrome 114+,
Safari 18+) or a setTimeout(800) fallback. 'message' intent (jump-to-message
from search) does NOT suppress — the gate flips honestly so the user is left
at the targeted message.

Verified live on nova.ddns.net Orbit → general: Jump-to-Present
lands flush at bottom; new Spotify-link messages stay at bottom as embeds
arrive via WS. docs/systems/message-list.md updated.
2026-04-27 17:51:55 +02:00
Jannis Braun de2ef10129 fix(brand): home tile = Element 1 with #000 bg, drop tile lavender
Iterating-by-paint-over wasn't working. Going back to first
principles: the brand IS the badge — full Element 1 mass, mark with
its own internal padding, dark frame around it. That's what reads as
"Backspace" on every other surface (dock, taskbar, favicon, apple-
touch). The sidebar home tile should look the same.

The earlier "ugly grey" complaint about Element 1 in the sidebar
came down to one specific colour mismatch: the source SVG's
#1d1d1b (warm near-black) sat next to the sidebar's #1a1a23 (cool
near-black) and read as an off-grey rectangle. Fix: in the generator,
do a string-replace `#1d1d1b → #000000` on app-icon.svg before
rendering logo.png only — every other output (.icns, .ico, favicons,
PWA) keeps the original brand `#1d1d1b`. Pure black against the
sidebar's #1a1a23 reads as a deliberately darker tile, not a hue
mismatch.

Reverts the SpaceSidebar lavender-tile workaround: with the badge
filling the tile via object-cover, the button's overflow-hidden +
rounded-[20px → 13px] morph already animates the badge cleanly.
backgroundStyle for type === 'dm' returns undefined again; deps trim
back to what they were.

Determinism gate verified — only logo.png changed (other 21 outputs
byte-identical across two regen runs).
2026-04-27 15:39:54 +02:00
Jannis Braun 343fdd6e3d fix(sidebar): home tile uses brand lavender, not neutral grey
Translucent white over the cool-dark sidebar reads as muddy grey;
that's the same root cause as the earlier #1d1d1b warm-grey complaint.
Any unsaturated tint loses against the dark sidebar bg.

Switching the home tile to accent-lavender (RGB 196,181,253 — the
endpoint of the mark's gradient) breaks the grey because saturation
defeats the grey-on-grey muddiness. Lavender also (a) complements
the gradient mark sitting on top, (b) parallels Discord's brand-
coloured home button pattern, and (c) gives the sidebar a clear
semantic colour hierarchy: action = mint, home = lavender (brand),
spaces = user-defined.

Three states escalate by alpha (0.08 / 0.16 / 0.28) so corner-morph
and fill-brightness both signal interaction.
2026-04-27 15:32:48 +02:00
Jannis Braun 695f699ddf fix(sidebar): give home tile visible hover/active feedback
The home/DM tile previously set backgroundStyle to undefined — the
button had no fill, so the existing rounded-[20px] → rounded-[13px]
corner-radius transition had nothing to morph and produced zero
hover/active feedback. The regression became visible after the icon
rebrand: the old logo.png shipped a #1d1d1b rounded-square that
filled the tile and gave it a de-facto background; the new bare-mark
logo is centred with transparent padding, exposing the missing
button bg.

Apply the same pattern as the existing 'action' tile: a translucent
white surface that brightens through three states (rest/hover/active)
so both the corner morph and the fill change are visible. Active
state (rgba 0.10) is the strongest because it signals 'you are on
the home page right now', complementing the existing pill indicator
on the left edge.
2026-04-27 15:27:27 +02:00
Jannis Braun 7a8d0ce0e2 fix(brand): logo.png is bare mark on transparent, 75% padded
The previous Element-1 (full badge) approach produced a visible
warm-grey #1d1d1b square inside the cool-dark #1a1a23 sidebar — the
two near-blacks differ enough in hue to read as a foreign rectangle
in the slot.

The right answer (third time lucky): the SpaceSidebar's 40×40
rounded-[20px] tile already IS the dark squircle frame. logo.png now
composites the bare gradient mark at 75% on a transparent canvas:
the sidebar's tile bg shows through, the 25% padding keeps the mark
off the squircle edges and clear of the active-state ring, and there
is no foreign-bg colour conflict.

Refactored writeMaskablePng → writeCenteredMarkPng to share one code
path between the maskable PWA (opaque #1d1d1b background, 60% mark)
and the in-app logo (transparent background, 75% mark). Both use the
same composition pipeline; only canvas / scale / bg differ.

Only logo.png regenerated; other 21 outputs byte-identical
(determinism gate verified twice).
2026-04-27 15:20:29 +02:00
Jannis Braun 6b71aecc28 fix(brand): logo.png uses full badge, not bare mark
The SpaceSidebar's home tile is a 40×40 rounded-[20px] squircle with
object-cover. The original spec assumed the sidebar provides "the
colour-circle frame," so logo.png shipped as the bare gradient B on
transparent. In practice the tile is a transparent slot — the bare
mark touched the squircle edges (no internal padding), conflicted
with the active-state ring, and read as poorly-integrated.

Switching to the full badge (Element 1 / app-icon.svg): the badge's
own dark squircle bg fills the tile, its internal padding around the
mark keeps the gradient off the edges, and the result reads as a
clean Discord-style "home space" tile alongside the user's other
spaces.

Only logo.png regenerated; remaining 21 outputs byte-identical
(determinism gate verified). Spec output matrix and generator README
updated to reflect the corrected source mapping.
2026-04-27 15:06:22 +02:00
Jannis Braun 56e6c72322 chore(brand): regenerate icon set from new SVGs
First run of pnpm gen-icons against the new artwork. All 22 outputs
generated deterministically (verified by hashing twice). Replaces the
dated raster set everywhere — desktop app icons, tray, web favicons,
PWA, in-app logo.
2026-04-27 14:32:05 +02:00
Jannis Braun 113efcf583 fix(web): drop optimistic updates and disable inert Start-minimized toggle
- AutoLaunchSettings: remove pre-await state writes; backend response is
  now the sole writer to openAtLogin/startMinimized state, eliminating
  the flicker caused when optimistic values diverged from OS truth
- Add busy flag across IPC round-trip; both Toggles receive disabled={busy}
- Start-minimized Toggle additionally receives disabled={!openAtLogin},
  preventing interaction when it would have no effect
- Toggle.tsx: add optional disabled prop (forwarded to <button disabled>)
  with cursor-not-allowed opacity-50 visual feedback; non-breaking for
  all existing callers
2026-04-27 12:57:34 +02:00
Jannis Braun 4fd67fa7dd fix(web): map peer_pending_local_admin to user-facing message
Without this, when the local admin gate fires on friend-add, the user
got a generic 'Could not send friend request' fallback. Now they see
'Your admin needs to approve federation with this instance' with a
pointer to Connections settings where Task 12's pending-approvals
section shows their queued request.
2026-04-26 22:43:52 +02:00
Jannis Braun cebbd5c859 feat(web): user-facing pending peering subscriptions and outcome notifications
Two new inline sections in the user-facing federation/connections settings
panel: 'Recent peering outcomes' (terminal-state notifications with
Retry-for-approved + Dismiss) and 'Pending peering approvals' (active
subscriber rows the user is waiting on, with Cancel). New WS handlers for
peering_subscription_changed and peering_notification_received refresh the
lists in real-time and surface a transient toast for online users. Retry
deep-link for friend_add prefills the friend-add input with the original
target handle (other reasons get Dismiss only — the gate doesn't wire
those paths yet).
2026-04-26 22:42:53 +02:00
Jannis Braun eddf2254cc feat(web): render outbound peering requests with subscriber list in admin panel
PendingApprovals now branches row rendering on direction. Inbound rows
render exactly as before. Outbound rows show '<instance> — N users
want us to peer' with an inline subscriber list (username + reason +
target). ConfirmDialog descriptions branch on direction so admins
see appropriate copy for outbound approve (initiate handshake on
behalf of N users) vs outbound deny (notify requesting users).
2026-04-26 22:35:16 +02:00
Jannis Braun 51da827089 feat(web): API client methods for peering subscriptions and notifications
- Drops the web-local ApprovalRequest interface in favor of the
  canonical shared type (Task 2 already established this; Task 10
  reconciles the consumer side).
- Adds five new federation methods wrapping the REST endpoints
  added in Tasks 8 and 9: peeringSubscriptions (GET), cancelPeering
  Subscription (DELETE), peeringNotifications (GET +/- unreadOnly
  filter), markPeeringNotificationRead (POST :id/read), markAll
  PeeringNotificationsRead (POST read-all).
2026-04-26 22:32:36 +02:00
Jannis Braun fe969a7d96 fix(web): friend-add error toasts surface raw codes — read err.message, not err.body
The T19/T20 catch blocks looked for an `.body` property on thrown errors
to extract the structured error code. The shared API client (api/client.ts:298)
actually throws `new Error(body.error)` — the code lives in `err.message`,
and there's no `.body` attached.

Live E2E (T22 scenario 2) caught this: typing alice@orbit against an
awaiting_approval peer surfaced the raw code 'peer_pending_approval' as
the toast text instead of the human-readable mapServerErrorToMessage
output. Same defect would have hit every server-error toast on both
FriendsPage (AddFriend + UserDiscoverCard) and UserProfileModal.

Catch blocks now use err.message as both the code and the fallback text;
the inline comment points at the API client throw site so the contract
is documented at the consumer.
2026-04-25 23:17:19 +02:00
Jannis Braun 4a939b743f refactor(web): UserProfileModal — toast on server errors, drop ConnectInstanceModal triggers
Same pattern as FriendsPage cleanup (T19): the friend-add catch no longer
branches on the deleted InstanceNotConnectedError / InstanceDisconnectedError;
the ConnectInstanceModal trigger is removed since the server handles
all routing/peering. Errors surface via toast using mapServerErrorToMessage.

This restores the web package to a compileable state.
2026-04-25 22:35:52 +02:00
Jannis Braun 7309f44de5 refactor(web): FriendsPage — toast on server errors, drop ConnectInstanceModal triggers
Removes try/catch on the deleted InstanceNotConnectedError/Disconnected
classes (T17). Server now returns structured error codes; client maps
them to human-readable toasts via the new mapServerErrorToMessage helper.

The friend-add flow no longer triggers ConnectInstanceModal — the server
handles all routing/peering/lookup. The modal itself stays for Connections
settings and space-join flows.
2026-04-25 22:33:26 +02:00
Jannis Braun 9d3f75b33c feat(web): WS handlers for friend_request_sent + friend_request_relay_failed
Multi-tab sync: friend_request_sent appends the new outbound request to
socialStore (deduped by id+origin), no toast.

Async rollback: friend_request_relay_failed removes the row by id and
surfaces a warning toast with the target handle and reason. Wires the
client side of the rollback hook from T10.
2026-04-25 22:29:45 +02:00
Jannis Braun b28bf6646d refactor(socialStore): collapse sendFriendRequest; delete federation error classes
Server now handles all parsing/routing/peering/lookup (T11-T14). Client
sends the trimmed username verbatim to /api/social/requests and surfaces
server errors via toast (added in T18-T20).

Note: FriendsPage.tsx and UserProfileModal.tsx will fail to compile
until T19 and T20 remove their now-dead try/catch blocks for the
deleted error classes. TypeScript catches it; pnpm dev will not start
until those tasks land.
2026-04-25 22:26:58 +02:00
Jannis Braun 92a8a6a727 chore(friends): tighten Direct-Add gate consistency
Per code-review: directAddDisplay now reads directAt === -1
instead of re-deriving includes('@'); add a one-line comment on
showDirectAdd so the predicate's intent is obvious at first read.
2026-04-25 19:34:29 +02:00
Jannis Braun da98d78489 feat(friends): always-visible Direct-Add row with resolved-form display
The Send-Friend-Request action row in the Add Friend tab previously
appeared only when the typed query contained a non-edge @, leaving
no way to fire a blind request for a bare local handle. Widen the
gate to allow non-empty bare handles, keep the malformed @ shapes
(@, @bob, bob@) hidden. When the typed query has no @, display the
resolved form <query>@<window.location.host> so the user sees which
instance the request will hit. Submission string is unchanged.

Updates FriendsPage.test.tsx: inverts the now-stale 'does not show
Direct Add row for plain usernames' test into the new positive
assertion, and adds a separate test for the malformed @ shapes.
2026-04-25 19:32:03 +02:00
Jannis Braun 781e293cf7 chore(social-client): drop redundant comment, harden defineProperty
Per code-review:
- Drop the inline comment in sendFriendRequest; the commit message
  for the prior commit already covers the why and CLAUDE.md prefers
  no comments when the code is self-explanatory.
- Add writable: true to the window.location defineProperty in the
  test so re-firing beforeEach across jsdom version drift is safe.
2026-04-25 19:29:06 +02:00
Jannis Braun fba1f0b87d fix(social-client): lowercase parsed @domain in sendFriendRequest
Hostnames are case-insensitive (RFC 4343), and both right-hand
sides of the routing comparisons (window.location.host and
URL.host) are already canonical lowercase. The user-typed domain
substring was compared with strict ===, so ORBIT.ddns.net
failed to match an existing connected peer and popped a spurious
Connect Instance modal. Normalize at parse time.
2026-04-25 19:20:07 +02:00
Jannis Braun d659637930 docs(message-list): note smooth-scroll exclusion; point sentinel comment at subsystem doc
Reviewer caught two small gaps after Task 3:
- Effect A's smooth-scroll path on new messages is intentionally NOT
  instrumented with the sentinel (the animation lands asynchronously
  across frames; no intermediate scrollTop is worth pinning to). The
  doc now records this so the reader's intuition matches the code.
- The sentinel-branch comment in MessageList.tsx pointed at "spec §2",
  which is the planning doc rather than the durable subsystem spec.
  Pointed at docs/systems/message-list.md instead.
2026-04-25 13:11:05 +02:00
Jannis Braun d18845acb5 fix(web): close handleScroll race that disabled auto-bottom on cold-cache image load
Tracks the post-clamp scrollTop of every programmatic scroll-to-bottom in
lastProgrammaticBottomScrollRef. handleScroll skips the at-bottom flip and
re-pins when the event's scrollTop matches the sentinel — i.e., the event
was queued by our own command and layout grew underneath. User scrolls
break the match (scrollTop changes) and flow through the normal path.

This complements the 2026-03-25 race-fix (which gated auxiliary effects on
isAtBottomRef) by also preventing handleScroll from flipping that ref to
false based on a post-growth distance measurement of our own scroll.
2026-04-25 13:02:01 +02:00
Jannis Braun 84ec8e04b7 fix(web): restore known-dimension reservation in ImageEmbed without letterbox fallback
Restores the dimension reservation reverted in dae6f2d, scoped to
the embed.width && embed.height case only. No fallback aspect-ratio
when dims are null — that was the source of the dark letterbox bars
on Tenor/Klipy GIFs and OG-less images that triggered the revert.

The server-side probe in embedResolver.ts:196 already populates dims
for all image-type embeds; this change makes the client honor them.
2026-04-25 12:53:11 +02:00
Jannis Braun b16ece93b8 feat(web): no_recipient toast copy arm (#18)
buildCallUndeliverableToast renders "{peerLabel} couldn't ring anyone."
for the single-failure terminal case; multi-failure + non-terminal paths
fall through to existing lines (which already fold the new reason in by
peer label). TDD — four new assertions.
2026-04-24 21:15:21 +02:00
Jannis Braun 5e509cf3df feat(web): host_unreachable phase copy for dm_call_undeliverable (TDD) 2026-04-24 00:51:30 +02:00
Jannis Braun 6a5b02b1a0 feat(web): phase-aware dm_call_undeliverable toast copy (TDD) 2026-04-23 23:21:40 +02:00
Jannis Braun 086158511c test(join-space): update stale assertions to match current UI and API
Four assertions drifted from the current JoinSpace modal, carried over
when the file was renamed from the old JoinServer component in fc06e25
without being updated:

- Placeholder was expanded to cover URL-form invite input
  ('e.g. abc123' → 'e.g. abc123 or https://instance.com/join/abc123').
- 'shows validation error when submitting empty code' asserted a code
  path that no longer exists: the submit button is now disabled when
  the trimmed input is empty (JoinSpace.tsx line 166), so clicking it
  is a no-op and the 'Invite code is required' error from the parser
  is unreachable from the rendered form. Replaced with an assertion
  that the button is disabled while the input is empty — the actual
  validation UX.
- joinByCode signature took on a second `origin` argument during the
  S2S DM unification + federated-join work (spaceStore.ts line 69).
  parseInviteInput returns { code, origin: undefined } for a bare
  code, so the call is `joinByCode('my-invite-code', undefined)`.
  Assertion updated to match exactly.

No code behavior change — tests now reflect actual behavior, which
was already correct and deployed. Closes backlog #28.
2026-04-23 02:52:00 +02:00
Jannis Braun 4d2e50b55b fix(web): extract cross-store resolvers into neutral utility to break TDZ
instanceStore registers three resolver functions at module load —
setApiForOriginResolver, setUserIdForOriginResolver,
setOriginFromHostnameResolver — whose backing `let` bindings used to
live in spaceStore. When the module graph was entered from
instanceStore (e.g. JoinSpaceModal importing useInstanceStore) the
order became spaceStore → chatStore → useWebSocket → socialStore →
instanceStore (top-level setter call) while spaceStore was still
paused on its line-8 chatStore import, so the backing `let` had not
been reached yet and the setter crashed with
`Cannot access '_getApiForOrigin' before initialization`. This left
InviteModal.test.tsx and JoinSpace.test.tsx unable to even load their
suites once AudioManager was mocked away.

Move the three `let` bindings, their setters, their pure getters, plus
the WS-populated user-ID cache (`_myUserIdByOrigin`, setMyUserIdForOrigin,
getCachedUserIdForOrigin, clearMyUserIdCache) into
`packages/web/src/utils/crossStoreResolvers.ts`. The utility imports
nothing from `./stores/*`, so no back-edge exists. spaceStore re-exports
the public surface for backward compatibility with the many existing
import sites; instanceStore imports the setters directly from the
utility (the in-cycle re-export path does not resolve at module-init
time under vite-ssr, so a direct import is required for the top-level
setter calls).

spaceStore's remaining wrappers (resolveUserOrigin, getLayoutHomeOrigin,
getMyUserIdForOrigin) stay where they are — they combine the utility's
pure lookups with authStore state — but now delegate to the utility.

Also adds the AudioManager mock to InviteModal.test.tsx and
JoinSpace.test.tsx so their suites actually load (same pattern already
used in 5 other test files). Net test-suite result: 127/131 pass (up
from 121/121 — +6 newly unlockable). The 4 remaining JoinSpace
failures are pre-existing stale UI-text assertions (the placeholder was
expanded and the submit button was made disable-when-empty) made
visible by the suite now loading; they're orthogonal to this change
and handed back for a separate triage.

Closes backlog #27.
2026-04-23 02:46:19 +02:00
Jannis Braun ae5bdaa338 test(friends): drop stale origin arg from addDmChannel assertion
The DM "Message button" test asserted addDmChannel was called with two
arguments — the channel and an empty-string origin — but the assertion
has been stale since commit 7f3ca4e ("route DM creation to home instance
with federated identity", 2026-04-01). That refactor made FriendsPage
always route DM creation through the home api client and dropped the
second argument from the addDmChannel call because the remote friend's
instanceOrigin no longer applies — home-created DMs don't need a
channelOriginMap entry (lookups default to '' for missing keys; remote-
delivered DMs still get their origin tagged by useWebSocket).

The two-arg assertion was introduced on 2026-03-25 (commit 277b69a)
against an intermediate form of the code that was later rewritten. Drop
the trailing '' so the assertion matches the current, intentional
one-arg call.
2026-04-23 02:28:57 +02:00