Commit Graph
178 Commits
Author SHA1 Message Date
Jannis Braun c3fe1bc9d4 feat: track awaitingApprovalPeerOrigins and show login toast for pending approvals 2026-04-20 15:06:00 +02:00
Jannis Braun 4285e44d2d feat: client ensurePeered API, connection flow swap, WS event handlers for peer rejection 2026-04-09 13:56:18 +02:00
Jannis Braun 016ca2c59b fix: use canonical identity for federated friend/request dedup
The socialStore WS-driven handlers (addFriendFromAccepted,
addIncomingRequest, removeFriendLocally, removeRequestById,
updateFriendPresence) used instance-local id:origin composite keys
for deduplication. When the client is connected to multiple instances,
both fire WS events for the same federated user with different local
IDs, bypassing the dedup and creating duplicate entries.

Switch all handlers to use homeUserId??id (canonical identity),
matching the pattern loadFriends/loadRequests already use. Also
replace the loadRequests() re-fetch in updateFriendRequest with
optimistic canonical removal to avoid racing S2S relay propagation.
2026-04-09 01:23:49 +02:00
Jannis Braun a1a7c2ff18 fix: null roomRef before destroyRoom to suppress teardown sounds
During room.disconnect(), LiveKit fires ParticipantDisconnected for
each remote participant BEFORE the final Disconnected event. Because
roomRef was still set, guardedUpdate() called updateParticipants(),
which updated the voiceStore while isLiveKitConnected was still true.
SoundController played user_leave for each departing participant
alongside the disconnect sound.

Fix: set roomRef.current = null before calling destroyRoom(). This
causes guardedUpdate() to return early for all teardown events.
The disconnect function handles cleanup after destroyRoom resolves.
2026-04-08 15:39:21 +02:00
Jannis Braun 22aa1e3f66 fix: batch participants + isLiveKitConnected on disconnect to prevent double sound
RoomEvent.Disconnected handler set participants=[] and
isLiveKitConnected=false in separate setState calls. SoundController
subscription fired between them — saw empty participants while still
"connected" → played user_leave, then saw disconnected → played
disconnect. Both sounds played simultaneously.

Batching into one setState ensures SoundController sees the final
state atomically: participants gone AND disconnected in one update.
2026-04-08 14:59:08 +02:00
Jannis Braun c037803c5a fix: acceptor sets activeDmCall in click handler, not server response
Bug A: handleAccept relied on dm_call_accepted server response to set
activeDmCall. But connectFn's async AudioContext resume yields to the
event loop, dm_call_accepted arrives during the yield, finds
isLiveKitConnected=false (connectFn just reset it), and skips
setActiveDmCall. The acceptor connects to LiveKit but the UI never
shows the call. Fix: set activeDmCall and clear incomingCall
directly in the click handler.

Bug B: ready handler no longer sets activeDmCall for active calls.
On refresh/restart the client has no LiveKit connection — showing
"Connecting..." with no connection is broken. The call exists on
the server but this client session is disconnected.
2026-04-08 14:23:41 +02:00
Jannis Braun aae0b1a74e fix: comprehensive client-side session management for federated DM calls
Four fixes addressing the full state management problem:

1. Passive ready handler: no longer auto-connects to LiveKit on
   page refresh. Prevents identity conflicts when the same user
   has multiple sessions fighting for one LiveKit identity slot.
   The user must re-accept to join; state is shown but not acted on.

2. SoundController sync guard: incomingCallLoading/outgoingCallLoading
   refs prevent multiple playSound calls during async audio load.
   If call is cancelled while sound loads, stops it immediately on
   completion. Eliminates the "5 ringtones at once" bug.

3. Host dm_call_accepted broadcasts now include federatedCallId so
   all clients (including remote instances) can match the event.

4. Removed all diagnostic console.log statements.
2026-04-08 14:12:45 +02:00
Jannis Braun 86fe713a7c fix: cross-instance event contamination in federated DM calls
Root cause: sendToFederatedCallUsers used sendToDmMembers when dmChannelId
was set, which broadcast to ALL DM members including the caller's replicated
stub. The caller's multi-instance WS received dm_call_accepted with the
REMOTE instance's dmChannelId, causing token request for a non-existent
channel (403) and preventing the caller from connecting.

