Bump
All checks were successful
Build and Release / build-and-release (push) Successful in 12m44s

This commit is contained in:
Bryan1029384756
2026-04-18 17:30:53 -05:00
parent 593eaba82e
commit 43cfbe65f3
22 changed files with 1244 additions and 36 deletions

View File

@@ -56,7 +56,11 @@
"Bash(sed -n '1,30p' packages/shared/src/components/channel/ChannelHeader.tsx)", "Bash(sed -n '1,30p' packages/shared/src/components/channel/ChannelHeader.tsx)",
"Bash(sed -n '1,30p' packages/shared/src/components/member/MemberListContainer.tsx)", "Bash(sed -n '1,30p' packages/shared/src/components/member/MemberListContainer.tsx)",
"Bash(timeout 20 npm run dev:web)", "Bash(timeout 20 npm run dev:web)",
"Bash(timeout 8 npm run dev:web)" "Bash(timeout 8 npm run dev:web)",
"Bash(grep -E '\"electron\":\\\\s*\"' package.json apps/electron/package.json)",
"Bash(grep -E '\"electron\":\\\\s*\"' package.json)",
"Bash(node --check apps/electron/main.cjs)",
"Bash(node --check apps/electron/preload.cjs)"
] ]
} }
} }

View File

@@ -131,6 +131,8 @@ All platform-specific APIs are accessed via the `usePlatform()` hook:
- Profile banner: `userProfiles.bannerStorageId` (optional), resolved to `bannerUrl` in `auth.getPublicKeys`. `auth.updateProfileInternal` takes `bannerStorageId` + `removeBanner` (the remove path also `ctx.storage.delete`s the blob). All four profile card surfaces (`MemberProfilePopout`, `MemberProfileModal`, `MobileMemberProfileSheet`, `UserAreaProfilePopout`) render the image when present, fall back to accent color when not - Profile banner: `userProfiles.bannerStorageId` (optional), resolved to `bannerUrl` in `auth.getPublicKeys`. `auth.updateProfileInternal` takes `bannerStorageId` + `removeBanner` (the remove path also `ctx.storage.delete`s the blob). All four profile card surfaces (`MemberProfilePopout`, `MemberProfileModal`, `MobileMemberProfileSheet`, `UserAreaProfilePopout`) render the image when present, fall back to accent color when not
- Voice messages: mic button in `ChannelTextarea` records via `MediaRecorder` (picks `audio/webm;codecs=opus` where supported), stages the resulting `File` through the existing attachment pipeline — no new backend. Receivers render it via the standard `AttachmentAudio` player. Filename convention: `voice-message-{timestamp}.{webm|ogg|m4a}`. Voice-recorded messages set `isVoiceMessage: true` + `peaks: number[]` + `durationSec` in the attachment metadata; `EncryptedAttachment` dispatches those to `VoiceMessagePlayer` (pill with play button + waveform) instead of the full audio card - Voice messages: mic button in `ChannelTextarea` records via `MediaRecorder` (picks `audio/webm;codecs=opus` where supported), stages the resulting `File` through the existing attachment pipeline — no new backend. Receivers render it via the standard `AttachmentAudio` player. Filename convention: `voice-message-{timestamp}.{webm|ogg|m4a}`. Voice-recorded messages set `isVoiceMessage: true` + `peaks: number[]` + `durationSec` in the attachment metadata; `EncryptedAttachment` dispatches those to `VoiceMessagePlayer` (pill with play button + waveform) instead of the full audio card
- Push-to-talk: `voiceSettings.inputMode` is `'voice-activity'` (default) or `'push-to-talk'`. Paired with the `voice.pushToTalk` keybind (marked `pressAndHold: true`). `KeybindContext` dispatches `brycord:keybind:voice.pushToTalk:down` / `:up` events — pressAndHold actions never `preventDefault`, so binding PTT to a letter still lets you type. `VoiceContext` reads the settings via the `brycord:voice-settings-changed` window event, listens for the PTT events, and routes them through a configurable release-delay timer before reconciling the LiveKit mic track. All mic-on/mic-off sources (user mute, deafen, server mute, PTT gate) converge on a single `setMicrophoneEnabled` effect - Push-to-talk: `voiceSettings.inputMode` is `'voice-activity'` (default) or `'push-to-talk'`. Paired with the `voice.pushToTalk` keybind (marked `pressAndHold: true`). `KeybindContext` dispatches `brycord:keybind:voice.pushToTalk:down` / `:up` events — pressAndHold actions never `preventDefault`, so binding PTT to a letter still lets you type. `VoiceContext` reads the settings via the `brycord:voice-settings-changed` window event, listens for the PTT events, and routes them through a configurable release-delay timer before reconciling the LiveKit mic track. All mic-on/mic-off sources (user mute, deafen, server mute, PTT gate) converge on a single `setMicrophoneEnabled` effect
- Plaintext cache: `SearchDatabase` has a `plaintext_cache` table (per-channel cap of 500) that persists decrypted message bodies encrypted at rest by the existing search DB key. `SearchContext` exposes `cachePlaintexts` / `getChannelPlaintexts`. `Messages.tsx` seeds the in-memory `decryptionCache` from it on cold channel open (source-scoped on the full ciphertext so edits naturally invalidate), and writes back after each successful decrypt batch. Cuts the "empty bubbles then fill in" wave on app restart
- Webcam video: camera publish already lived in `VoiceContext.setCamera` via LiveKit `setCameraEnabled`. Now also propagates `isCameraOn` through `voiceStates.updateState`. `CameraTile` attaches a participant's camera track to a `<video>` element (mirrored for the local preview, `muted` locally). `CameraGrid` (mounted in `VoiceCallView` above the audio-only tile grid) renders one tile per participant with `isCameraOn: true`, force-including the local user immediately when `voice.isCameraOn` flips so the preview doesn't lag behind the Convex round-trip
## Environment Variables ## Environment Variables

View File

