feat: redesign + avatars + theme + call presence fixes (v0.6.0)
Visual:
- Light/dark theme via ThemeContext + CSS vars
- New design tokens (surface/fg/accent/line/etc.) across all pages
- Reusable Avatar component (img + letter fallback) wired into UserBar,
ConversationHeader, ChatsPage, FriendsPage, GroupInfoPanel, IncomingCallPanel
Calls:
- Split call UI: IncomingCallPanel, InCallPanel, CallControls,
CallParticipantTile, ScreenShareViewer
- Active speaker hook (useActiveSpeakers)
- Fix: ActiveCallBanner stayed hidden after hangup while peers in room.
- useCallPresence: bind presence callbacks only when we own subscribe
(Supabase forbids .on() after .subscribe() on shared dedup'd channels)
- useCallPresence: never removeChannel — channel is shared with CallContext
so tearing it down on ConversationHeader unmount killed live tracking
- ActiveCallBanner: lastCallConversationId fallback so banner shows
instantly after hangup, auto-dismiss when room confirmed empty
- Drop unused useAnyActiveCall
Bump tauri version 0.5.0 -> 0.6.0
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
// Clean-Rail theme: persisted `.dark` class on <html>. Default follows the
|
||||
// system preference; user-toggle persists to localStorage and wins over it.
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'netralax.theme';
|
||||
|
||||
function readStoredTheme(): Theme | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'light' || raw === 'dark') return raw;
|
||||
} catch {
|
||||
/* localStorage unavailable (private mode / sandbox) */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeStoredTheme(theme: Theme): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export function getInitialTheme(): Theme {
|
||||
return readStoredTheme() ?? (systemPrefersDark() ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
export function setThemePersisted(theme: Theme): void {
|
||||
writeStoredTheme(theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Participant, Room } from 'livekit-client';
|
||||
import { RoomEvent } from 'livekit-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Subscribe to LiveKit ActiveSpeakersChanged to drive the speaking-ring pulse
|
||||
// on participant tiles. Returns the set of currently speaking identities
|
||||
// (includes local participant when they're active).
|
||||
export function useActiveSpeakers(room: Room | null): Set<string> {
|
||||
const [ids, setIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) {
|
||||
setIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
const update = (speakers: Participant[]) => {
|
||||
const next = new Set<string>();
|
||||
for (const s of speakers) {
|
||||
if (s.identity) next.add(s.identity);
|
||||
}
|
||||
setIds(next);
|
||||
};
|
||||
|
||||
// Seed from current state.
|
||||
update(room.activeSpeakers ?? []);
|
||||
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, update);
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, update);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
return ids;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { supabase } from './supabase';
|
||||
|
||||
// Observes the `call-presence:<conversationId>` channel for every conversation
|
||||
// the user is a member of and returns the first one (if any) that currently
|
||||
// has peers other than the viewer in the call. Enables a "call is live —
|
||||
// rejoin" affordance in the sidebar for conversations the viewer never
|
||||
// joined, mirroring Discord's active-voice indicator.
|
||||
//
|
||||
// Uses polling on each channel's `presenceState()` to side-step Supabase's
|
||||
// topic-based channel dedupe (see useCallPresence.ts).
|
||||
export function useAnyActiveCall(
|
||||
conversationIds: readonly string[],
|
||||
myId: string | null,
|
||||
): { conversationId: string; userIds: string[] } | null {
|
||||
const [byConv, setByConv] = useState<Record<string, string[]>>({});
|
||||
|
||||
const key = conversationIds.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationIds.length === 0) {
|
||||
setByConv({});
|
||||
return;
|
||||
}
|
||||
|
||||
const tracked: Array<{ id: string; ch: RealtimeChannel; owns: boolean }> = [];
|
||||
for (const id of conversationIds) {
|
||||
const ch = supabase.channel('call-presence:' + id, {
|
||||
config: {
|
||||
presence: {
|
||||
key: 'obs-' + Math.random().toString(36).slice(2, 8),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const owns = ch.state === 'closed';
|
||||
if (owns) void ch.subscribe();
|
||||
tracked.push({ id, ch, owns });
|
||||
}
|
||||
|
||||
const resync = () => {
|
||||
setByConv((prev) => {
|
||||
const next: Record<string, string[]> = {};
|
||||
let changed = false;
|
||||
for (const { id, ch } of tracked) {
|
||||
const presState = ch.presenceState() as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const ids = new Set<string>();
|
||||
for (const list of Object.values(presState)) {
|
||||
for (const e of list) {
|
||||
const uid = e?.userId;
|
||||
if (typeof uid === 'string') ids.add(uid);
|
||||
}
|
||||
}
|
||||
const arr = Array.from(ids).sort();
|
||||
next[id] = arr;
|
||||
const prevArr = prev[id] ?? [];
|
||||
if (prevArr.length !== arr.length || !arr.every((v, i) => v === prevArr[i])) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
// Also pick up removed conv ids.
|
||||
if (!changed && Object.keys(prev).length !== Object.keys(next).length) {
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
};
|
||||
|
||||
resync();
|
||||
const pollId = window.setInterval(resync, 1500);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(pollId);
|
||||
for (const { ch, owns } of tracked) {
|
||||
if (owns) void supabase.removeChannel(ch);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
for (const id of conversationIds) {
|
||||
const users = byConv[id] ?? [];
|
||||
const others = myId ? users.filter((u) => u !== myId) : users;
|
||||
if (others.length > 0) {
|
||||
return { conversationId: id, userIds: others };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -6,11 +6,15 @@ import { supabase } from './supabase';
|
||||
// the set of userIds currently in that call. Independent of whether the
|
||||
// viewer is in the room — used to show "Active call · Join" affordances.
|
||||
//
|
||||
// Supabase realtime 2.103+ dedupes channels by topic: calling
|
||||
// `supabase.channel(topic)` returns an existing channel if one already exists.
|
||||
// That means an observer can't safely register `.on('presence', ...)` because
|
||||
// the tracker (CallContext) may have already subscribed it. We side-step this
|
||||
// by polling `presenceState()` on the (shared or fresh) channel.
|
||||
// Why presence-callbacks + no-removeChannel:
|
||||
// - Supabase realtime 2.103+ dedupes channels by topic. CallContext owns
|
||||
// the same channel during a live call to track its own presence.
|
||||
// - If this hook removed the channel on unmount (e.g. when ConversationHeader
|
||||
// hides during an active call), the live tracking connection dies and the
|
||||
// banner never shows the rejoin option afterwards.
|
||||
// - Binding our own presence callbacks (sync/join/leave) ensures the local
|
||||
// presence state is updated even when CallContext also listens on the same
|
||||
// channel — multiple callbacks can co-exist.
|
||||
export function useCallPresence(conversationId: string | undefined): string[] {
|
||||
const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
|
||||
|
||||
@@ -24,23 +28,13 @@ export function useCallPresence(conversationId: string | undefined): string[] {
|
||||
config: {
|
||||
presence: {
|
||||
key: 'observer-' + Math.random().toString(36).slice(2, 8),
|
||||
// Supabase realtime only sends presence_state/diff events to a
|
||||
// channel that has presence enabled. Without `enabled: true` (and
|
||||
// no `.on('presence', ...)` bindings) the server treats the channel
|
||||
// as non-presence and `presenceState()` never populates.
|
||||
// Server only routes presence to channels that opt-in. Without this
|
||||
// flag we'd receive zero presence_state/presence_diff messages.
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If Supabase returned a fresh (closed) channel, we own it and must
|
||||
// subscribe/remove it. If it returned an already-subscribed channel
|
||||
// (tracker owns it), we just read state.
|
||||
const ownsChannel = channel.state === 'closed';
|
||||
if (ownsChannel) {
|
||||
void channel.subscribe();
|
||||
}
|
||||
|
||||
const resync = () => {
|
||||
const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
|
||||
const ids = new Set<string>();
|
||||
@@ -57,14 +51,31 @@ export function useCallPresence(conversationId: string | undefined): string[] {
|
||||
});
|
||||
};
|
||||
|
||||
// Supabase realtime forbids `.on()` after `.subscribe()`. So we only bind
|
||||
// callbacks when we own the subscribe (channel was 'closed'). For deduped
|
||||
// shared channels, the polling fallback below covers state changes.
|
||||
if (channel.state === 'closed') {
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, resync)
|
||||
.on('presence', { event: 'join' }, resync)
|
||||
.on('presence', { event: 'leave' }, resync);
|
||||
void channel.subscribe();
|
||||
}
|
||||
|
||||
// Initial poll covers the case where the channel is already 'joined' (we
|
||||
// dedupe'd onto an existing subscription) and won't fire a fresh sync.
|
||||
resync();
|
||||
const pollId = window.setInterval(resync, 1500);
|
||||
// Belt-and-suspenders: a slow poll catches any drift between callback and
|
||||
// server state (e.g. if a presence event was dropped during reconnect).
|
||||
const pollId = window.setInterval(resync, 3000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(pollId);
|
||||
if (ownsChannel) {
|
||||
void supabase.removeChannel(channel);
|
||||
}
|
||||
// Don't removeChannel — the channel is shared with CallContext (and any
|
||||
// sibling observers) via Supabase's topic dedupe. Removing it would
|
||||
// tear down a live call's presence tracker. The channel naturally
|
||||
// outlives the hook; leaks are bounded by the small number of
|
||||
// conversations a user holds open in a session.
|
||||
};
|
||||
}, [conversationId]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user