Fix 1: sendToFederatedCallUsers always uses ringedUserIds (exact recipients)
instead of sendToDmMembers (all members including caller stub).

Fix 2: dm_call_accepted handler only sets activeDmCall if the client is the
caller (wasOutgoingCall) or already connected to LiveKit. Other instances of
the same user just clear ringing without entering stuck "Connecting..." state.
2026-04-08 13:50:25 +02:00
Jannis Braun df2701b17f fix: route call accept/reject through delivering WS, not host URL
callOrigin was set to event.callOrigin (the HOST instance URL), which
routed accept/reject through the multi-instance WS connection. On
mobile hotspot or when the multi-instance WS drops, the accept is
silently lost — the host never knows, the call stays ringing forever.

Now callOrigin = origin (the WS that delivered dm_call_incoming).
This is always connected. The server on that instance finds the
FederatedCallEntry and relays to the host via S2S HTTP, which is
reliable and independent of client WS state.
2026-04-08 13:15:38 +02:00
Jannis Braun 5265117d29 fix: three root causes for federated DM call failures
1. Accept/reject/end from remote instance now resolves federatedId
   to local dmChannelId via DB lookup, so the host can find its
   VoiceRoom when the event arrives with only a federatedCallId.
   Previously silently failed with "No active call" error.

2. Batch all dm_call_incoming state updates into a single
   useVoiceStore.setState() call. Prevents SoundController from
   starting multiple ringtone instances (async playSound guard
   race when 4 separate set() calls each triggered the subscription).

3. Always overwrite callOrigin/federatedCallId (with null if absent)
   on dm_call_incoming. Prevents stale values from a previous
   federated call routing local accepts to the wrong instance.
2026-04-08 12:53:58 +02:00
Jannis Braun 9ad240495f fix: prevent auto-connect on dm_call_accepted for non-caller instances
Three fixes for multi-instance call state consistency:

1. Client dm_call_accepted handler only auto-connects to LiveKit if
   the user was the caller (outgoingCall was set). Other instances of
   the same user just clear ringing state without connecting.

2. Server processDmCallAcceptEvent remote path skips duplicate
   broadcast when FederatedCallEntry is already active (prevents
   state conflicts from host fan-out arriving after local accept).

3. Ready payload handler clears stuck incomingCall when restoring
   an already-active call after page refresh.
2026-04-08 12:21:27 +02:00
Jannis Braun ba7b975bc8 feat: add federatedCallId and callOrigin to client call routing
Enable federated DM calls to route accept/reject/end through the correct
WebSocket connection using callOrigin, and include federatedCallId in all
dm_call payloads for server-side FederatedCallEntry lookup.
2026-04-08 03:26:48 +02:00
Jannis Braun 95f97c1526 feat: process DM WS events from all origins
Remove home-only restriction on DM events. Add federatedId
dedup check to dm_channel_created to prevent duplicate sidebar
entries for cross-instance DMs.
2026-04-07 19:55:08 +02:00
Jannis Braun 4c2e75b80c feat: handle deleted user cleanup in user_updated WebSocket handler 2026-04-03 04:23:18 +02:00
Jannis Braun b77aad61eb refactor(web): unify DM sorting with sortDmChannels across all triggers 2026-04-02 17:56:26 +02:00
Jannis Braun 187f71e68e feat: add dm_typing_stop event to clear typing indicator on message send
Broadcasts dm_typing_stop to DM members before dm_message_created,
so the typing indicator clears immediately when a message arrives
instead of lingering for up to 3 seconds after delivery.
2026-04-01 12:50:03 +02:00
Jannis Braun 2f211e0588 feat(federation): useLiveKit connects to remote LiveKit for federated calls (FED-009) 2026-03-31 23:41:50 +02:00
Jannis Braun 24571edc34 feat(federation): client voice store handles federated call data (FED-009) 2026-03-31 23:40:11 +02:00
Jannis Braun c3191be4e7 feat: system messages, group DM welcome header, and dm_owner_updated event
- Render system messages (member_added, member_removed, owner_changed) inline
  in MessageList with icon + human-readable text; system messages never group
  with adjacent user messages