@@ -36,6 +36,12 @@ const DEFAULT_SETTINGS = {
let mainWindow = null; let mainWindow = null;
// Screen-share source picked by the renderer right before LiveKit's
// setScreenShareEnabled(true) call triggers getDisplayMedia. The
// setDisplayMediaRequestHandler reads + clears this on each fire,
// then resolves the callback with the matching DesktopCapturerSource.
let pendingScreenSourceSelection = null;
// ─────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────
// Voice recording — per-participant audio capture // Voice recording — per-participant audio capture
// ─────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────
@@ -187,6 +193,34 @@ function createWindow() {
}); });
} }
// Electron 20+ refuses navigator.mediaDevices.getDisplayMedia()
// unless a handler is registered here. LiveKit's
// setScreenShareEnabled(true) hits getDisplayMedia internally,
// so without this the renderer sees "NotSupportedError: Not
// supported". The renderer pre-stashes its picked source via the
// `screen-share:set-pending-source` IPC, then triggers the LiveKit
// call — we read + clear the pending selection here.
mainWindow.webContents.session.setDisplayMediaRequestHandler(async (_request, callback) => {
const pending = pendingScreenSourceSelection;
pendingScreenSourceSelection = null;
if (!pending) {
callback({});
return;
}
try {
const { desktopCapturer } = require('electron');
const sources = await desktopCapturer.getSources({ types: ['window', 'screen'] });
const source = sources.find(s => s.id === pending.sourceId);
if (!source) { callback({}); return; }
callback({
video: source,
audio: pending.includeAudio ? 'loopback' : undefined,
});
} catch {
callback({});
}
}, { useSystemPicker: false });
if (settings.isMaximized) { if (settings.isMaximized) {
mainWindow.maximize(); mainWindow.maximize();
} }
@@ -634,6 +668,13 @@ app.whenReady().then(async () => {
})); }));
}); });
ipcMain.handle('screen-share:set-pending-source', (_event, payload) => {
pendingScreenSourceSelection = payload && typeof payload.sourceId === 'string'
? { sourceId: payload.sourceId, includeAudio: !!payload.includeAudio }
: null;
return true;
});
// Crypto Handlers // Crypto Handlers
const crypto = require('crypto'); const crypto = require('crypto');

View File

