This commit is contained in:
@@ -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/member/MemberListContainer.tsx)",
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
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
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
@@ -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) {
|
||||
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
|
||||
const crypto = require('crypto');
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld('cryptoAPI', {
|
||||
fetchMetadata: (url) => ipcRenderer.invoke('fetch-metadata', url),
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
getScreenSources: () => ipcRenderer.invoke('get-screen-sources'),
|
||||
setPendingScreenSource: (payload) => ipcRenderer.invoke('screen-share:set-pending-source', payload),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('windowControls', {
|
||||
|
||||
@@ -47,6 +47,7 @@ const electronPlatform = {
|
||||
},
|
||||
screenCapture: {
|
||||
getScreenSources: () => window.cryptoAPI.getScreenSources(),
|
||||
setPendingSource: (payload) => window.cryptoAPI.setPendingScreenSource(payload),
|
||||
},
|
||||
windowControls: {
|
||||
minimize: () => window.windowControls.minimize(),
|
||||
|
||||
@@ -119,6 +119,10 @@ export default defineSchema({
|
||||
isDeafened: v.boolean(),
|
||||
isScreenSharing: 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")),
|
||||
lastHeartbeat: v.optional(v.number()),
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ export const updateState = mutation({
|
||||
isMuted: v.optional(v.boolean()),
|
||||
isDeafened: v.optional(v.boolean()),
|
||||
isScreenSharing: v.optional(v.boolean()),
|
||||
isCameraOn: v.optional(v.boolean()),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -155,6 +156,7 @@ export const getAll = query({
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isScreenSharing: boolean;
|
||||
isCameraOn: boolean;
|
||||
isServerMuted: boolean;
|
||||
avatarUrl: string | null;
|
||||
joinSoundUrl: string | null;
|
||||
@@ -186,6 +188,7 @@ export const getAll = query({
|
||||
isMuted: s.isMuted,
|
||||
isDeafened: s.isDeafened,
|
||||
isScreenSharing: s.isScreenSharing,
|
||||
isCameraOn: s.isCameraOn ?? false,
|
||||
isServerMuted: s.isServerMuted,
|
||||
avatarUrl,
|
||||
joinSoundUrl,
|
||||
@@ -357,7 +360,7 @@ export const moveUser = mutation({
|
||||
// No-op if already in the target channel
|
||||
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.insert("voiceStates", {
|
||||
channelId: args.targetChannelId,
|
||||
@@ -366,6 +369,7 @@ export const moveUser = mutation({
|
||||
isMuted: currentState.isMuted,
|
||||
isDeafened: currentState.isDeafened,
|
||||
isScreenSharing: currentState.isScreenSharing,
|
||||
isCameraOn: currentState.isCameraOn ?? false,
|
||||
isServerMuted: currentState.isServerMuted,
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useAction, useMutation, usePaginatedQuery, useQuery } from 'convex/reac
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { useSearch } from '../../contexts/SearchContext';
|
||||
import { MessageGroup } from './MessageGroup';
|
||||
import { PollCard } from './PollCard';
|
||||
import { ChannelWelcomeSection } from './ChannelWelcomeSection';
|
||||
@@ -338,6 +339,26 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
|
||||
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.
|
||||
// `messages.edit` changes the ciphertext while keeping the id,
|
||||
// so the decrypt effects below gate on "has id AND source
|
||||
@@ -542,9 +563,40 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const persistBatch: Array<{
|
||||
id: string;
|
||||
channelId: string;
|
||||
ciphertextSrc: string;
|
||||
plaintext: string;
|
||||
createdAt: number;
|
||||
}> = [];
|
||||
for (const r of results) {
|
||||
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.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) => {
|
||||
const next = new Map(prev);
|
||||
|
||||
@@ -137,6 +137,7 @@ export function VoiceMessagePlayer({ src, peaks, durationSec }: VoiceMessagePlay
|
||||
gap: 10,
|
||||
padding: '6px 12px 6px 6px',
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
borderRadius: 999,
|
||||
maxWidth: 340,
|
||||
width: '100%',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CreateCategoryModal } from './CategorySettingsModal';
|
||||
import { InviteModal } from '../modals/InviteModal';
|
||||
import { CreateServerModal } from '../modals/CreateServerModal';
|
||||
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
|
||||
import { ScreenShareFlow } from '../voice/ScreenShareFlow';
|
||||
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
|
||||
import { MobileChannelSettingsPage } from '../channel/MobileChannelSettingsPage';
|
||||
import { MobileCreateChannelPage } from './MobileCreateChannelPage';
|
||||
@@ -273,6 +274,7 @@ export function AppLayout() {
|
||||
/>
|
||||
)}
|
||||
<RecordingRecoveryModal />
|
||||
<ScreenShareFlow />
|
||||
<UpdateBanner />
|
||||
<NotificationManager myUserId={myUserId} />
|
||||
</KeybindProvider>
|
||||
|
||||
83
packages/shared/src/components/voice/CameraGrid.tsx
Normal file
83
packages/shared/src/components/voice/CameraGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
packages/shared/src/components/voice/CameraTile.tsx
Normal file
182
packages/shared/src/components/voice/CameraTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
packages/shared/src/components/voice/ScreenShareFlow.tsx
Normal file
41
packages/shared/src/components/voice/ScreenShareFlow.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
147
packages/shared/src/components/voice/ScreenSourcePickerModal.tsx
Normal file
147
packages/shared/src/components/voice/ScreenSourcePickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ArrowLeft, Hash } from '@phosphor-icons/react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { CameraGrid } from './CameraGrid';
|
||||
import { ScreenSharePreview } from './ScreenSharePreview';
|
||||
import { VoiceGridLayout } from './VoiceGridLayout';
|
||||
import { VoiceControlBar } from './VoiceControlBar';
|
||||
@@ -85,6 +86,7 @@ export function VoiceCallView({ channelId, compact = false }: VoiceCallViewProps
|
||||
|
||||
<div className={styles.mainContent}>
|
||||
<ScreenSharePreview />
|
||||
<CameraGrid channelId={channelId} />
|
||||
<VoiceGridLayout
|
||||
participants={participants}
|
||||
variant={compact ? 'compact' : 'default'}
|
||||
|
||||
@@ -59,9 +59,21 @@ export function SearchProvider({ children }) {
|
||||
}
|
||||
}, [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(() => (
|
||||
{ isReady, indexMessages, search, save, searchDB, initialize }
|
||||
), [isReady, indexMessages, search, save, searchDB, initialize]);
|
||||
{ isReady, indexMessages, search, save, searchDB, initialize, cachePlaintexts, getChannelPlaintexts }
|
||||
), [isReady, indexMessages, search, save, searchDB, initialize, cachePlaintexts, getChannelPlaintexts]);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider value={value}>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../../../../convex/_generated/api';
|
||||
import { findTrackPubs } from '../utils/streamUtils.jsx';
|
||||
import { VoiceRecorder } from '../utils/voiceRecorder';
|
||||
import { usePlatform } from '../platform';
|
||||
import { getUserPref, setUserPref } from '../utils/userPreferences';
|
||||
import '@livekit/components-styles';
|
||||
|
||||
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
|
||||
// earlier implementation only toggled local React state + the
|
||||
// voiceStates row, so the Share button lit up but no track ever
|
||||
// got published. `setScreenShareEnabled` is LiveKit's one-call
|
||||
// helper that prompts for `getDisplayMedia`, publishes the
|
||||
// resulting track, and tears it down on false.
|
||||
const setScreenSharing = async (active) => {
|
||||
// Quality preset → resolution map. `setScreenShareEnabled`
|
||||
// forwards `resolution` (a `VideoResolution`) into getDisplayMedia
|
||||
// constraints; the OS clamps anything the source can't supply.
|
||||
const SCREEN_SHARE_QUALITY_MAP = {
|
||||
'480p': { width: 854, height: 480 },
|
||||
'720p': { width: 1280, height: 720 },
|
||||
'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;
|
||||
// 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 = [];
|
||||
if (!active) {
|
||||
const pubs = room.localParticipant?.trackPublications;
|
||||
if (pubs?.forEach) {
|
||||
pubs.forEach((pub) => {
|
||||
const src = pub.source ?? pub.track?.source;
|
||||
if (src === 'screen_share' || src === 'screen_share_audio') {
|
||||
if (pub.track?.mediaStreamTrack) {
|
||||
toStop.push(pub.track.mediaStreamTrack);
|
||||
}
|
||||
const pubs = room.localParticipant?.trackPublications;
|
||||
if (pubs?.forEach) {
|
||||
pubs.forEach((pub) => {
|
||||
const src = pub.source ?? pub.track?.source;
|
||||
if (src === 'screen_share' || src === 'screen_share_audio') {
|
||||
if (pub.track?.mediaStreamTrack) {
|
||||
toStop.push(pub.track.mediaStreamTrack);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
try {
|
||||
await room.localParticipant.setScreenShareEnabled(active, {
|
||||
audio: true,
|
||||
});
|
||||
await room.localParticipant.setScreenShareEnabled(false);
|
||||
} catch (e) {
|
||||
console.warn('Failed to toggle screen share:', e);
|
||||
console.warn('Failed to stop screen share:', e);
|
||||
const published = !!room.localParticipant.getTrackPublication?.(
|
||||
'screen_share',
|
||||
);
|
||||
@@ -1273,15 +1284,98 @@ export const VoiceProvider = ({ children }) => {
|
||||
await updateVoiceState({ isScreenSharing: published });
|
||||
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) {
|
||||
for (const mst of toStop) {
|
||||
try { mst.stop(); } catch { /* already stopped */ }
|
||||
await stopScreenShare();
|
||||
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 updateVoiceState({ isScreenSharing: active });
|
||||
await publishScreenShare(settings);
|
||||
};
|
||||
|
||||
const cancelScreenShareUI = () => setScreenShareUI(null);
|
||||
|
||||
// Camera toggle — publishes / unpublishes the local webcam track
|
||||
// via LiveKit's `setCameraEnabled` helper. Mirrors the mic and
|
||||
// screen-share code paths so the UI can show a single consistent
|
||||
@@ -1292,11 +1386,16 @@ export const VoiceProvider = ({ children }) => {
|
||||
try {
|
||||
await room.localParticipant.setCameraEnabled(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');
|
||||
} catch (e) {
|
||||
console.warn('Failed to toggle camera:', e);
|
||||
const published = !!room.localParticipant.isCameraEnabled;
|
||||
setIsCameraOn(published);
|
||||
try { await updateVoiceState({ isCameraOn: published }); } catch {}
|
||||
}
|
||||
};
|
||||
const toggleCamera = () => setCamera(!isCameraOn);
|
||||
@@ -1424,6 +1523,10 @@ export const VoiceProvider = ({ children }) => {
|
||||
toggleDeafen,
|
||||
isScreenSharing,
|
||||
setScreenSharing,
|
||||
screenShareUI,
|
||||
startScreenShareWithSettings,
|
||||
finishScreenSharePick,
|
||||
cancelScreenShareUI,
|
||||
isCameraOn,
|
||||
setCamera,
|
||||
toggleCamera,
|
||||
@@ -1469,6 +1572,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
toggleDeafen,
|
||||
isScreenSharing,
|
||||
setScreenSharing,
|
||||
screenShareUI,
|
||||
isCameraOn,
|
||||
setCamera,
|
||||
toggleCamera,
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
/**
|
||||
* @typedef {Object} PlatformScreenCapture
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -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_sender ON messages(sender_id);
|
||||
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;
|
||||
|
||||
function getSql() {
|
||||
@@ -81,6 +104,10 @@ export default class SearchDatabase {
|
||||
try { this.db.run('DROP TABLE IF EXISTS messages_fts'); } catch {}
|
||||
// Migrate: add attachment_meta column if missing
|
||||
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');
|
||||
}
|
||||
} 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() {
|
||||
if (!this.db) return { count: 0 };
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user