- Rewrite WelcomeHeader to branch on ownerId: group DMs show overlapping avatars,
  group name, creator attribution, federated privacy note, and a Leave Group button
- Add dm_owner_updated ServerEvent; broadcast from dm.ts leave handler and
  federation processOwnershipTransferEvent so all clients update ownerId in real-time
- Add updateDmOwner action to spaceStore and handle dm_owner_updated in useWebSocket
- Add leaveGroup alias to API client dm namespace
2026-03-27 05:43:30 +01:00
Jannis Braun 589af25b45 feat(federation): show toast on file_rejected federation event 2026-03-27 04:43:23 +01:00
Jannis Braun b8a41c4484 fix: update discoverStore on friend accept and remove WS events
The friend_request_accepted and friend_removed WS handlers updated
socialStore but not discoverStore, so the discovery page buttons
stayed stale until page refresh. Decline/cancel already updated
both stores correctly.
2026-03-27 01:24:14 +01:00
Jannis Braun f362f5530c fix(federation): prevent duplicate DM sidebar entries from relay broadcasts
When a relay-created dm_message_created arrives for an unknown channel,
check if an existing DM has the same member (by homeUserId). If so,
route the message to the existing channel instead of adding a duplicate
sidebar entry. Prevents the transient duplicate that required page
refresh to dedup.
2026-03-26 14:45:04 +01:00
Jannis Braun 78555a43f8 fix(voice): stabilize grid layout across focus-mode and fullscreen transitions
Refactor useGridLayout from RefObject to callback-ref API so the
ResizeObserver reattaches when the grid container remounts after
focus-mode toggle. Fixes tiles rendering at stale fullscreen dimensions
on 4:3 monitors.

Also clamp focus-mode participant strip height (max-h-[20vh] min-h-[80px])
to prevent it from crowding the focused stream on short viewports.
2026-03-25 03:08:45 +01:00
Jannis Braun d9094fabe1 feat(ws): re-push activities on new instance connections
When a WS connection receives its ready event, push cached myActivities
to that instance so federated users see activities immediately without
waiting for the next debounce cycle.
2026-03-24 22:00:28 +01:00
Jannis Braun 7f4f40a1e5 feat(ws): add wsSendAll utility for multi-instance broadcast 2026-03-24 21:52:51 +01:00
Jannis Braun f21a1f75ce feat(voice): add cachedUser fallback tier to useVoiceParticipantMeta
Inserts a new tier 3 between DM member lookup and identity-only fallback.
When the current space's member list doesn't contain the voice participant
(user navigated to a different space/instance), the hook now falls back
to the cached User object from ParticipantInfo. This preserves avatar,
avatarColor, displayName, and homeUserId for federated users in PiP.
2026-03-24 21:03:46 +01:00
Jannis Braun 1ca5ca3132 fix(voice): align local user homeUserId fallback with null contract
The isLocal branch was falling through to cachedUser.id when
homeUserId was null, diverging from the null-for-home-instance
contract used by the memberMatch and carry-forward branches.
Avatar.tsx already falls back to user.id internally.
2026-03-24 21:01:37 +01:00
Jannis Braun 8910de3053 feat(voice): add cachedUser carry-forward to ParticipantInfo
Extends ParticipantInfo with cachedUser: User | null. In updateParticipants,
snapshots the previous participant list and carries forward the cached User
object when spaceStore member lookup fails (user navigated to a different
space). Local user falls back to authStore. homeUserId is now derived from
cachedUser when the direct member lookup misses. Updates EMPTY_PARTICIPANT
in PictureInPicture.tsx.
2026-03-24 19:01:34 +01:00
Jannis Braun 52c93ae0c5 chore: clean up review feedback — consolidate imports, fix reconnect verb, DRY username 2026-03-24 18:42:45 +01:00
Jannis Braun 947fb30901 feat: add useInstanceConnect hook for federation connect flow 2026-03-24 18:32:20 +01:00
Jannis Braun 69a16ccdd5 fix: preserve hwOverdrive across mid-stream codec restart
stopScreenShare() resets hwOverdrive: false, which clobbers the
user's intent when switching VP9 → H.264 HW mid-stream. Save
and restore the flag around the restart.
2026-03-24 04:35:33 +01:00
Jannis Braun 46c3838193 fix: reset hwOverdrive in all voice cleanup paths
Add hwOverdrive: false to resetSession(), leaveVoice(), and
handleForceDisconnect() store resets. Add deactivateHwOverdrive()
to disconnect callback and cleanup effect in useLiveKit.
Ensures "resets when stream ends" contract holds for edge cases
like server shutdown, identity collision, and logout.
2026-03-24 04:00:37 +01:00
Jannis Braun 66a0793297 feat: add hwOverdrive to updateActiveTracks effect deps for mid-stream switching 2026-03-24 03:52:20 +01:00
Jannis Braun a54dd9084a feat: show encoder implementation in Connection Info, VP9 recommended hint
- Connection Info now shows encoder impl (e.g. libvpx, OpenH264,
  ExternalEncoder) on send-side video tracks alongside quality
  limitation reason