@@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld('cryptoAPI', {
fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url), fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url),
openExternal: (url) => ipcRenderer.invoke('open-external', url), openExternal: (url) => ipcRenderer.invoke('open-external', url),
getScreenSources: () => ipcRenderer.invoke('get-screen-sources'), getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
setPendingScreenSource: (payload) => ipcRenderer.invoke('screen-share:set-pending-source', payload),
}); });
contextBridge.exposeInMainWorld('windowControls', { contextBridge.exposeInMainWorld('windowControls', {

View File

@@ -47,6 +47,7 @@ const electronPlatform = {
}, },
screenCapture: { screenCapture: {
getScreenSources: () => window.cryptoAPI.getScreenSources(), getScreenSources: () => window.cryptoAPI.getScreenSources(),
setPendingSource: (payload) => window.cryptoAPI.setPendingScreenSource(payload),
}, },
windowControls: { windowControls: {
minimize: () => window.windowControls.minimize(), minimize: () => window.windowControls.minimize(),

View File

@@ -119,6 +119,10 @@ export default defineSchema({
isDeafened: v.boolean(), isDeafened: v.boolean(),
isScreenSharing: v.boolean(), isScreenSharing: v.boolean(),
isServerMuted: v.boolean(), isServerMuted: v.boolean(),
// Webcam publish state — optional so existing rows still
// validate. Undefined means "unknown / not reported yet",
// which the UI treats the same as `false`.
isCameraOn: v.optional(v.boolean()),
watchingStream: v.optional(v.id("userProfiles")), watchingStream: v.optional(v.id("userProfiles")),
lastHeartbeat: v.optional(v.number()), lastHeartbeat: v.optional(v.number()),
}) })

View File

@@ -61,6 +61,7 @@ export const updateState = mutation({
isMuted: v.optional(v.boolean()), isMuted: v.optional(v.boolean()),
isDeafened: v.optional(v.boolean()), isDeafened: v.optional(v.boolean()),
isScreenSharing: v.optional(v.boolean()), isScreenSharing: v.optional(v.boolean()),
isCameraOn: v.optional(v.boolean()),
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -155,6 +156,7 @@ export const getAll = query({
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
isCameraOn: boolean;
isServerMuted: boolean; isServerMuted: boolean;
avatarUrl: string | null; avatarUrl: string | null;
joinSoundUrl: string | null; joinSoundUrl: string | null;
@@ -186,6 +188,7 @@ export const getAll = query({
isMuted: s.isMuted, isMuted: s.isMuted,
isDeafened: s.isDeafened, isDeafened: s.isDeafened,
isScreenSharing: s.isScreenSharing, isScreenSharing: s.isScreenSharing,
isCameraOn: s.isCameraOn ?? false,
isServerMuted: s.isServerMuted, isServerMuted: s.isServerMuted,
avatarUrl, avatarUrl,
joinSoundUrl, joinSoundUrl,
@@ -357,7 +360,7 @@ export const moveUser = mutation({
// No-op if already in the target channel // No-op if already in the target channel
if (currentState.channelId === args.targetChannelId) return null; if (currentState.channelId === args.targetChannelId) return null;
// Delete old voice state and insert new one preserving mute/deaf/screenshare // Delete old voice state and insert new one preserving mute/deaf/screenshare/camera
await ctx.db.delete(currentState._id); await ctx.db.delete(currentState._id);
await ctx.db.insert("voiceStates", { await ctx.db.insert("voiceStates", {
channelId: args.targetChannelId, channelId: args.targetChannelId,
@@ -366,6 +369,7 @@ export const moveUser = mutation({
isMuted: currentState.isMuted, isMuted: currentState.isMuted,
isDeafened: currentState.isDeafened, isDeafened: currentState.isDeafened,
isScreenSharing: currentState.isScreenSharing, isScreenSharing: currentState.isScreenSharing,
isCameraOn: currentState.isCameraOn ?? false,
isServerMuted: currentState.isServerMuted, isServerMuted: currentState.isServerMuted,
lastHeartbeat: Date.now(), lastHeartbeat: Date.now(),
}); });

View File

@@ -2,6 +2,7 @@ import { useAction, useMutation, usePaginatedQuery, useQuery } from 'convex/reac
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../../../../convex/_generated/api'; import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { useSearch } from '../../contexts/SearchContext';
import { MessageGroup } from './MessageGroup'; import { MessageGroup } from './MessageGroup';
import { PollCard } from './PollCard'; import { PollCard } from './PollCard';
import { ChannelWelcomeSection } from './ChannelWelcomeSection'; import { ChannelWelcomeSection } from './ChannelWelcomeSection';
@@ -338,6 +339,26 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map()); const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map());
// Persistent plaintext cache (sql.js + safeStorage / Web Crypto).
// On cold channel open, seed the in-memory `decryptionCache` from
// SQL so the `useLayoutEffect` hydration step below hits before
// the real decrypt runs. Writes happen after each successful
// decrypt batch further down.
const searchCtx = useSearch();
const searchReady = searchCtx?.isReady ?? false;
useEffect(() => {
if (!searchReady || !channelId) return;
const rows = searchCtx?.getChannelPlaintexts?.(channelId, 200) ?? [];
if (rows.length === 0) return;
for (const r of rows) {
// Populate the in-memory LRU — the existing source-scoped
// cacheGet will hit on the next render if the live Convex
// ciphertext still matches what we cached. Edits (different
// ciphertext) naturally miss and fall through to decrypt.
cacheSet(userId, r.id, r.plaintext, r.ciphertextSrc);
}
}, [searchReady, channelId, userId, searchCtx]);
// Fingerprint of each decrypted entry's source ciphertext. // Fingerprint of each decrypted entry's source ciphertext.
// `messages.edit` changes the ciphertext while keeping the id, // `messages.edit` changes the ciphertext while keeping the id,
// so the decrypt effects below gate on "has id AND source // so the decrypt effects below gate on "has id AND source
@@ -542,9 +563,40 @@ export function Messages({ channelId, onReply }: MessagesProps) {
}), }),
); );
if (cancelled) return; if (cancelled) return;
const persistBatch: Array<{
id: string;
channelId: string;
ciphertextSrc: string;
plaintext: string;
createdAt: number;
}> = [];
for (const r of results) { for (const r of results) {
if (r.cache && r.source) cacheSet(userId, r.id, r.value, r.source); if (r.cache && r.source) cacheSet(userId, r.id, r.value, r.source);
if (r.source) plaintextSourceRef.current.set(r.id, r.source); if (r.source) plaintextSourceRef.current.set(r.id, r.source);
if (r.cache && r.source && channelId) {
// Store the full ciphertext so the in-memory cache's
// `source`-scoped lookup hits on cold open. Edits
// change the ciphertext → different key → natural miss.
const msg = (pagedMessages as any[] | null | undefined)?.find(
(m) => m.id === r.id,
);
const createdAt =
typeof msg?.created_at === 'number'
? msg.created_at
: typeof msg?.createdAt === 'number'
? msg.createdAt
: Date.now();
persistBatch.push({
id: r.id,
channelId,
ciphertextSrc: r.source,
plaintext: r.value,
createdAt,
});
}
}
if (persistBatch.length > 0) {
searchCtx?.cachePlaintexts?.(persistBatch);
} }
setDecryptedMap((prev) => { setDecryptedMap((prev) => {
const next = new Map(prev); const next = new Map(prev);

View File

@@ -137,6 +137,7 @@ export function VoiceMessagePlayer({ src, peaks, durationSec }: VoiceMessagePlay
gap: 10, gap: 10,
padding: '6px 12px 6px 6px', padding: '6px 12px 6px 6px',
background: 'var(--background-secondary)', background: 'var(--background-secondary)',
border: '1px solid var(--background-modifier-accent)',
borderRadius: 999, borderRadius: 999,
maxWidth: 340, maxWidth: 340,
width: '100%', width: '100%',

View File

@@ -13,6 +13,7 @@ import { CreateCategoryModal } from './CategorySettingsModal';
import { InviteModal } from '../modals/InviteModal'; import { InviteModal } from '../modals/InviteModal';
import { CreateServerModal } from '../modals/CreateServerModal'; import { CreateServerModal } from '../modals/CreateServerModal';
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal'; import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
import { ScreenShareFlow } from '../voice/ScreenShareFlow';
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal'; import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
import { MobileChannelSettingsPage } from '../channel/MobileChannelSettingsPage'; import { MobileChannelSettingsPage } from '../channel/MobileChannelSettingsPage';
import { MobileCreateChannelPage } from './MobileCreateChannelPage'; import { MobileCreateChannelPage } from './MobileCreateChannelPage';
@@ -273,6 +274,7 @@ export function AppLayout() {
/> />
)} )}
<RecordingRecoveryModal /> <RecordingRecoveryModal />
<ScreenShareFlow />
<UpdateBanner /> <UpdateBanner />
<NotificationManager myUserId={myUserId} /> <NotificationManager myUserId={myUserId} />
</KeybindProvider> </KeybindProvider>

View File

@@ -0,0 +1,83 @@
import { useMemo } from 'react';
import { useVoice } from '../../contexts/VoiceContext';
import { CameraTile } from './CameraTile';
interface CameraGridProps {
channelId: string;
}
/**
* Grid of active webcam tiles for a voice channel. Pulls from the
* reactive `voiceStates` map — any participant with `isCameraOn: true`
* gets a tile. The local user's own preview is included when their
* LiveKit `isCameraEnabled` is on, independent of the server state,
* so the tile appears instantly on toggle without waiting for the
* Convex round-trip.
*/
export function CameraGrid({ channelId }: CameraGridProps) {
const voice = useVoice() as any;
const participants: any[] = useMemo(
() => (voice?.voiceStates && voice.voiceStates[channelId]) || [],
[voice?.voiceStates, channelId],
);
const localUserId: string | null =
typeof localStorage !== 'undefined'
? localStorage.getItem('userId')
: null;
const tiles = useMemo(() => {
const byId = new Map<
string,
{ userId: string; label: string; isLocal: boolean }
>();
for (const p of participants) {
if (!p?.isCameraOn) continue;
const isLocal = p.userId === localUserId;
byId.set(p.userId, {
userId: p.userId,
label: p.displayName || p.username || 'User',
isLocal,
});
}
// The local camera-on flag flips client-side immediately; the
// voiceStates round-trip can lag by a frame or two. Force the
// tile in for the local user so the preview appears right away.
if (voice?.isCameraOn && localUserId && !byId.has(localUserId)) {
const me = participants.find((p) => p.userId === localUserId);
byId.set(localUserId, {
userId: localUserId,
label: me?.displayName || me?.username || 'You',
isLocal: true,
});
}
return Array.from(byId.values());
}, [participants, localUserId, voice?.isCameraOn]);
if (tiles.length === 0) return null;
return (
<div
style={{
display: 'grid',
gridTemplateColumns:
tiles.length === 1
? '1fr'
: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: 12,
padding: '0 16px 16px',
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
}}
>
{tiles.map((t) => (
<CameraTile
key={t.userId}
userId={t.userId}
label={t.label}
isLocal={t.isLocal}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,182 @@
import { useEffect, useRef, useState } from 'react';
import { useVoice } from '../../contexts/VoiceContext';
interface CameraTileProps {
/** `identity` in LiveKit is the user's `userProfiles._id`. For the
* local user, we match the room's `localParticipant.identity` so
* the same tile component can render self-preview too. */
userId: string;
label: string;
isLocal?: boolean;
}
// LiveKit's Track.Source.Camera enum value — kept as a raw comparison
// so we don't need to import livekit-client here. Matches the pattern
// ScreenSharePreview already uses for Track.Source.ScreenShare = 3.
const TRACK_SOURCE_CAMERA = 1;
export function CameraTile({ userId, label, isLocal = false }: CameraTileProps) {
const voice = useVoice() as any;
const videoRef = useRef<HTMLVideoElement>(null);
const [hasTrack, setHasTrack] = useState(false);
useEffect(() => {
const room = voice?.room;
const video = videoRef.current;
if (!room || !video) {
setHasTrack(false);
return;
}
let attached = false;
let currentTrack: any = null;
const findParticipant = (): any | null => {
if (isLocal) return room.localParticipant ?? null;
// LiveKit keys remote participants by `identity` — we set
// that to the Convex userId on join, so the map lookup is a
// direct hit. Fall back to linear scan for safety.
const direct = room.remoteParticipants?.get(userId);
if (direct) return direct;
for (const p of room.remoteParticipants?.values?.() ?? []) {
if ((p as any).identity === userId) return p;
}
return null;
};
const findCameraTrack = (p: any): any | null => {
if (!p?.trackPublications) return null;
for (const pub of p.trackPublications.values()) {
if (pub?.source === TRACK_SOURCE_CAMERA || pub?.source === 'camera') {
const t = pub.videoTrack || pub.track;
if (t) return t;
}
}
return null;
};
const attach = () => {
const p = findParticipant();
const track = findCameraTrack(p);
if (!track) {
if (attached && currentTrack) {
try { currentTrack.detach(video); } catch {}
attached = false;
currentTrack = null;
setHasTrack(false);
}
return;
}
if (attached && currentTrack === track) return;
if (attached && currentTrack) {
try { currentTrack.detach(video); } catch {}
}
try {
track.attach(video);
currentTrack = track;
attached = true;
setHasTrack(true);
} catch {
setHasTrack(false);
}
};
attach();
const retry = window.setTimeout(attach, 250);
const onPublished = () => attach();
const onUnpublished = () => attach();
const onSubscribed = () => attach();
const onUnsubscribed = () => attach();
if (isLocal) {
room.localParticipant.on?.('localTrackPublished', onPublished);
room.localParticipant.on?.('localTrackUnpublished', onUnpublished);
} else {
room.on?.('trackSubscribed', onSubscribed);
room.on?.('trackUnsubscribed', onUnsubscribed);
room.on?.('trackPublished', onPublished);
room.on?.('trackUnpublished', onUnpublished);
}
return () => {
window.clearTimeout(retry);
if (isLocal) {
room.localParticipant.off?.('localTrackPublished', onPublished);
room.localParticipant.off?.('localTrackUnpublished', onUnpublished);
} else {
room.off?.('trackSubscribed', onSubscribed);
room.off?.('trackUnsubscribed', onUnsubscribed);
room.off?.('trackPublished', onPublished);
room.off?.('trackUnpublished', onUnpublished);
}
if (attached && currentTrack) {
try { currentTrack.detach(video); } catch {}
}
};
}, [voice?.room, voice?.activeChannelId, userId, isLocal, voice?.voiceStates]);
return (
<div
style={{
position: 'relative',
aspectRatio: '16 / 9',
background: '#000',
borderRadius: 12,
overflow: 'hidden',
border: '1px solid var(--background-tertiary)',
}}
>
<video
ref={videoRef}
autoPlay
playsInline
// Mute the local preview so the user doesn't hear
// themselves; remote tiles don't carry audio (that
// rides the separate mic track LiveKit subscribes to),
// but keeping `muted` here is defensive.
muted={isLocal}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
// Local preview is mirrored so the user sees
// themselves like a mirror — matches Discord/Zoom.
transform: isLocal ? 'scaleX(-1)' : undefined,
}}
/>
{!hasTrack && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-tertiary)',
fontSize: 13,
}}
>
Waiting for video
</div>
)}
<div
style={{
position: 'absolute',
bottom: 8,
left: 8,
padding: '4px 10px',
borderRadius: 999,
background: 'rgba(0, 0, 0, 0.55)',
color: '#fff',
fontSize: 12,
fontWeight: 600,
pointerEvents: 'none',
}}
>
{label}
{isLocal ? ' (you)' : ''}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
/**
* ScreenShareFlow — global host for the two-step screen-share UI.
* Reads `screenShareUI` from VoiceContext and renders either the
* settings modal or the source picker. Mounted at app level so the
* flow survives view switches mid-pick.
*/
import { useVoice } from '../../contexts/VoiceContext';
import { ScreenShareSettingsModal } from './ScreenShareSettingsModal';
import { ScreenSourcePickerModal } from './ScreenSourcePickerModal';
export function ScreenShareFlow() {
const voice = useVoice() as any;
const ui = voice?.screenShareUI;
if (!ui) return null;
if (ui.step === 'settings') {
return (
<ScreenShareSettingsModal
initial={ui.initial}
onCancel={voice.cancelScreenShareUI}
onConfirm={voice.startScreenShareWithSettings}
/>
);
}
if (ui.step === 'picker') {
// Back returns to the settings modal pre-filled with the
// settings the user just confirmed (already persisted to prefs
// in startScreenShareWithSettings).
return (
<ScreenSourcePickerModal
includeAudio={ui.settings.includeAudio}
onCancel={voice.cancelScreenShareUI}
onBack={() => voice.setScreenSharing(true)}
onConfirm={voice.finishScreenSharePick}
/>
);
}
return null;
}

View File

@@ -0,0 +1,117 @@
.body {
padding: 4px 22px 18px;
display: flex;
flex-direction: column;
gap: 18px;
min-width: 480px;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionLabel {
font-size: 0.875rem;
font-weight: 700;
color: var(--text-primary);
text-transform: none;
letter-spacing: 0;
}
.segmented {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.segment {
flex: 1 1 0;
min-width: 72px;
padding: 8px 12px;
border: 1px solid var(--background-header-secondary, rgba(255, 255, 255, 0.06));
border-radius: 6px;
background: var(--background-secondary, #2b2d31);
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.segment:hover {
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
}
.segmentActive {
background: var(--brand-experiment, #5865f2);
border-color: var(--brand-experiment, #5865f2);
color: #fff;
}
.segmentActive:hover {
background: var(--brand-experiment, #5865f2);
}
.toggleRow {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border-radius: 8px;
background: var(--background-tertiary, #1e1f22);
}
.toggleText {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.toggleSubtitle {
font-size: 0.75rem;
color: var(--text-tertiary, #949ba4);
}
.toggle {
flex-shrink: 0;
width: 44px;
height: 24px;
border: none;
border-radius: 12px;
background: var(--background-accent, #4e5058);
position: relative;
cursor: pointer;
padding: 0;
transition: background-color 0.15s;
}
.toggleOn {
background: var(--brand-experiment, #5865f2);
}
.toggleKnob {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
transition: transform 0.15s;
}
.toggleOn .toggleKnob {
transform: translateX(20px);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,116 @@
/**
* ScreenShareSettingsModal — first step of the screen-share flow.
* The user picks video quality, framerate, and whether to share
* audio. On confirm the parent advances to the source picker
* (Electron) or the browser's native getDisplayMedia picker (web).
*
* The chosen settings persist via userPreferences so the next
* share pre-fills with the user's last choice.
*/
import { useState } from 'react';
import { Modal, Button } from '@discord-clone/ui';
import styles from './ScreenShareSettingsModal.module.css';
export type ScreenShareQuality = '480p' | '720p' | '1080p' | '1440p' | '4k';
export type ScreenShareFps = 15 | 24 | 30 | 60;
export interface ScreenShareSettings {
quality: ScreenShareQuality;
fps: ScreenShareFps;
includeAudio: boolean;
}
interface Props {
initial: ScreenShareSettings;
onCancel: () => void;
onConfirm: (settings: ScreenShareSettings) => void;
}
const QUALITY_OPTIONS: ScreenShareQuality[] = ['480p', '720p', '1080p', '1440p', '4k'];
const FPS_OPTIONS: ScreenShareFps[] = [15, 24, 30, 60];
const QUALITY_LABEL: Record<ScreenShareQuality, string> = {
'480p': '480p',
'720p': '720p',
'1080p': '1080p',
'1440p': '1440p',
'4k': '4K',
};
export function ScreenShareSettingsModal({ initial, onCancel, onConfirm }: Props) {
const [quality, setQuality] = useState<ScreenShareQuality>(initial.quality);
const [fps, setFps] = useState<ScreenShareFps>(initial.fps);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeAudio);
const handleConfirm = () => {
onConfirm({ quality, fps, includeAudio });
};
return (
<Modal.Root isOpen onClose={onCancel} size="medium">
<Modal.Header title="Screen Share Settings" onClose={onCancel} />
<Modal.Content>
<div className={styles.body}>
<section className={styles.section}>
<div className={styles.sectionLabel}>Video Quality</div>
<div className={styles.segmented}>
{QUALITY_OPTIONS.map((q) => (
<button
key={q}
type="button"
className={`${styles.segment} ${q === quality ? styles.segmentActive : ''}`}
onClick={() => setQuality(q)}
>
{QUALITY_LABEL[q]}
</button>
))}
</div>
</section>
<section className={styles.section}>
<div className={styles.sectionLabel}>Frame Rate</div>
<div className={styles.segmented}>
{FPS_OPTIONS.map((f) => (
<button
key={f}
type="button"
className={`${styles.segment} ${f === fps ? styles.segmentActive : ''}`}
onClick={() => setFps(f)}
>
{f} FPS
</button>
))}
</div>
</section>
<section className={`${styles.section} ${styles.toggleRow}`}>
<div className={styles.toggleText}>
<div className={styles.sectionLabel}>Share Audio</div>
<div className={styles.toggleSubtitle}>
Include audio from your screen in the share
</div>
</div>
<button
type="button"
role="switch"
aria-checked={includeAudio}
className={`${styles.toggle} ${includeAudio ? styles.toggleOn : ''}`}
onClick={() => setIncludeAudio((v) => !v)}
>
<span className={styles.toggleKnob} />
</button>
</section>
<div className={styles.footer}>
<Button variant="secondary" size="md" onClick={onCancel}>
Cancel
</Button>
<Button variant="primary" size="md" onClick={handleConfirm}>
Start Sharing
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -0,0 +1,157 @@
.body {
padding: 4px 22px 18px;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 720px;
max-width: 920px;
}
.tabs {
display: flex;
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--background-header-secondary, rgba(255, 255, 255, 0.06));
padding-bottom: 8px;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #b5bac1);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.tab:hover {
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
}
.tabActive {
background: var(--background-secondary, #2b2d31);
color: var(--text-primary);
}
.tabCount {
font-size: 0.6875rem;
color: var(--text-tertiary, #949ba4);
background: var(--background-tertiary, #1e1f22);
padding: 1px 6px;
border-radius: 10px;
font-weight: 700;
}
.audioHint {
margin-left: auto;
font-size: 0.75rem;
color: var(--text-tertiary, #949ba4);
}
.audioHint strong {
color: var(--text-primary);
font-weight: 700;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
max-height: 440px;
overflow-y: auto;
padding: 4px 2px;
}
.tile {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
border: 2px solid transparent;
border-radius: 8px;
background: var(--background-secondary, #2b2d31);
color: var(--text-primary);
font: inherit;
cursor: pointer;
text-align: left;
transition: background-color 0.12s, border-color 0.12s, transform 0.12s;
}
.tile:hover {
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
}
.tileActive {
border-color: var(--brand-experiment, #5865f2);
background: var(--background-modifier-selected, rgba(88, 101, 242, 0.12));
}
.thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 6px;
overflow: hidden;
background: var(--background-tertiary, #1e1f22);
display: flex;
align-items: center;
justify-content: center;
}
.thumb {
width: 100%;
height: 100%;
object-fit: contain;
}
.tileLabel {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tileIcon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.tileName {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--text-tertiary, #949ba4);
font-size: 0.875rem;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,147 @@
/**
* ScreenSourcePickerModal — Electron-only step that runs after the
* settings modal. Lists available windows and screens with live
* thumbnails (refreshed every 2s) using the existing
* `desktopCapturer.getSources` IPC. The user picks one and clicks
* Share — the parent then stashes the source ID via
* `platform.screenCapture.setPendingSource` and triggers LiveKit's
* `setScreenShareEnabled(true)`, which our main-process
* setDisplayMediaRequestHandler resolves to that source.
*/
import { useEffect, useState } from 'react';
import { Modal, Button, Spinner } from '@discord-clone/ui';
import { usePlatform } from '../../platform';
import styles from './ScreenSourcePickerModal.module.css';
interface ScreenSource {
id: string;
name: string;
thumbnail: string;
appIcon: string | null;
}
interface Props {
includeAudio: boolean;
onCancel: () => void;
onBack: () => void;
onConfirm: (sourceId: string) => void;
}
type Tab = 'screens' | 'windows';
export function ScreenSourcePickerModal({ includeAudio, onCancel, onBack, onConfirm }: Props) {
const platform = usePlatform() as any;
const [sources, setSources] = useState<ScreenSource[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>('screens');
const [loading, setLoading] = useState(true);
useEffect(() => {
const sc = platform?.screenCapture;
if (!sc?.getScreenSources) return;
let cancelled = false;
const refresh = async () => {
try {
const list = await sc.getScreenSources();
if (cancelled) return;
setSources(Array.isArray(list) ? list : []);
setLoading(false);
} catch {
if (cancelled) return;
setSources([]);
setLoading(false);
}
};
refresh();
const t = window.setInterval(refresh, 2000);
return () => {
cancelled = true;
window.clearInterval(t);
};
}, [platform]);
const screens = sources.filter((s) => s.id.startsWith('screen:'));
const windows = sources.filter((s) => s.id.startsWith('window:'));
const visible = tab === 'screens' ? screens : windows;
const handleShare = () => {
if (selectedId) onConfirm(selectedId);
};
return (
<Modal.Root isOpen onClose={onCancel} size="large">
<Modal.Header title="Choose what to share" onClose={onCancel} />
<Modal.Content>
<div className={styles.body}>
<div className={styles.tabs}>
<button
type="button"
className={`${styles.tab} ${tab === 'screens' ? styles.tabActive : ''}`}
onClick={() => { setTab('screens'); setSelectedId(null); }}
>
Screens
<span className={styles.tabCount}>{screens.length}</span>
</button>
<button
type="button"
className={`${styles.tab} ${tab === 'windows' ? styles.tabActive : ''}`}
onClick={() => { setTab('windows'); setSelectedId(null); }}
>
Applications
<span className={styles.tabCount}>{windows.length}</span>
</button>
<div className={styles.audioHint}>
Audio: <strong>{includeAudio ? 'On' : 'Off'}</strong>
</div>
</div>
{loading ? (
<div className={styles.loading}>
<Spinner />
</div>
) : visible.length === 0 ? (
<div className={styles.empty}>
No {tab === 'screens' ? 'screens' : 'open windows'} available.
</div>
) : (
<div className={styles.grid}>
{visible.map((s) => (
<button
key={s.id}
type="button"
className={`${styles.tile} ${selectedId === s.id ? styles.tileActive : ''}`}
onClick={() => setSelectedId(s.id)}
onDoubleClick={() => onConfirm(s.id)}
>
<div className={styles.thumbWrap}>
<img src={s.thumbnail} alt="" className={styles.thumb} />
</div>
<div className={styles.tileLabel}>
{s.appIcon && (
<img src={s.appIcon} alt="" className={styles.tileIcon} />
)}
<span className={styles.tileName} title={s.name}>{s.name}</span>
</div>
</button>
))}
</div>
)}
<div className={styles.footer}>
<Button variant="secondary" size="md" onClick={onBack}>
Back
</Button>
<Button
variant="primary"
size="md"
onClick={handleShare}
disabled={!selectedId}
>
Share
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -2,6 +2,7 @@ import { ArrowLeft, Hash } from '@phosphor-icons/react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useVoice } from '../../contexts/VoiceContext'; import { useVoice } from '../../contexts/VoiceContext';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { CameraGrid } from './CameraGrid';
import { ScreenSharePreview } from './ScreenSharePreview'; import { ScreenSharePreview } from './ScreenSharePreview';
import { VoiceGridLayout } from './VoiceGridLayout'; import { VoiceGridLayout } from './VoiceGridLayout';
import { VoiceControlBar } from './VoiceControlBar'; import { VoiceControlBar } from './VoiceControlBar';
@@ -85,6 +86,7 @@ export function VoiceCallView({ channelId, compact = false }: VoiceCallViewProps
<div className={styles.mainContent}> <div className={styles.mainContent}>
<ScreenSharePreview /> <ScreenSharePreview />
<CameraGrid channelId={channelId} />
<VoiceGridLayout <VoiceGridLayout
participants={participants} participants={participants}
variant={compact ? 'compact' : 'default'} variant={compact ? 'compact' : 'default'}

View File

@@ -59,9 +59,21 @@ export function SearchProvider({ children }) {
} }
}, [searchDB]); }, [searchDB]);
// Plaintext cache — the decrypt fast-path for cold channel opens.
// See `SearchDatabase.cachePlaintexts` / `getChannelPlaintexts`.
const cachePlaintexts = useCallback((entries) => {
if (!searchDB?.isOpen() || !entries?.length) return;
searchDB.cachePlaintexts(entries);
}, [searchDB]);
const getChannelPlaintexts = useCallback((channelId, limit) => {
if (!searchDB?.isOpen() || !channelId) return [];
return searchDB.getChannelPlaintexts(channelId, limit);
}, [searchDB]);
const value = useMemo(() => ( const value = useMemo(() => (
{ isReady, indexMessages, search, save, searchDB, initialize } { isReady, indexMessages, search, save, searchDB, initialize, cachePlaintexts, getChannelPlaintexts }
), [isReady, indexMessages, search, save, searchDB, initialize]); ), [isReady, indexMessages, search, save, searchDB, initialize, cachePlaintexts, getChannelPlaintexts]);
return ( return (
<SearchContext.Provider value={value}> <SearchContext.Provider value={value}>

View File

@@ -6,6 +6,7 @@ import { api } from '../../../../convex/_generated/api';
import { findTrackPubs } from '../utils/streamUtils.jsx'; import { findTrackPubs } from '../utils/streamUtils.jsx';
import { VoiceRecorder } from '../utils/voiceRecorder'; import { VoiceRecorder } from '../utils/voiceRecorder';
import { usePlatform } from '../platform'; import { usePlatform } from '../platform';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import '@livekit/components-styles'; import '@livekit/components-styles';
import joinSound from '../assets/sounds/join_call.mp3'; import joinSound from '../assets/sounds/join_call.mp3';
@@ -1232,40 +1233,50 @@ export const VoiceProvider = ({ children }) => {
} }
}; };
// Actually flip the LiveKit screen-share publication on/off. The // Quality preset → resolution map. `setScreenShareEnabled`
// earlier implementation only toggled local React state + the // forwards `resolution` (a `VideoResolution`) into getDisplayMedia
// voiceStates row, so the Share button lit up but no track ever // constraints; the OS clamps anything the source can't supply.
// got published. `setScreenShareEnabled` is LiveKit's one-call const SCREEN_SHARE_QUALITY_MAP = {
// helper that prompts for `getDisplayMedia`, publishes the '480p': { width: 854, height: 480 },
// resulting track, and tears it down on false. '720p': { width: 1280, height: 720 },
const setScreenSharing = async (active) => { '1080p': { width: 1920, height: 1080 },
'1440p': { width: 2560, height: 1440 },
'4k': { width: 3840, height: 2160 },
};
// Two-step screen-share UI driven from VoiceCallView:
// null — no flow active
// { step: 'settings', initial } — quality/fps/audio
// { step: 'picker', settings } — Electron source picker
// The web build skips the picker step (browser ships its own
// getDisplayMedia source picker) and goes straight to publish.
const [screenShareUI, setScreenShareUI] = useState(null);
// Tear down the active screen-share publication. Snapshots the
// publications *before* disabling so we can explicitly stop the
// underlying MediaStreamTracks afterwards — LiveKit's
// setScreenShareEnabled(false) unpublishes but doesn't always
// fully release the getDisplayMedia tracks before returning,
// which caused intermittent "NotAllowedError: Permission denied"
// when the user re-shared immediately.
const stopScreenShare = async () => {
if (!room) return; if (!room) return;
// Snapshot the screen-share publications *before* disabling so we
// can explicitly stop the underlying MediaStreamTracks afterwards.
// LiveKit's `setScreenShareEnabled(false)` unpublishes but doesn't
// always fully release the getDisplayMedia tracks before returning,
// which caused intermittent "NotAllowedError: Permission denied"
// when the user re-shared immediately.
const toStop = []; const toStop = [];
if (!active) { const pubs = room.localParticipant?.trackPublications;
const pubs = room.localParticipant?.trackPublications; if (pubs?.forEach) {
if (pubs?.forEach) { pubs.forEach((pub) => {
pubs.forEach((pub) => { const src = pub.source ?? pub.track?.source;
const src = pub.source ?? pub.track?.source; if (src === 'screen_share' || src === 'screen_share_audio') {
if (src === 'screen_share' || src === 'screen_share_audio') { if (pub.track?.mediaStreamTrack) {
if (pub.track?.mediaStreamTrack) { toStop.push(pub.track.mediaStreamTrack);
toStop.push(pub.track.mediaStreamTrack);
}
} }
}); }
} });
} }
try { try {
await room.localParticipant.setScreenShareEnabled(active, { await room.localParticipant.setScreenShareEnabled(false);
audio: true,
});
} catch (e) { } catch (e) {
console.warn('Failed to toggle screen share:', e); console.warn('Failed to stop screen share:', e);
const published = !!room.localParticipant.getTrackPublication?.( const published = !!room.localParticipant.getTrackPublication?.(
'screen_share', 'screen_share',
); );
@@ -1273,15 +1284,98 @@ export const VoiceProvider = ({ children }) => {
await updateVoiceState({ isScreenSharing: published }); await updateVoiceState({ isScreenSharing: published });
return; return;
} }
for (const mst of toStop) {
try { mst.stop(); } catch { /* already stopped */ }
}
setIsScreenSharingLocal(false);
await updateVoiceState({ isScreenSharing: false });
};
// Actually publish the screen-share track with the user's chosen
// quality / framerate / audio settings. Called from
// `finishScreenSharePick` (Electron) and `startScreenShareWithSettings`
// (web — browser native picker handles source selection).
const publishScreenShare = async (settings) => {
if (!room) return;
const res = SCREEN_SHARE_QUALITY_MAP[settings.quality]
|| SCREEN_SHARE_QUALITY_MAP['720p'];
try {
await room.localParticipant.setScreenShareEnabled(true, {
resolution: { ...res, frameRate: settings.fps },
audio: settings.includeAudio,
systemAudio: settings.includeAudio ? 'include' : 'exclude',
});
} catch (e) {
console.warn('Failed to start screen share:', e);
const published = !!room.localParticipant.getTrackPublication?.(
'screen_share',
);
setIsScreenSharingLocal(published);
await updateVoiceState({ isScreenSharing: published });
return;
}
setIsScreenSharingLocal(true);
await updateVoiceState({ isScreenSharing: true });
};
// Public entry point. `false` tears down immediately; `true`
// opens the settings modal. The modal advances via
// `startScreenShareWithSettings` → `finishScreenSharePick`.
const setScreenSharing = async (active) => {
if (!room) return;
if (!active) { if (!active) {
for (const mst of toStop) { await stopScreenShare();
try { mst.stop(); } catch { /* already stopped */ } return;
}
setScreenShareUI({
step: 'settings',
initial: {
quality: getUserPref(myUserId, 'screenShareQuality', '720p'),
fps: getUserPref(myUserId, 'screenShareFps', 30),
includeAudio: getUserPref(myUserId, 'screenShareAudio', true),
},
});
};
// Settings modal → next step. On Electron we open our picker;
// on web we kick off LiveKit directly (the browser's native
// getDisplayMedia picker handles source selection).
const startScreenShareWithSettings = async (settings) => {
if (myUserId) {
setUserPref(myUserId, 'screenShareQuality', settings.quality, platform?.settings);
setUserPref(myUserId, 'screenShareFps', settings.fps, platform?.settings);
setUserPref(myUserId, 'screenShareAudio', settings.includeAudio, platform?.settings);
}
if (platform?.screenCapture?.setPendingSource) {
setScreenShareUI({ step: 'picker', settings });
} else {
setScreenShareUI(null);
await publishScreenShare(settings);
}
};
// Picker modal → final step. Stash the source via IPC so the
// main-process setDisplayMediaRequestHandler resolves to it,
// then trigger LiveKit publish.
const finishScreenSharePick = async (sourceId) => {
const settings = screenShareUI?.settings;
setScreenShareUI(null);
if (!settings) return;
if (platform?.screenCapture?.setPendingSource) {
try {
await platform.screenCapture.setPendingSource({
sourceId,
includeAudio: settings.includeAudio,
});
} catch (e) {
console.warn('Failed to stash screen-share source:', e);
} }
} }
setIsScreenSharingLocal(active); await publishScreenShare(settings);
await updateVoiceState({ isScreenSharing: active });
}; };
const cancelScreenShareUI = () => setScreenShareUI(null);
// Camera toggle — publishes / unpublishes the local webcam track // Camera toggle — publishes / unpublishes the local webcam track
// via LiveKit's `setCameraEnabled` helper. Mirrors the mic and // via LiveKit's `setCameraEnabled` helper. Mirrors the mic and
// screen-share code paths so the UI can show a single consistent // screen-share code paths so the UI can show a single consistent
@@ -1292,11 +1386,16 @@ export const VoiceProvider = ({ children }) => {
try { try {
await room.localParticipant.setCameraEnabled(active); await room.localParticipant.setCameraEnabled(active);
setIsCameraOn(active); setIsCameraOn(active);
// Publish camera state so everyone in the channel sees the
// video tile appear / disappear without having to rely on
// LiveKit track events alone.
await updateVoiceState({ isCameraOn: active });
playSound(active ? 'camera_on' : 'camera_off'); playSound(active ? 'camera_on' : 'camera_off');
} catch (e) { } catch (e) {
console.warn('Failed to toggle camera:', e); console.warn('Failed to toggle camera:', e);
const published = !!room.localParticipant.isCameraEnabled; const published = !!room.localParticipant.isCameraEnabled;
setIsCameraOn(published); setIsCameraOn(published);
try { await updateVoiceState({ isCameraOn: published }); } catch {}
} }
}; };
const toggleCamera = () => setCamera(!isCameraOn); const toggleCamera = () => setCamera(!isCameraOn);
@@ -1424,6 +1523,10 @@ export const VoiceProvider = ({ children }) => {
toggleDeafen, toggleDeafen,
isScreenSharing, isScreenSharing,
setScreenSharing, setScreenSharing,
screenShareUI,
startScreenShareWithSettings,
finishScreenSharePick,
cancelScreenShareUI,
isCameraOn, isCameraOn,
setCamera, setCamera,
toggleCamera, toggleCamera,
@@ -1469,6 +1572,7 @@ export const VoiceProvider = ({ children }) => {
toggleDeafen, toggleDeafen,
isScreenSharing, isScreenSharing,
setScreenSharing, setScreenSharing,
screenShareUI,
isCameraOn, isCameraOn,
setCamera, setCamera,
toggleCamera, toggleCamera,

View File

@@ -43,6 +43,7 @@
/** /**
* @typedef {Object} PlatformScreenCapture * @typedef {Object} PlatformScreenCapture
* @property {() => Promise<Array>} getScreenSources * @property {() => Promise<Array>} getScreenSources
* @property {(payload: {sourceId: string, includeAudio: boolean}) => Promise<boolean>} [setPendingSource] - Electron only. Pre-stash the source the renderer picked so the next getDisplayMedia call resolves to it.
*/ */
/** /**

View File

@@ -27,8 +27,31 @@ CREATE TABLE IF NOT EXISTS messages (
CREATE INDEX IF NOT EXISTS idx_channel ON messages(channel_id); CREATE INDEX IF NOT EXISTS idx_channel ON messages(channel_id);
CREATE INDEX IF NOT EXISTS idx_sender ON messages(sender_id); CREATE INDEX IF NOT EXISTS idx_sender ON messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at); CREATE INDEX IF NOT EXISTS idx_created ON messages(created_at);
-- Decryption cache: stores the post-decrypt plaintext alongside the
-- ciphertext fingerprint it came from. On cold channel open the UI
-- seeds its decrypted map from here so message bodies appear
-- instantly; the existing effects in Messages.tsx compare the stored
-- fingerprint with the live Convex ciphertext so edits invalidate
-- the entry and re-run the real decrypt.
CREATE TABLE IF NOT EXISTS plaintext_cache (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
ciphertext_src TEXT NOT NULL,
plaintext TEXT NOT NULL,
created_at INTEGER NOT NULL,
cached_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pc_channel ON plaintext_cache(channel_id);
CREATE INDEX IF NOT EXISTS idx_pc_channel_created ON plaintext_cache(channel_id, created_at DESC);
`; `;
// Per-channel cap — when the cache grows beyond this, the oldest
// rows for that channel get evicted first. Generous enough to cover
// several long scrollbacks without blowing up the encrypted blob.
const PLAINTEXT_PER_CHANNEL_CAP = 500;
let sqlPromise = null; let sqlPromise = null;
function getSql() { function getSql() {
@@ -81,6 +104,10 @@ export default class SearchDatabase {
try { this.db.run('DROP TABLE IF EXISTS messages_fts'); } catch {} try { this.db.run('DROP TABLE IF EXISTS messages_fts'); } catch {}
// Migrate: add attachment_meta column if missing // Migrate: add attachment_meta column if missing
try { this.db.run("ALTER TABLE messages ADD COLUMN attachment_meta TEXT DEFAULT ''"); } catch {} try { this.db.run("ALTER TABLE messages ADD COLUMN attachment_meta TEXT DEFAULT ''"); } catch {}
// Ensure the plaintext cache table exists for DBs created
// before it was part of the schema. `CREATE TABLE IF NOT
// EXISTS` is a no-op when it's already there.
try { this.db.run(SCHEMA_SQL); } catch {}
console.log('Search DB loaded from encrypted storage'); console.log('Search DB loaded from encrypted storage');
} }
} catch (err) { } catch (err) {
@@ -334,6 +361,113 @@ export default class SearchDatabase {
} }
} }
/**
* Cache a batch of freshly-decrypted messages so a later cold open
* can skip the decrypt roundtrip. `entries` is an array of
* `{ id, channelId, ciphertextSrc, plaintext, createdAt }` — the
* caller decides what counts as "ciphertextSrc" (we use the first
* 64 chars of the on-wire ciphertext, which is enough to detect
* edits). Idempotent; subsequent calls overwrite by primary key.
*/
cachePlaintexts(entries) {
if (!this.db || !entries || entries.length === 0) return;
try {
this.db.run('BEGIN TRANSACTION');
const put = this.db.prepare(
`INSERT OR REPLACE INTO plaintext_cache
(id, channel_id, ciphertext_src, plaintext, created_at, cached_at)
VALUES (?, ?, ?, ?, ?, ?)`,
);
const now = Date.now();
const channels = new Set();
for (const e of entries) {
if (!e?.id || !e?.channelId || !e?.ciphertextSrc) continue;
put.run([
String(e.id),
String(e.channelId),
String(e.ciphertextSrc),
String(e.plaintext ?? ''),
typeof e.createdAt === 'number' ? e.createdAt : now,
now,
]);
channels.add(String(e.channelId));
}
put.free();
// Per-channel eviction so a single hot channel can't starve
// everything else. The DELETE keeps the newest `CAP` rows by
// `created_at`, matching how Messages.tsx reads the head.
for (const channelId of channels) {
const evict = this.db.prepare(
`DELETE FROM plaintext_cache
WHERE channel_id = ?
AND id NOT IN (
SELECT id FROM plaintext_cache
WHERE channel_id = ?
ORDER BY created_at DESC
LIMIT ?
)`,
);
evict.run([channelId, channelId, PLAINTEXT_PER_CHANNEL_CAP]);
evict.free();
}
this.db.run('COMMIT');
this._dirty = true;
this._scheduleSave();
} catch (err) {
try { this.db.run('ROLLBACK'); } catch {}
console.error('Plaintext cache write failed:', err);
}
}
/**
* Read the most recent `limit` plaintexts for a channel, newest
* first. Each row has `{ id, ciphertextSrc, plaintext, createdAt }`.
* Safe to call before the DB finishes its first save — returns an
* empty array if the DB isn't open yet.
*/
getChannelPlaintexts(channelId, limit = 200) {
if (!this.db || !channelId) return [];
try {
const stmt = this.db.prepare(
`SELECT id, ciphertext_src, plaintext, created_at
FROM plaintext_cache
WHERE channel_id = ?
ORDER BY created_at DESC
LIMIT ?`,
);
stmt.bind([String(channelId), Math.max(1, Math.min(limit, 500))]);
const out = [];
while (stmt.step()) {
const row = stmt.getAsObject();
out.push({
id: row.id,
ciphertextSrc: row.ciphertext_src,
plaintext: row.plaintext,
createdAt: row.created_at,
});
}
stmt.free();
return out;
} catch (err) {
console.error('Plaintext cache read failed:', err);
return [];
}
}
/** Drop every cached plaintext for a channel — e.g. after channel delete. */
clearChannelPlaintexts(channelId) {
if (!this.db || !channelId) return;
try {
this.db.run('DELETE FROM plaintext_cache WHERE channel_id = ?', [String(channelId)]);
this._dirty = true;
this._scheduleSave();
} catch (err) {
console.error('Plaintext cache clear failed:', err);
}
}
getStats() { getStats() {
if (!this.db) return { count: 0 }; if (!this.db) return { count: 0 };
try { try {