OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.
The callback arrives as a plain browser redirect with no Authorization header,
so the OAuth state carries the user id signed with the instance secret and is
compared in constant time; without that, anyone could bind their Spotify
account to another user.
Activities are now tracked per producer. pushActivities replaced the whole
list, so the desktop game detector and Spotify would erase each other — losing
exactly the case this is for, a game and Spotify at once.
Polling backs off when the tab is hidden and keeps the last known track on a
network error rather than reporting 'stopped listening'. A rejected refresh
token (access revoked on Spotify's side) drops the row so the UI stops
claiming a live connection.
Scope is read-only: user-read-currently-playing and user-read-playback-state.
Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp).
Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card.
The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height.
Closes#37
Two compounding root causes produced a "PiP-style grey view" briefly visible after Join Voice on mobile:
1. MobileSpacesScreen.handleVoiceJoin pushed 'voice' onto the mobile stack. MobileShell.screenMap only has 'voice-full' — the renderer returned null for 'voice' while the root screen sat under `visibility: hidden` (stack length > 0). Corrected to 'voice-full' with an explanatory comment.
2. AppLayout mounted the desktop <PictureInPicture /> in the mobile branch too. Its visibility check `(isInServerVoice || isInDmCall) && !voiceFullscreen` is true the moment currentVoiceChannelId is set, so the desktop PiP rendered as the only visible UI on top of the hidden mobile shell. Removed from the mobile branch; kept on desktop. Added a comment to prevent regression.
Mobile owns its voice overlay chrome exclusively via MobileVoiceMiniBar + MobileVoiceFullScreen.
Spec: docs/systems/mobile-ui.md gains a "Voice Join Flow (Mobile)" subsection and a leading paragraph in "Voice Overlay" stating that PiP is desktop-only.
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.
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.
- Add role="status" and aria-label to all skeleton containers
- Refactor ChannelSidebar from conditional siblings to ternary
for consistent mutual exclusivity with MemberSidebar pattern
Add cross-platform process scanning that detects running games and apps,
then broadcasts them as Rich Presence activities via the existing Phase 1
pipeline (activityStore -> WS -> other users).
- games.json dictionary with 26 entries (games, Spotify, VLC, OBS)
- activityDetector module: polls OS process list every 15s via execFile,
matches against dictionary, emits IPC on state change only
- IPC bridge in main.ts/preload.ts with cleanup on quit
- Frontend activityBridge subscribes to IPC and feeds activityStore
- Supports Windows (tasklist CSV), macOS (ps -c), Linux (ps)
- Custom screen share picker for Electron (ScreenSharePicker.tsx) with
Aether Drift design: glass-bubble footer, adaptive grid, pill tabs,
border-based selection (avoids overflow clipping), hover brightness
- Instance URL picker so Electron connects to any Backspace server
- System audio loopback on Windows/Linux via desktopCapturer callback
- macOS: video-only callback (OS blocks system audio capture)
- IPC bridge for screen source enumeration and selection
- Purge stale service worker caches on Electron launch
- Dev/prod URL auto-detection (Vite 5173 in dev, server 3000 in prod)
- Typed IPC bridge via preload (notifications, badge, window controls, updates, deep links)
- Native OS notifications via NotificationController with window focus suppression
- Auto-update via electron-updater with UpdateToast UI
- Deep linking (backspace:// protocol) for macOS and Windows/Linux
- Window state persistence (position, size, maximize across restarts)
- Tray icon with graceful fallback when icon asset missing
- Suppress PWA service worker polling/reloads inside Electron
- Platform detection layer (isElectron, getElectronAPI)
- Root workspace scripts (dev:desktop, build:desktop)
- Document BACKSPACE_URL and BACKSPACE_UPDATE_URL env vars
Make /join/:code a public route with a standalone JoinPage that shows a
space preview and handles authenticated, unauthenticated, and cross-instance
users. Adds GET /api/spaces/invite/:code/preview (no auth) endpoint,
?redirect= param support on login/register, and cleans up dead invite
handling from AppLayout and JoinSpace modal.
Add stale-channel guard in AppLayout that redirects to the space root
when the URL's channelId no longer exists in the channel list. Remove
the hardcoded navigate('/channels/@me') from ChannelSettingsModal and
the redundant setCurrentChannel fallback from the WS channel_deleted
handler — AppLayout's guard now handles all cases uniformly.
Add CreateCategory modal (replaces browser prompt) and right-click
"Delete Category" context menu on category headers with confirmation
dialog explaining channels will be uncategorized, not deleted.
Add banner image, accent color, and bio fields to user profiles with
full-stack support: schema migration, API validation (hex color format,
190-char bio limit), sanitizeUser propagation, and new GET /users/:id/mutuals
endpoint. Rewrite AccountPanel with live preview card, avatar/banner upload
via ImageCropModal, 16-preset accent color picker, and bio editor. Enhance
UserProfilePopout with banner display, accent-colored names, bio rendering,
and mutual counts. Add new UserProfileModal with About/Mutual Friends/Mutual
Spaces tabs and friend action buttons.
Split monolithic UserSettings into AccountPanel, VoicePanel, ConnectionsPanel,
and InstancePanel tabs. Remove standalone InstanceSettings modal. Add reusable
Toggle component fixing size deformation, color inconsistency (green→purple),
and flex-shrink issues. Fix custom status clearing by always sending the field
to the server. Wrap Log Out button in glass bubble. Add subtle card depth with
borders.
Move instance-level administration out of Space Settings into its own
modal. Adds admin UI for instance name, registration toggle, and
discovery toggle. Streaming limits panel relocated from SpaceSettings.
- Add InstanceAdminSettings type and GET/PATCH /api/settings/instance
- Add registration_open DB column (nullable, env var fallback)
- Auth registration and instance info now check DB override
- New InstanceSettings modal with General and Streaming tabs
- Admin shield button in UserAreaPanel (visible to admins only)
- Remove Streaming tab from SpaceSettings
The redirect effect used the shared `channels` array which could contain
stale data from a previously viewed server during async loadServerDetail.
Added channelToServerMap ownership check to ensure channels belong to
the target server before redirecting. Also persists last-visited channel
per server in localStorage for better return navigation.
The Explore page shared /channels/@me with Friends, differentiated by a
showExplore UI flag. When PiP navigated to a voice channel, the stale
flag caused MainContent to render text chat instead of the voice grid.
Replace the flag with a dedicated /explore route so the URL is the
single source of truth.
Clear stale currentChannelId when clicking Explore/DMs in ServerSidebar,
and guard AppLayout route effect from clobbering showExplore. Move member
exclusion into SQL for correct pagination/totals, surface allSettled errors,
and show context-aware empty state messages.
Add identity.ts with isSelf() and resolveDisplayIdentity() — pure
stateless functions that detect replicated-self using the immutable
(username, homeInstance) composite key. No store lookups, no data
mutation. Fixes wrong avatar gradient and missing edit/delete on
own messages in remote channels.
Also includes: optimistic message dedup fix for cross-instance
messages (content-only matching), federation toast notifications,
Username component with @domain display, invite parser, and
deploy script simplification.
- Add Backspace-design-prototype.html: finalized "Aether Drift" design
language with warm matte surfaces and subtle frosted glass accents
- Update CLAUDE.md with DESIGN SYSTEM section and remove Discord clone references
- Rename all Opencord references to Backspace across the full codebase
- Archive outdated design experiments and Discord-specific assets
- Add science-backed accessibility fallback (prefers-reduced-transparency)
Wire the bitwise permission engine end-to-end:
- Add sendToChannel() to ConnectionManager, filtering WS recipients by VIEW_CHANNEL
- Convert 6 channel-scoped events (messages, typing, reactions) from sendToServer to sendToChannel
- Add broadcastOverrideChange() to push channel_updated/channel_deleted per-user on override mutations
- Bridge legacy server_members.role TEXT to member_roles junction table on PATCH
- Add pushReadyPayload() to force re-sync frontend store after role changes
- Filter channels by VIEW_CHANNEL in GET /api/servers/:id to prevent initial load data leak
- Pre-compute viewers before CASCADE delete on channel_deleted
- Fix frontend channel event handlers to upsert/cleanup channelToServerMap and channelPermissions
- Add ChannelSettingsModal with Private Channel toggle and gear icon in ChannelSidebar
Unlock group DMs by removing the 2-member assumption across the stack.
No schema migration needed — dm_members junction table already supports
N members. Includes dedup bug fix, add/leave member endpoints, late-join
call support, group-aware sidebar rendering, and AddDmMemberModal.
Merge connectDm() into connect() with isDm flag, eliminating ~145 lines
of duplicated LiveKit room setup. DM calls now inherit all event handlers
(SpeakingDetector cleanup, deafen broadcasts, metadata changes). Replace
monolithic DmCallView with shared VoiceGrid + VoiceControlBar components,
making DM calls group-DM-ready with full feature parity.
Eliminate double-state architecture (useState → useEffect bridge → store)
that lost speaking events due to React 18 batching. ActiveSpeakersChanged
now writes speakingParticipantIds directly to voiceStore; 200ms poll
catches missed SDK events. Each VoiceUser subscribes to its own identity
via fine-grained selector for minimal re-renders. Also adds connection
quality indicator and ConnectionInfoPopover.
Inject @sapphi-red/web-noise-suppressor into AudioManager's input pipeline
as a toggleable AudioWorkletNode. The worklet is loaded lazily on first
enable, then kept alive — toggling bypasses by rewiring the graph without
destroying the WASM instance. Browser NS is forced off when RNNoise is
active to avoid double-processing. InputGain forced to stereo up-mix to
prevent mono-left-only output from the worklet. Also wires Phase 2 output
device routing (setSinkId) into AppLayout/ChannelSidebar.
- Add WS events: dm_channel_created, dm_channel_closed, friend_removed,
channel_created/updated/deleted, server_updated
- Fix first-ever DM: broadcast dm_channel_created to recipient
- Add DELETE /api/dm/:id for closing DMs with re-open support
- Wire Close DM button in sidebar
- Fix dm_message_created for unknown channels (safety net)
- Broadcast friend_removed on friend deletion
- Sound system: track realtimeMessageEvents separately from API loads,
play notification for messages in all channels, not just current
- Optimistic updates: send/edit/delete messages appear instantly with
rollback on failure, temp message deduplication on WS echo
- Channel CRUD broadcasts to all server members
- Server update broadcast on PATCH
- Server join registers user in connectionManager immediately
- Reconnect: only reload current channel, preserve other channel caches
- Activity panel, right panel, member list toggle components
Add MutationObserver to neutralize LiveKit's re-attached <audio> elements,
mark our keep-alive elements with data-opencord, detach tracks on unsubscribe,
remove dangerous blanket .play(), and soften compressor to transparent limiter.
Streams no longer auto-focus into large view when they start — tiles
stay in the equal-size grid until manually clicked. Audio playback is
moved out of VoiceUser/StreamTile into a new GlobalAudioRenderer
component rendered in AppLayout so it survives channel navigation.
- New PictureInPicture component: draggable floating window that shows
active stream when navigating away from voice channel, with LIVE badge,
snap-to-edge, click-to-navigate-back
- Fix VoiceUser to check actual track presence instead of boolean flags
for video rendering
- Add LocalTrackPublished/LocalTrackUnpublished events to useLiveKit
so local screen share and camera show immediately in VoiceGrid
- Add pipCollapsed state to uiStore
- Implemented 'Overdrive' logic to force high bitrates on Chrome
- Fixed 'Auto' preset to default to stable 720p60
- Added persistent 'Triple-Kick' hammer to prevent bitrate throttling
- Fixed sidebar connection status sync
- Added comprehensive diagnostic logger
- Guard Disconnected/ConnectionStateChanged event handlers against stale rooms:
old room events no longer nuke the new room's state (root cause of buttons
failing, mute getting stuck, DUPLICATE_IDENTITY cascades)
- Reset media state (isMuted/isCameraOn/isScreenSharing) on connect to prevent
desync after reconnects
- Add voiceStates to WS ready payload so users see who's in voice on page load
- Wire VoiceControls buttons to check getActiveRoom() before SDK calls
- Guard ChannelSidebar against re-joining the same voice channel
- Switch LIVEKIT_URL to wss://nova.ddns.net/livekit for HTTPS secure context
(required for getUserMedia in Safari)