- Amber warning "VP9 is recommended" when H.264 is selected
2026-03-24 01:06:19 +01:00
Jannis Braun 8897a5894c feat: smart codec defaults + mid-stream codec switching
- Gaming mode defaults to H.264 (NVENC hardware encoding, zero CPU)
- Text mode defaults to VP9 (better compression, CPU is free)
- Changing mode auto-updates codec to smart default
- User can manually override codec via UI pills
- Changing codec mid-stream auto-restarts the stream (~1s interruption)
2026-03-23 23:35:33 +01:00
Jannis Braun e7a18bd28c fix: screenshare codec fallback, native FPS, and connection info
- Gaming mode uses H.264 primary (hardware NVENC encoding, zero CPU
  impact on games). Text mode uses VP9 primary with H.264 backup
  and SIMULCAST policy for Safari compatibility.
- Fix native mode starting at 30fps by decoupling frameRate constraint
  from resolution constraint in both screenShare.ts overdrive and
  useLiveKit.ts updateActiveTracks.
- Filter paused backup codec tracks in Connection Info stats so dead
  0kbps entries don't show alongside active codec tracks.
2026-03-23 22:10:16 +01:00
Jannis Braun 0b7206487e chore: delete dead useLongPress.ts hook
Zero imports — useGlobalLongPress in ContextMenuRenderer handles
all long-press detection at the document level.
2026-03-23 21:12:32 +01:00
Jannis Braun 241451ca18 feat: gesture-aware voice connection, remove AppLayout auto-connect
Replace the useEffect-based auto-connect pattern in AppLayout with
direct connect/disconnect calls from user gesture contexts. This is
required for iOS (AudioContext.resume + getUserMedia must happen in
a gesture handler) and aligns with tightening autoplay policies on
desktop browsers.

Architecture:
- voiceStore gains connectFn/disconnectFn refs, registered by AppLayout
  from the single useLiveKit() instance.
- All voice join paths (ChannelSidebar, MobileSpacesScreen, MainContent,
  voice_moved WS handler) pass connectFn to joinVoiceChannel().
- All disconnect paths (VoiceControls, voiceActions, MobileVoiceFullScreen,
  MobileVoiceMiniBar, dm_call_ended/rejected WS handlers, ready handler)
  call disconnectFn() directly.
- dm_call_accepted WS handler calls connectFn() to initiate the DM call
  LiveKit connection.
- The 55-line auto-connect useEffect and lastAttemptedRef are removed.
2026-03-23 21:06:19 +01:00
Jannis Braun 80c32ae1e6 feat: add long-press context menus for mobile messages
Create useLongPress hook with 500ms delay, 10px movement cancellation
(Euclidean distance), and ghost click suppression. Integrate into
Message component to trigger the existing context menu bottom sheet
on touch devices.
2026-03-23 14:22:38 +01:00
Jannis Braun a888828941 chore: gitignore installers and include pending web changes
Add installers/ to .gitignore and remove from tracking — binaries
should not be in git.

Also includes:
- Re-push Electron activities after WebSocket reconnect (sleep/wake)
- Merge activity map instead of replacing to preserve local state
- Nori mascot design plan
2026-03-23 00:25:31 +01:00
Jannis Braun 100ac15939 fix: deduplicate keybind actions when native hook and web fallback both fire
When the app is focused and the native uiohook hook is active, both the
IPC bridge and the web fallback capture the same keypress, dispatching
the toggle action twice (~0-20ms apart). This double-toggles mute/deafen
back to the original state.

Add a 100ms dedup window in dispatchKeybindAction to suppress the second
dispatch. Safe for human input (key repeat starts at ~500ms).
2026-03-22 23:55:59 +01:00
Jannis Braun 43eaceadc0 fix: improve animation hook robustness
- Add .catch handler to scheduleNext to prevent unhandled promise
  rejections from killing the scheduling loop
- Replace `as unknown as Animation` cast with Cancellable interface
  for type-safe cleanup handles
2026-03-22 23:31:50 +01:00
Jannis Braun 977983eed1 feat: add useMascotAnimation hook with WAAPI state machine 2026-03-22 23:26:53 +01:00
Jannis Braun 7d5b9c1eee feat: add Mascot component with state-driven SVG rendering
Implement the <Mascot> component that renders Nori in four emotional
states (idle, sleeping, excited, lonely) with distinct SVG geometry,
color palettes, and facial expressions. Includes useMascotAnimation
hook stub for Task 2 and full test coverage (15 tests).
2026-03-22 23:15:46 +01:00
Jannis Braun 623928812d fix: always enable web fallback keybind listeners alongside Electron IPC
When the Electron main process can't start uiohook (macOS Accessibility
denied, Linux missing input group), the IPC path silently produces no
events. Previously this left zero keybind listeners active. Now the web
capture-phase fallback is always set up as a safety net, ensuring
keybinds work in-app even when the OS-level hook fails.
2026-03-22 22:31:59 +01:00
Jannis Braun 39ee894d77 fix: fall back to web keybind listeners when Electron lacks keybind APIs
When running in Electron but the desktop app hasn't been rebuilt with
keybind support (no syncKeybinds/onKeybindAction in preload), the hook
now falls through to the web capture-phase listener instead of silently
doing nothing. This ensures keybinds work immediately on the web-served
UI regardless of the desktop app version.
2026-03-22 21:36:05 +01:00
Jannis Braun 5554b1abc7 fix: replace broken 2-char hash with djb2 for keybind code mapping
The previous hash (charCodeAt(0)*256 + charCodeAt(1)) only used the
first 2 characters of KeyboardEvent.code strings, causing all Key*
codes (KeyA, KeyB, ..., KeyZ) to collide to the same numeric value.
This made Ctrl+D and Ctrl+M appear as identical bindings (false
conflict) and broke keybind matching in the web fallback.

djb2 hashes the full string, producing unique values for all codes.
2026-03-22 21:01:02 +01:00
Jannis Braun c6d345048a feat(keybinds): add useKeybinds hook with Electron IPC bridge and web fallback 2026-03-22 20:35:31 +01:00
Jannis Braun 437873c823 refactor: Instance settings back to separate pages with sidebar sub-links
The continuous scroll was wrong for Instance — its sub-panels are
fundamentally different page types (user table, storage tool, bitrate
matrix, toggles). Revert to conditional rendering.

The hook now supports two modes:
- Scroll mode (default): smooth scroll + IntersectionObserver spy
- Tab mode (onNavigate callback): sidebar sub-links switch tabs

Instance uses tab mode. The scroll-spy infrastructure remains for
future panels that benefit from it (e.g. Account sections).

Also reverts GeneralPanel to sticky save bar since it's on its own
page again with no overlap risk.
2026-03-22 03:52:26 +01:00
Jannis Braun fbf83cf8f4 fix: prevent infinite re-render loop in settings sections
- Memoize context value in SettingsSectionsProvider
- Use ctxRef pattern in hook to avoid depending on ctx object
  in useLayoutEffect deps (ctx object changes on every render,
  causing effect → setState → re-render → effect loop)
2026-03-22 03:32:43 +01:00