1.1.00
All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s

This commit is contained in:
Bryan1029384756
2026-04-16 20:14:27 -05:00
parent 56a12fdf3e
commit 6813bb40dc
40 changed files with 2228 additions and 387 deletions

View File

@@ -66,8 +66,18 @@ export function LoginPage() {
searchDbKey: searchKeys.dak,
savedAt: Date.now(),
});
} catch (err) {
console.warn('Session persistence unavailable:', err);
} catch (err: any) {
// Quota overflow means encryption keys never hit disk —
// user will be logged out on next reload. Surface this
// so they can clear browser storage instead of
// wondering why they keep getting kicked out.
if (err?.isQuotaError) {
setError(
'Browser storage is full. You will be logged out on reload. Free up storage to persist your session.',
);
} else {
console.warn('Session persistence unavailable:', err);
}
}
}

View File

@@ -1,4 +1,4 @@
import { useConvex, useMutation, useQuery } from 'convex/react';
import { useAction, useConvex, useMutation, useQuery } from 'convex/react';
import {
ArrowUp,
ChartBar,
@@ -130,7 +130,7 @@ export function ChannelTextarea({
);
const keyBundle = allKeys?.find((k) => k.channel_id === channelId) ?? null;
const sendMessage = useMutation(api.messages.send);
const sendMessage = useAction(api.messageActions.send);
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const validateUpload = useMutation(api.files.validateUpload);
@@ -389,6 +389,15 @@ export function ChannelTextarea({
.map((a) => a.previewUrl)
.filter((u): u is string => !!u);
// Clear the composer up front so a second Enter keystroke
// (while the signed send round-trips through the action) can't
// re-submit the same text. Snapshot innerHTML first so we can
// put the draft back if the send throws.
const prevHTML = editorRef.current?.innerHTML ?? '';
if (editorRef.current) editorRef.current.textContent = '';
setIsEmpty(true);
setMentionQuery(null);
try {
// 1. Text message first (if any). Matches the old client's
// send-then-attach order so the reply context lands on
@@ -397,6 +406,11 @@ export function ChannelTextarea({
const { content, iv, tag } = await crypto.encryptData(text, channelKey);
const ciphertext = content + tag;
const signature = await crypto.signMessage(signingKey, ciphertext);
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`send:${channelId}:${userId}:${authTimestamp}`,
);
await sendMessage({
channelId: channelId as any,
senderId: userId as any,
@@ -405,6 +419,8 @@ export function ChannelTextarea({
signature,
keyVersion: channelKeyVersion,
replyTo: replyTo ? (replyTo.eventId as any) : undefined,
authTimestamp,
authSignature,
});
}
@@ -430,9 +446,6 @@ export function ChannelTextarea({
}
}
if (editorRef.current) editorRef.current.textContent = '';
setIsEmpty(true);
setMentionQuery(null);
if (userId && channelId) {
void stopTyping({
channelId: channelId as any,
@@ -443,6 +456,13 @@ export function ChannelTextarea({
onCancelReply?.();
} catch (err) {
console.error('Failed to send message:', err);
// Send failed — restore the draft so the user can retry
// without retyping. innerHTML preserves mentions, emoji
// nodes, and any other rich content.
if (editorRef.current && prevHTML) {
editorRef.current.innerHTML = prevHTML;
setIsEmpty((editorRef.current.textContent ?? '').trim().length === 0);
}
}
};
@@ -455,6 +475,11 @@ export function ChannelTextarea({
const { content, iv, tag } = await crypto.encryptData(payload, channelKey);
const ciphertext = content + tag;
const signature = await crypto.signMessage(signingKey, ciphertext);
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`send:${channelId}:${userId}:${authTimestamp}`,
);
await sendMessage({
channelId: channelId as any,
senderId: userId as any,
@@ -463,6 +488,8 @@ export function ChannelTextarea({
signature,
keyVersion: channelKeyVersion,
replyTo: replyTo ? (replyTo.eventId as any) : undefined,
authTimestamp,
authSignature,
});
};
@@ -476,6 +503,11 @@ export function ChannelTextarea({
const { content, iv, tag } = await crypto.encryptData(payload, channelKey);
const ciphertext = content + tag;
const signature = await crypto.signMessage(signingKey, ciphertext);
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`send:${channelId}:${userId}:${authTimestamp}`,
);
await sendMessage({
channelId: channelId as any,
senderId: userId as any,
@@ -484,6 +516,8 @@ export function ChannelTextarea({
signature,
keyVersion: keyBundle?.key_version ?? 1,
replyTo: replyTo ? (replyTo.eventId as any) : undefined,
authTimestamp,
authSignature,
});
};

View File

@@ -0,0 +1,99 @@
/* ── Delete confirmation modal ────────────────────────────────────
Shown before `api.messageActions.remove` actually runs. Parallels
PinConfirmationModal: description + static PinnedMessageRow
preview + Cancel / Delete actions. Two differences from the pin
dialog: the action row is horizontal (Cancel left, Delete right)
and the primary button is always the danger variant. */
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0;
}
.headerFlush {
border-bottom: none;
}
.description {
font-size: 0.9375rem;
line-height: 1.4;
color: var(--text-secondary);
margin: 0;
}
.previewWrap {
margin: 0 -12px;
}
.actions {
display: flex;
flex-direction: row;
gap: 8px;
margin-top: 4px;
}
.primaryButton,
.secondaryButton {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
border: none;
border-radius: 0.75rem;
font: inherit;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.primaryButton {
background-color: var(--brand-primary);
color: #fff;
transition: filter 0.15s;
}
.primaryButton:active:not(:disabled) {
filter: brightness(0.92);
}
.primaryButton:disabled {
opacity: 0.55;
cursor: default;
}
/* Danger variant — Delete is always destructive so this is always on. */
.primaryButtonDanger {
background-color: var(--button-danger-fill);
}
.primaryButtonDanger:hover:not(:disabled) {
filter: brightness(1.05);
}
.primaryButtonDanger:active:not(:disabled) {
background-color: var(--button-danger-active-fill);
filter: none;
}
.secondaryButton {
background-color: var(--background-secondary-alt);
color: var(--text-primary);
transition: background-color 0.15s;
}
.secondaryButton:hover,
.secondaryButton:active {
background-color: var(--background-modifier-hover);
}
.error {
padding: 10px 14px;
background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%);
color: hsl(0, calc(80% * var(--saturation-factor)), 85%);
border-radius: 0.5rem;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,116 @@
/**
* DeleteConfirmationModal — confirmation dialog shown before a
* message is actually removed. Mirrors PinConfirmationModal: read-only
* `PinnedMessageRow` preview of the target, short reassurance copy,
* two stacked actions with the primary button in danger red.
*
* Calls `api.messageActions.remove` directly (with the signed auth
* payload the action layer requires) so callers don't need to plumb
* their own signing logic through to the confirm button.
*/
import { useState } from 'react';
import { useAction } from 'convex/react';
import { Modal } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { usePlatform } from '../../platform';
import { PinnedMessageRow, type PinnedMessage } from './PinnedMessageRow';
import styles from './DeleteConfirmationModal.module.css';
interface DeleteConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
messageId: string | null;
message: PinnedMessage | null;
}
export function DeleteConfirmationModal({
isOpen,
onClose,
messageId,
message,
}: DeleteConfirmationModalProps) {
const removeMessage = useAction(api.messageActions.remove);
const { crypto } = usePlatform();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConfirm = async () => {
if (!messageId || busy) return;
const userId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!userId || !signingKey) {
setError('Not signed in');
return;
}
setBusy(true);
setError(null);
try {
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`remove:${messageId}:${userId}:${authTimestamp}`,
);
await removeMessage({
id: messageId as Id<'messages'>,
userId: userId as Id<'userProfiles'>,
authTimestamp,
authSignature,
});
onClose();
} catch (err: any) {
setError(err?.message || 'Failed to delete message.');
} finally {
setBusy(false);
}
};
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="small">
<Modal.Header
title="Delete Message"
onClose={onClose}
className={styles.headerFlush}
/>
<Modal.Content>
<div className={styles.body}>
<p className={styles.description}>
Are you sure you want to delete this message? This action cannot
be undone.
</p>
{message && (
<div className={styles.previewWrap}>
<PinnedMessageRow message={message} showEmbeds />
</div>
)}
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
type="button"
className={styles.secondaryButton}
onClick={onClose}
disabled={busy}
>
Cancel
</button>
<button
type="button"
className={`${styles.primaryButton} ${styles.primaryButtonDanger}`}
onClick={handleConfirm}
disabled={busy || !messageId}
>
{busy ? 'Deleting…' : 'Delete'}
</button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -23,6 +23,20 @@ const TAG_HEX_LEN = 32;
// same file. Keyed by the remote storage URL.
const attachmentCache = new Map<string, string>();
// Natural dimensions learned on first render for legacy images whose
// metadata predates the upload-time dimension capture. Once populated,
// remounts of the same attachment (pagination, channel re-open) go
// straight to the correctly-sized placeholder instead of the
// fluid-but-wrong 4:3 fallback that used to expand mid-load.
const probedDimsCache = new Map<string, { w: number; h: number }>();
// Placeholder box for images whose dimensions we haven't probed yet.
// Fixed size is preferable to a fluid fallback because a wrong fluid
// aspect-ratio (e.g. 4:3 for a 16:9 landscape) shifts height when the
// real image lands; a fixed-size box may leave blank margin but doesn't
// shift the scroll anchor.
const PROBE_FALLBACK = { w: 300, h: 200 } as const;
function fromHexString(hex: string): Uint8Array {
const matches = hex.match(/.{1,2}/g) ?? [];
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
@@ -118,24 +132,26 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac
}
if (kind === 'image') {
// Reserve the exact final layout box up-front so the loaded
// image lands in the same slot the placeholder occupied — no
// post-load height shift, no scroll jump. When the metadata
// carries both width + height we compute the box from them;
// otherwise we fall back to a ratio-aware aspect-ratio so the
// browser still reserves a sensible chunk of space.
const hasDims = !!metadata.width && !!metadata.height;
const maxW = metadata.width ? Math.min(metadata.width, 400) : 300;
const renderedH =
hasDims
? Math.round(maxW * (metadata.height! / metadata.width!))
: undefined;
// Dimension resolution order:
// 1. metadata.width/height (captured at upload time — modern path)
// 2. probedDimsCache for this url (learned on a previous mount)
// 3. PROBE_FALLBACK (fixed 300×200) while we wait for the
// first successful <img> onLoad to populate the cache
// Fixed fallback (vs. a fluid 4:3 aspect-ratio) is the critical
// bit: a wrong fluid ratio shifts height when the real image
// lands, which defeats the whole point of reserving space.
const metaDims =
metadata.width && metadata.height
? { w: metadata.width, h: metadata.height }
: null;
const probedDims = metaDims ?? probedDimsCache.get(metadata.url) ?? null;
const boxDims = probedDims ?? PROBE_FALLBACK;
const maxW = Math.min(boxDims.w, 400);
const renderedH = Math.round(maxW * (boxDims.h / boxDims.w));
const sharedBoxStyle: React.CSSProperties = {
width: maxW,
...(renderedH !== undefined ? { height: renderedH } : {}),
...(hasDims
? { aspectRatio: `${metadata.width} / ${metadata.height}` }
: { aspectRatio: '4 / 3' }),
height: renderedH,
aspectRatio: `${boxDims.w} / ${boxDims.h}`,
maxHeight: '50vh',
borderRadius: 'var(--radius-lg)',
};
@@ -165,7 +181,20 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac
objectFit: 'cover',
cursor: 'pointer',
}}
onLoad={() => {
onLoad={(e) => {
// Learn natural dimensions for legacy attachments
// whose metadata omitted width/height. The cache is
// keyed by the remote storage url so re-mounts and
// channel re-opens pick up the correct box without
// re-probing.
if (!metaDims) {
const img = e.currentTarget;
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w > 0 && h > 0) {
probedDimsCache.set(metadata.url, { w, h });
}
}
// Tell the Messages scroller that an attachment
// finished decoding so it can re-pin to bottom if
// the user is still anchored there. Belt-and-

View File

@@ -113,26 +113,35 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
}, [trendingAction, categoriesAction]);
// Debounced search — fires 350ms after the last keystroke so we
// don't hammer the upstream API on every character.
// don't hammer the upstream API on every character. The `cancelled`
// flag is checked after every await so a slow response from an
// earlier query can't overwrite results from a newer one.
useEffect(() => {
const q = search.trim();
if (!q) {
setSearchResults([]);
return;
}
let cancelled = false;
const t = window.setTimeout(async () => {
if (cancelled) return;
setLoading(true);
setError(null);
try {
const res: any = await searchAction({ q, limit: 24 });
if (cancelled) return;
setSearchResults(res?.results ?? []);
} catch (err: any) {
if (cancelled) return;
setError(err?.message ?? 'Search failed.');
} finally {
setLoading(false);
if (!cancelled) setLoading(false);
}
}, 350);
return () => window.clearTimeout(t);
return () => {
cancelled = true;
window.clearTimeout(t);
};
}, [search, searchAction]);
const handlePick = (gif: GifResult) => {

View File

@@ -11,6 +11,8 @@ interface UrlPreview {
description?: string;
imageUrl?: string;
siteName?: string;
imageWidth?: number;
imageHeight?: number;
}
const VIDEO_HOSTS = [
@@ -51,6 +53,23 @@ function isVideoUrl(url: string): boolean {
// avoid hammering the fetcher for URLs that will never resolve.
const previewCache = new Map<string, UrlPreview | null>();
// Natural dimensions learned on first successful <img> onLoad for every
// embed image (OG preview image, direct image URL). Populates the
// reserved-box aspect-ratio so the next mount of the same URL goes
// straight to its real proportions instead of the fixed fallback.
const embedImageDimsCache = new Map<string, { w: number; h: number }>();
// Fallback box for an embed image whose dimensions we haven't probed
// yet. Roughly matches the common OG-image aspect ratio (~1.91:1 for
// Twitter/Facebook card images). Fixed-size fallback > fluid fallback
// because a wrong fluid ratio shifts height when the real image lands.
const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const;
// Direct inline video embeds default to 16:9 — most web video ships at
// that ratio. A wrong default just means a little blank space above or
// below the video, not a scroll jump.
const DIRECT_VIDEO_FALLBACK_RATIO = '16 / 9';
function normaliseMetadata(raw: any): UrlPreview | null {
if (!raw || typeof raw !== 'object') return null;
@@ -65,9 +84,29 @@ function normaliseMetadata(raw: any): UrlPreview | null {
raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined;
const siteName =
raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined;
// Dimensions come from the Convex action's `imageWidth`/`imageHeight`
// fields (parsed from og:image:width / og:image:height on the server).
// Fall through a few other naming conventions in case a platform-
// native fetcher emits the OG keys verbatim.
const pickNum = (v: unknown): number | undefined => {
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
if (typeof v === 'string') {
const n = Number(v);
if (Number.isFinite(n) && n > 0) return n;
}
return undefined;
};
const imageWidth =
pickNum(raw.imageWidth) ??
pickNum(raw['og:image:width']) ??
pickNum(raw.ogImageWidth);
const imageHeight =
pickNum(raw.imageHeight) ??
pickNum(raw['og:image:height']) ??
pickNum(raw.ogImageHeight);
if (!title && !description && !imageUrl) return null;
return { title, description, imageUrl, siteName };
return { title, description, imageUrl, siteName, imageWidth, imageHeight };
}
function useUrlPreview(url: string): UrlPreview | null {
@@ -79,7 +118,22 @@ function useUrlPreview(url: string): UrlPreview | null {
useEffect(() => {
if (previewCache.has(url)) {
setPreview(previewCache.get(url) ?? null);
const cached = previewCache.get(url) ?? null;
// Same cache-seeding as the fresh-fetch branch below — ensures
// the reserved image box is correct even when the preview came
// out of the module cache on a re-render.
if (
cached?.imageUrl &&
cached.imageWidth &&
cached.imageHeight &&
!embedImageDimsCache.has(cached.imageUrl)
) {
embedImageDimsCache.set(cached.imageUrl, {
w: cached.imageWidth,
h: cached.imageHeight,
});
}
setPreview(cached);
return;
}
@@ -112,6 +166,23 @@ function useUrlPreview(url: string): UrlPreview | null {
}
if (cancelled) return;
previewCache.set(url, result);
// Server-provided image dimensions populate the same
// cache the <img> onLoad handler updates — so the
// reserved box is correct on the very first paint of
// the preview card, not only after the image finishes
// decoding. Falls through to the onLoad probe if the
// server didn't have width/height tags.
if (
result?.imageUrl &&
result.imageWidth &&
result.imageHeight &&
!embedImageDimsCache.has(result.imageUrl)
) {
embedImageDimsCache.set(result.imageUrl, {
w: result.imageWidth,
h: result.imageHeight,
});
}
setPreview(result);
} catch {
if (!cancelled) previewCache.set(url, null);
@@ -146,14 +217,34 @@ function DirectMediaEmbed({
setPlaying(true);
};
// `preload="metadata"` leaves the <video> element at zero height
// until `loadedmetadata` fires — that was a measurable source of
// scroll jump. Wrap it in an aspect-ratio box so the space is
// reserved from the first paint. 16:9 is the overwhelming majority
// of web video; when the real metadata lands and differs slightly
// the ResizeObserver catches it, but the gross box is already
// there.
return (
<div className={`${styles.embed} ${styles.embedBare}`}>
<div className={styles.directVideoWrapper}>
<div
className={styles.directVideoWrapper}
style={{
aspectRatio: DIRECT_VIDEO_FALLBACK_RATIO,
width: 400,
maxWidth: '100%',
}}
>
<video
ref={videoRef}
className={styles.directVideo}
src={url}
preload="metadata"
style={{ width: '100%', height: '100%' }}
onLoadedMetadata={() => {
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);
}}
onPause={() => {
if (videoRef.current && videoRef.current.ended) {
videoRef.current.controls = false;
@@ -193,6 +284,15 @@ function DirectMediaEmbed({
);
}
// Direct image embed: reserve a box from the probed cache (or a
// fixed fallback) so the image lands in a slot of known height
// instead of expanding the wrapper from zero. `loading="lazy"` was
// here but removed — a direct-image embed is always rendered in
// view when it first mounts, and the deferred decode defeats the
// scroll anchor window we're trying to hold onto.
const probed = embedImageDimsCache.get(url) ?? EMBED_IMG_FALLBACK;
const boxW = Math.min(probed.w, 400);
const boxH = Math.round(boxW * (probed.h / probed.w));
return (
<div className={`${styles.embed} ${styles.embedBare}`}>
<a href={url} target="_blank" rel="noopener noreferrer">
@@ -200,7 +300,23 @@ function DirectMediaEmbed({
className={styles.directImage}
src={url}
alt=""
loading="lazy"
decoding="async"
width={boxW}
height={boxH}
style={{
aspectRatio: `${probed.w} / ${probed.h}`,
}}
onLoad={(e) => {
const img = e.currentTarget;
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w > 0 && h > 0) {
embedImageDimsCache.set(url, { w, h });
}
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);
}}
/>
</a>
</div>
@@ -263,33 +379,70 @@ function UrlPreviewEmbed({ url }: { url: string }) {
<div className={styles.description}>{preview.description}</div>
)}
{hasImage && (
<div className={styles.mediaContainer}>
<img
className={styles.mediaImage}
src={preview.imageUrl}
alt={preview.title || ''}
loading="lazy"
/>
{isVideo && (
<a
className={styles.mediaOverlay}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<div className={styles.mediaControls}>
<button type="button" className={styles.playButton}>
<Play size={28} weight="fill" />
</button>
<button type="button" className={styles.openButton}>
<ArrowSquareOut size={22} />
</button>
</div>
</a>
)}
</div>
)}
{hasImage && (() => {
// Reserve a box for the OG image so the card's
// final height is known before the image loads.
// Without this the card appears title-first, then
// expands downward as the image decodes — the
// classic link-preview height shift.
const imgUrl = preview.imageUrl!;
const probed =
embedImageDimsCache.get(imgUrl) ?? EMBED_IMG_FALLBACK;
return (
<div
className={styles.mediaContainer}
style={{
aspectRatio: `${probed.w} / ${probed.h}`,
// max-height matches .mediaImage CSS so
// the container never exceeds the
// image's own cap and the reserved
// space matches the rendered space.
maxHeight: 300,
width: '100%',
}}
>
<img
className={styles.mediaImage}
src={imgUrl}
alt={preview.title || ''}
decoding="async"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
onLoad={(e) => {
const img = e.currentTarget;
const w = img.naturalWidth;
const h = img.naturalHeight;
if (w > 0 && h > 0) {
embedImageDimsCache.set(imgUrl, { w, h });
}
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);
}}
/>
{isVideo && (
<a
className={styles.mediaOverlay}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<div className={styles.mediaControls}>
<button type="button" className={styles.playButton}>
<Play size={28} weight="fill" />
</button>
<button type="button" className={styles.openButton}>
<ArrowSquareOut size={22} />
</button>
</div>
</a>
)}
</div>
);
})()}
</div>
</div>
</div>

View File

@@ -16,6 +16,7 @@ import {
MemberProfilePopout,
type MemberProfilePopoutMember,
} from '../member/MemberProfilePopout';
import { DeleteConfirmationModal } from './DeleteConfirmationModal';
import { PinConfirmationModal } from './PinConfirmationModal';
import { ReactionsModal } from './ReactionsModal';
import { Tooltip } from '@discord-clone/ui';
@@ -23,6 +24,7 @@ import { reactionKeyToName } from '../../utils/emojiLookup';
import type { PinnedMessage } from './PinnedMessageRow';
import { TwemojiImg } from './TwemojiImg';
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
import { extractUrls, isGifOnlyContent } from '../../utils/messageUrls';
import styles from './MessageGroup.module.css';
interface MessageGroupProps {
@@ -31,32 +33,6 @@ interface MessageGroupProps {
onReply?: (eventId: string, username: string) => void;
}
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
function extractUrls(text: string): string[] {
const matches = text.match(URL_REGEX) ?? [];
// Strip trailing punctuation that's almost never part of the URL but
// commonly butts up against one in prose ("see https://foo.com.").
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
return Array.from(new Set(cleaned));
}
/** True when a message body is entirely made up of one or more GIF
* URLs plus whitespace — i.e. the user posted a GIF from the
* picker and there's nothing worth showing as text. The render
* path hides the <MessageContent> block in that case so only the
* embedded preview appears. */
function isGifOnlyContent(text: string): boolean {
const urls = extractUrls(text);
if (urls.length === 0) return false;
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
let remainder = text;
for (const u of urls) {
remainder = remainder.split(u).join('');
}
return remainder.trim().length === 0;
}
/**
* Discord-style relative timestamp:
* - Same calendar day → `Today at 7:08 PM`
@@ -105,7 +81,6 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
userId: m.id,
}))
: [];
const removeMessage = useMutation(api.messages.remove);
const addReaction = useMutation(api.reactions.add);
const removeReaction = useMutation(api.reactions.remove);
@@ -276,13 +251,29 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
setReactPicker(null);
};
const handleDelete = async (messageId: string) => {
if (!myUserId) return;
try {
await removeMessage({ id: messageId as any, userId: myUserId as any });
} catch (err) {
console.error('Failed to delete message:', err);
}
// Delete flows through the DeleteConfirmationModal — the old
// inline delete fired as soon as the user clicked the trash icon
// which was easy to trigger by accident on a misclick. The modal
// also holds the actual api.messageActions.remove call so the
// signing logic lives in one place.
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
preview: PinnedMessage;
} | null>(null);
const handleDelete = (messageId: string) => {
const msg = messages.find((m) => m.id === messageId);
if (!msg) return;
setDeleteTarget({
id: msg.id,
preview: {
id: msg.id,
authorName: msg.authorName,
authorAvatarUrl: msg.authorAvatarUrl,
content: msg.content,
timestamp: msg.timestamp,
attachments: msg.attachments,
},
});
};
const handleToggleReaction = async (messageId: string, emoji: string, me: boolean) => {
@@ -690,6 +681,13 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
variant={pinTarget?.variant ?? 'pin'}
/>
<DeleteConfirmationModal
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
messageId={deleteTarget?.id ?? null}
message={deleteTarget?.preview ?? null}
/>
{authorPopout && (
<MemberProfilePopout
anchorRect={authorPopout.anchorRect}

View File

@@ -50,15 +50,34 @@ export interface DecryptedMessage {
const TAG_LENGTH = 32;
// Small LRU-ish cache for decrypted messages so re-renders don't redecrypt.
// Entries are namespaced by the viewing user's id so decrypted plaintexts
// from a previous login can't bleed into a different user on the same
// page load (logout normally triggers a hard reload, but hot-reload in
// dev and rare reload failures can skip that path).
const decryptionCache = new Map<string, string>();
const MAX_CACHE = 2000;
function cacheSet(id: string, content: string) {
function namespacedKey(userId: string | null, id: string): string {
return `${userId ?? 'anon'}:${id}`;
}
function cacheSet(userId: string | null, id: string, content: string) {
const key = namespacedKey(userId, id);
if (decryptionCache.size >= MAX_CACHE) {
const firstKey = decryptionCache.keys().next().value;
if (firstKey !== undefined) decryptionCache.delete(firstKey);
}
decryptionCache.set(id, content);
decryptionCache.set(key, content);
}
function cacheGet(userId: string | null, id: string): string | undefined {
return decryptionCache.get(namespacedKey(userId, id));
}
// Exposed for the logout hook to flush plaintext from memory proactively,
// independently of the full-page reload useLogout does after this returns.
export function clearDecryptionCache(): void {
decryptionCache.clear();
}
// ── Day divider helpers ─────────────────────────────────────────────
@@ -308,6 +327,54 @@ export function Messages({ channelId, onReply }: MessagesProps) {
new Map(),
);
// Initial-load veil: the scroller starts invisible on every channel
// switch and reveals only once the last ~20 messages are decrypted
// (or a hard fallback timeout fires). The reveal moment is also
// when we perform the authoritative scroll-to-bottom — that way the
// user never sees the pre-hydration state that used to cause the
// viewport to visibly float upward as message bodies filled in.
const [ready, setReady] = useState(false);
const readyTimerRef = useRef<number | null>(null);
// Synchronous cache hydration. Runs as a layout effect so the
// setState + re-render happens before paint: warm-cache channel
// switches (the common case) render with real message text on the
// first frame instead of briefly showing empty `content: ''` bodies
// that then grow as the async effect below fills them in. That
// growth was the primary cause of the "scrolls to bottom, then
// jumps up" bug — no hydration wave, no jump.
useLayoutEffect(() => {
if (!pagedMessages || pagedMessages.length === 0) return;
let changed = false;
let next: Map<string, string> | null = null;
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (decryptedMap.has(id)) continue;
const cached = cacheGet(userId, id);
if (cached !== undefined) {
if (!next) next = new Map(decryptedMap);
next.set(id, cached);
changed = true;
}
}
if (changed && next) setDecryptedMap(next);
let replyChanged = false;
let nextReply: Map<string, string> | null = null;
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (replyPreviewMap.has(id)) continue;
if (!msg.replyToContent) continue;
const cached = cacheGet(userId, `reply:${id}`);
if (cached !== undefined) {
if (!nextReply) nextReply = new Map(replyPreviewMap);
nextReply.set(id, cached);
replyChanged = true;
}
}
if (replyChanged && nextReply) setReplyPreviewMap(nextReply);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagedMessages, userId]);
// Helper: try to scroll to whatever's in `pendingJumpRef`. If the
// target is in the DOM we scroll + flash, clear the pending ref,
// and we're done. Otherwise we kick off another paginate-up so
@@ -369,59 +436,85 @@ export function Messages({ channelId, onReply }: MessagesProps) {
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
let cancelled = false;
(async () => {
const next = new Map(decryptedMap);
let changed = false;
// Walk once, bucket each message into either a synchronous
// sentinel (invalid ciphertext, missing key) or an async
// decrypt job. The synchronous hits are applied in the same
// setState as the async results, so the UI never flashes
// through an intermediate "some decrypted, some not" state.
type Job =
| { kind: 'sentinel'; id: string; value: string }
| {
kind: 'decrypt';
id: string;
contentHex: string;
nonce: string;
tag: string;
key: string;
};
const jobs: Job[] = [];
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (next.has(id)) continue;
const cached = decryptionCache.get(id);
if (cached) {
next.set(id, cached);
changed = true;
continue;
}
if (decryptedMap.has(id)) continue;
if (cacheGet(userId, id) !== undefined) continue;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
next.set(id, '[Invalid Encrypted Message]');
changed = true;
jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' });
continue;
}
// Pick the key matching this message's version. Messages
// that predate key rotation default to version 1. If the
// exact version is missing (shouldn't happen normally),
// fall back to the latest key and try that so we never
// lock the user out of their own history.
const msgVersion: number = msg.keyVersion ?? 1;
const keyForVersion =
channelKeysByVersion.get(msgVersion) ?? channelKey;
if (!keyForVersion) {
next.set(id, '[Unable to decrypt]');
changed = true;
jobs.push({ kind: 'sentinel', id, value: '[Unable to decrypt]' });
continue;
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
keyForVersion,
msg.nonce,
tag,
);
if (cancelled) return;
cacheSet(id, plaintext);
next.set(id, plaintext);
changed = true;
} catch {
next.set(id, '[Unable to decrypt]');
changed = true;
}
jobs.push({
kind: 'decrypt',
id,
contentHex: msg.ciphertext.slice(0, -TAG_LENGTH),
tag: msg.ciphertext.slice(-TAG_LENGTH),
nonce: msg.nonce,
key: keyForVersion,
});
}
if (changed && !cancelled) setDecryptedMap(next);
if (jobs.length === 0) return;
// Fan out decrypts in parallel — 50 AES-GCM ops finish in
// one round-trip to the crypto worker instead of 50 sequential
// awaits. Collapses what used to be N setState waves (one
// after each decrypt in the original serial loop) into one.
const results = await Promise.all(
jobs.map(async (j) => {
if (j.kind === 'sentinel') {
return { id: j.id, value: j.value, cache: false };
}
try {
const plaintext = await crypto.decryptData(
j.contentHex,
j.key,
j.nonce,
j.tag,
);
return { id: j.id, value: plaintext, cache: true };
} catch {
return { id: j.id, value: '[Unable to decrypt]', cache: false };
}
}),
);
if (cancelled) return;
const next = new Map(decryptedMap);
for (const r of results) {
if (r.cache) cacheSet(userId, r.id, r.value);
next.set(r.id, r.value);
}
setDecryptedMap(next);
})();
return () => {
cancelled = true;
};
}, [pagedMessages, channelKeysByVersion, channelKey]);
// `userId` gates the cacheGet/cacheSet namespace below; without
// it a quick logout/login in the same tab would surface the
// previous user's cached plaintext under the new identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagedMessages, channelKeysByVersion, channelKey, userId]);
// Reply preview decryption — same pattern as the main loop but
// keyed by child message id and using the parent's `replyToContent`
@@ -431,11 +524,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
let cancelled = false;
(async () => {
const next = new Map(replyPreviewMap);
let changed = false;
type Job = {
id: string;
cacheKey: string;
contentHex: string;
nonce: string;
tag: string;
key: string;
};
const jobs: Job[] = [];
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (next.has(id)) continue;
if (replyPreviewMap.has(id)) continue;
if (
!msg.replyToContent ||
!msg.replyToNonce ||
@@ -444,60 +544,78 @@ export function Messages({ channelId, onReply }: MessagesProps) {
continue;
}
const cacheKey = `reply:${id}`;
const cached = decryptionCache.get(cacheKey);
if (cached) {
next.set(id, cached);
changed = true;
continue;
}
if (cacheGet(userId, cacheKey) !== undefined) continue;
const replyVersion: number = msg.replyToKeyVersion ?? 1;
const keyForVersion =
channelKeysByVersion.get(replyVersion) ?? channelKey;
if (!keyForVersion) continue;
const tag = msg.replyToContent.slice(-TAG_LENGTH);
const contentHex = msg.replyToContent.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
keyForVersion,
msg.replyToNonce,
tag,
);
if (cancelled) return;
// Strip JSON wrappers so the preview shows the
// human-readable text, not raw {"text":"…"} dumps.
let preview = plaintext;
jobs.push({
id,
cacheKey,
contentHex: msg.replyToContent.slice(0, -TAG_LENGTH),
tag: msg.replyToContent.slice(-TAG_LENGTH),
nonce: msg.replyToNonce,
key: keyForVersion,
});
}
if (jobs.length === 0) return;
// Parallel decrypt — collapses the per-message setState waves
// of the original serial loop into one, so the reply-preview
// layer can't produce a second reflow after the main body
// decryption already shifted heights.
const results = await Promise.all(
jobs.map(async (j) => {
try {
const parsed = JSON.parse(plaintext);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed)) {
preview = parsed
.filter((p: any) => p?.type === 'attachment')
.map((p: any) => p?.filename || 'attachment')
.join(', ');
} else if (parsed.type === 'attachment') {
preview = parsed.filename || parsed.mimeType || 'Attachment';
} else if (parsed.text !== undefined) {
preview = String(parsed.text);
const plaintext = await crypto.decryptData(
j.contentHex,
j.key,
j.nonce,
j.tag,
);
// Strip JSON wrappers so the preview shows the
// human-readable text, not raw {"text":"…"} dumps.
let preview = plaintext;
try {
const parsed = JSON.parse(plaintext);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed)) {
preview = parsed
.filter((p: any) => p?.type === 'attachment')
.map((p: any) => p?.filename || 'attachment')
.join(', ');
} else if (parsed.type === 'attachment') {
preview = parsed.filename || parsed.mimeType || 'Attachment';
} else if (parsed.text !== undefined) {
preview = String(parsed.text);
}
}
} catch {
/* plain text */
}
cacheSet(userId, j.cacheKey, preview);
return { id: j.id, preview };
} catch {
/* plain text */
return null;
}
cacheSet(cacheKey, preview);
next.set(id, preview);
}),
);
if (cancelled) return;
const next = new Map(replyPreviewMap);
let changed = false;
for (const r of results) {
if (r) {
next.set(r.id, r.preview);
changed = true;
} catch {
/* leave unset — UI shows the missing-context fallback */
}
}
if (changed && !cancelled) setReplyPreviewMap(next);
if (changed) setReplyPreviewMap(next);
})();
return () => {
cancelled = true;
};
// Same userId cache-namespace concern as the main loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagedMessages, channelKeysByVersion, channelKey]);
}, [pagedMessages, channelKeysByVersion, channelKey, userId]);
const decrypted: DecryptedMessage[] = useMemo(() => {
if (!pagedMessages) return [];
@@ -613,15 +731,102 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const scrollAnchorRef = useRef<{ bottomOffset: number } | null>(null);
const restoreActiveRef = useRef(false);
// On channel switch, re-pin and scroll to bottom.
// On channel switch, reset all scroll-state refs and drop the veil
// (reveal gate). The actual scroll-to-bottom is deferred to a
// separate effect that runs once the tail of the list is decrypted,
// so we pin against the final rendered height instead of the
// empty-content height that used to cause the visible jump.
useLayoutEffect(() => {
pinnedRef.current = true;
scrollAnchorRef.current = null;
restoreActiveRef.current = false;
const el = scrollerRef.current;
if (el) el.scrollTop = el.scrollHeight;
setReady(false);
if (readyTimerRef.current !== null) {
window.clearTimeout(readyTimerRef.current);
}
// Hard safety net: even if decryption stalls (missing key,
// network flake), drop the veil so the user always sees *some*
// channel state within ~400ms of switching. `[Unable to decrypt]`
// sentinels render at stable height too, so this fallback is
// safe — we aren't waiting for real text, we're waiting for
// *anything* to be populated so heights are final.
readyTimerRef.current = window.setTimeout(() => {
readyTimerRef.current = null;
setReady(true);
}, 400);
return () => {
if (readyTimerRef.current !== null) {
window.clearTimeout(readyTimerRef.current);
readyTimerRef.current = null;
}
};
}, [channelId]);
// Tail-ready detection: drop the veil once the newest ~20 messages
// (the ones visible on the first screen) have entries in
// `decryptedMap`. A message with an error sentinel (e.g.
// `[Unable to decrypt]`) counts as ready because its height is
// stable — we only care that heights won't grow after reveal.
useLayoutEffect(() => {
if (ready) return;
if (!pagedMessages) return;
const msgs = pagedMessages as any[];
if (msgs.length === 0) {
setReady(true);
return;
}
// pagedMessages is newest-first (the `decrypted` memo reverses
// it for render). The visible tail is the first ~20 entries.
const tailCount = Math.min(20, msgs.length);
for (let i = 0; i < tailCount; i++) {
if (!decryptedMap.has(msgs[i].id)) return;
}
setReady(true);
}, [ready, pagedMessages, decryptedMap]);
// Scroll-to-bottom + stubborn-bottom settle. Runs once `ready` flips
// true on the current channel. The initial rAF-delayed pin absorbs
// the reveal-frame layout; the later timeouts form a "stubborn
// bottom" window that keeps re-pinning for 500ms after reveal to
// catch late-loading content (OG images, cross-origin canvas taints
// in PausedGif, anything the observer's ResizeObserver misses
// because the placeholder and final content have identical boxes).
// The `pinnedRef` guard means a user who scrolls up during the
// settle window is never dragged back down.
useLayoutEffect(() => {
if (!ready) return;
const el = scrollerRef.current;
if (!el) return;
// Immediate pre-paint pin so the first painted frame is at the
// bottom. Everything after this is belt-and-suspenders.
el.scrollTop = el.scrollHeight;
const pin = () => {
if (!pinnedRef.current) return;
const cur = scrollerRef.current;
if (!cur) return;
restoreActiveRef.current = true;
cur.scrollTop = cur.scrollHeight;
requestAnimationFrame(() => {
restoreActiveRef.current = false;
});
};
const rafs: number[] = [];
const timers: number[] = [];
rafs.push(
requestAnimationFrame(() => {
pin();
rafs.push(requestAnimationFrame(pin));
}),
);
timers.push(window.setTimeout(pin, 80));
timers.push(window.setTimeout(pin, 250));
timers.push(window.setTimeout(pin, 500));
return () => {
rafs.forEach((h) => cancelAnimationFrame(h));
timers.forEach((h) => window.clearTimeout(h));
};
}, [ready, channelId]);
// Observe DOM mutations + resizes inside the scroller. If the user
// is pinned to bottom, snap to bottom on every content change. If
// the user triggered a paginate-up, preserve their position by
@@ -630,10 +835,14 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const el = scrollerRef.current;
if (!el) return;
const onContentChange = () => {
// Pagination anchor wins over pinned — the user is clearly
// scrolled up and reading older context, so we must NOT
// slam them to the bottom.
// Single rAF-batched handler so MutationObserver + ResizeObserver +
// window resize + attachment-loaded events all collapse into one
// scroll write per frame. Without batching, an image loading could
// fire both observers in the same tick and cause a double
// `scrollTop = scrollHeight`.
let rafHandle: number | null = null;
const runContentChange = () => {
rafHandle = null;
const anchor = scrollAnchorRef.current;
if (anchor) {
// Restore the same distance-from-bottom on every
@@ -654,6 +863,10 @@ export function Messages({ channelId, onReply }: MessagesProps) {
el.scrollTop = el.scrollHeight;
}
};
const onContentChange = () => {
if (rafHandle !== null) return;
rafHandle = requestAnimationFrame(runContentChange);
};
const mutationObs = new MutationObserver(onContentChange);
mutationObs.observe(el, { childList: true, subtree: true });
@@ -690,6 +903,10 @@ export function Messages({ channelId, onReply }: MessagesProps) {
);
return () => {
if (rafHandle !== null) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
mutationObs.disconnect();
resizeObs?.disconnect();
window.removeEventListener('resize', onWindowResize);
@@ -713,12 +930,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
pinnedRef.current = distanceFromBottom < 150;
// If the user has visibly moved away from the loader region
// (far enough down that another paginate-up can't trigger),
// clear the anchor. This lets the next near-top scroll start
// a fresh pagination cycle instead of chaining onto stale
// coords from a previous one.
if (scrollAnchorRef.current && el.scrollTop > 200) {
// Clear the anchor in two cases:
// 1. User scrolled well past the loader region (>200px) — a
// fresh near-top scroll should start a new pagination cycle.
// 2. Pagination is no longer loading-more (either CanLoadMore
// has been re-armed or we've Exhausted). Without this,
// decryption-driven reflows in the 80-200px band would keep
// firing anchor-restore and make the scroll feel sticky
// after older history loaded.
if (
scrollAnchorRef.current &&
(el.scrollTop > 200 || status !== 'LoadingMore')
) {
scrollAnchorRef.current = null;
}
@@ -726,7 +949,14 @@ export function Messages({ channelId, onReply }: MessagesProps) {
// the current distance-from-bottom into the anchor ref BEFORE
// firing loadMore so the content-change observer can pin the
// viewport to the same message the reader was looking at.
if (el.scrollTop < 80 && status === 'CanLoadMore' && !scrollAnchorRef.current) {
// Guard against duplicate loadMore calls while an earlier one
// is still in flight (`LoadingMore`) — Convex would de-dupe but
// it's wasted traffic.
if (
el.scrollTop < 80 &&
status === 'CanLoadMore' &&
!scrollAnchorRef.current
) {
scrollAnchorRef.current = {
bottomOffset: el.scrollHeight - el.scrollTop,
};
@@ -795,14 +1025,24 @@ export function Messages({ channelId, onReply }: MessagesProps) {
// seen, update the ref and schedule a debounced mark-read. The
// ref-only update doesn't cause re-renders — it just feeds the
// mark-read flush with an up-to-date target.
//
// Only advance the "seen" mark once the newest message has actually
// decrypted — otherwise the channel's unread dot + NEW divider
// clear while the body is still rendering as blank, and users miss
// new messages they never actually saw.
useEffect(() => {
if (items.length === 0) return;
const newest = items[items.length - 1].ts;
const last = items[items.length - 1];
if (last.kind === 'group') {
const tail = last.group[last.group.length - 1];
if (!tail || !decryptedMap.has(tail.id)) return;
}
const newest = last.ts;
if (newest > latestSeenTimestampRef.current) {
latestSeenTimestampRef.current = newest;
scheduleMarkRead();
}
}, [items, scheduleMarkRead]);
}, [items, decryptedMap, scheduleMarkRead]);
// Window visibility → when the tab comes back into focus, flush
// any pending mark-read so the sidebar dot disappears without
@@ -845,7 +1085,18 @@ export function Messages({ channelId, onReply }: MessagesProps) {
return (
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
<div className={styles.scroller}>
<div
className={styles.scroller}
style={{
// Veil: keep the list invisible until `ready` flips
// true (after decryption of the visible tail). The
// scroller is still laid out at opacity 0 so scroll
// math works — the user just doesn't see the pre-
// stabilised state that caused the visible jump.
opacity: ready ? 1 : 0,
transition: ready ? 'opacity 80ms linear' : 'none',
}}
>
{status === 'LoadingMore' && (
<div
style={{

View File

@@ -20,10 +20,22 @@ interface PausedGifProps {
onOpen?: (url: string) => void;
}
// Cache natural dimensions per URL so re-mounts (pagination, hover-in/out
// cycles in the parent, channel re-opens) start with the correct box
// instead of collapsing back to the fallback while the <img> re-decodes.
const gifDimsCache = new Map<string, { w: number; h: number }>();
// Fallback reservation for a GIF whose dimensions we haven't probed yet.
// Most Tenor/Giphy GIFs land in the 320480px / 4:3 range, so this keeps
// the scroll-anchor math roughly right even before the real dims arrive.
const FALLBACK_DIMS = { w: 320, h: 240 } as const;
export function PausedGif({ url, className, onOpen }: PausedGifProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = useState(false);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
const [dims, setDims] = useState<{ w: number; h: number } | null>(
() => gifDimsCache.get(url) ?? null,
);
const [hovered, setHovered] = useState(false);
// `imgKey` is bumped every time we need to re-mount the live
// <img> so the GIF starts over from frame 0 on each hover.
@@ -33,14 +45,21 @@ export function PausedGif({ url, className, onOpen }: PausedGifProps) {
useEffect(() => {
let cancelled = false;
setLoaded(false);
setDims(null);
const cached = gifDimsCache.get(url);
if (cached) {
setDims(cached);
setLoaded(false);
} else {
setLoaded(false);
setDims(null);
}
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
const w = img.naturalWidth || 400;
const h = img.naturalHeight || 300;
const w = img.naturalWidth || FALLBACK_DIMS.w;
const h = img.naturalHeight || FALLBACK_DIMS.h;
gifDimsCache.set(url, { w, h });
setDims({ w, h });
const canvas = canvasRef.current;
if (canvas) {
@@ -58,6 +77,12 @@ export function PausedGif({ url, className, onOpen }: PausedGifProps) {
}
}
setLoaded(true);
// Nudge the scroll anchor in case the fallback box differed
// from the real natural dimensions by more than a handful of
// pixels — the parent ResizeObserver catches the wrapper
// resize, but fire the shared event too so the same-frame rAF
// batching picks it up alongside other late-loading content.
window.dispatchEvent(new CustomEvent('brycord:attachment-loaded'));
};
img.onerror = () => {
if (cancelled) return;
@@ -97,11 +122,19 @@ export function PausedGif({ url, className, onOpen }: PausedGifProps) {
onOpen(url);
}
}}
style={
dims
? { aspectRatio: `${dims.w} / ${dims.h}`, maxWidth: Math.min(dims.w, 400) }
: undefined
}
style={(() => {
// Always reserve a box — if we haven't probed dimensions
// yet, fall back to 320×240 / 4:3 so the wrapper doesn't
// collapse to zero height on first paint. That collapse
// used to be the third "jump" in the initial-load sequence
// (the wrapper snapped from 0px to ~240px the moment the
// Image().onload fired).
const d = dims ?? FALLBACK_DIMS;
return {
aspectRatio: `${d.w} / ${d.h}`,
maxWidth: Math.min(d.w, 400),
};
})()}
>
<canvas
ref={canvasRef}

View File

@@ -10,10 +10,11 @@
* Calls `api.messages.setPinned` on confirm.
*/
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { useAction } from 'convex/react';
import { Modal } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { usePlatform } from '../../platform';
import { PinnedMessageRow, type PinnedMessage } from './PinnedMessageRow';
import styles from './PinConfirmationModal.module.css';
@@ -63,7 +64,8 @@ export function PinConfirmationModal({
message,
variant = 'pin',
}: PinConfirmationModalProps) {
const setPinned = useMutation(api.messages.pin);
const setPinned = useAction(api.messageActions.pin);
const { crypto } = usePlatform();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -71,12 +73,31 @@ export function PinConfirmationModal({
const handleConfirm = async () => {
if (!messageId || busy) return;
const userId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!userId || !signingKey) {
setError('Not signed in');
return;
}
setBusy(true);
setError(null);
try {
const pinned = variant === 'pin';
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`pin:${messageId}:${userId}:${pinned}:${authTimestamp}`,
);
await setPinned({
id: messageId as Id<'messages'>,
pinned: variant === 'pin',
userId: userId as Id<'userProfiles'>,
pinned,
authTimestamp,
authSignature,
});
onClose();
} catch (err: any) {

View File

@@ -12,8 +12,13 @@
* read-only preview.
*/
import { Flag, X } from '@phosphor-icons/react';
import { useQuery } from 'convex/react';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
import { LinkEmbed } from './LinkEmbed';
import { MessageContent } from './MessageContent';
import { extractUrls, isGifOnlyContent } from '../../utils/messageUrls';
import styles from './PinnedMessageRow.module.css';
export interface PinnedMessage {
@@ -41,6 +46,14 @@ interface PinnedMessageRowProps {
/** Whether the viewer has permission to unpin. Hides the X when
* they don't, even if `showHoverActions` is on. */
canUnpin?: boolean;
/**
* Render the message with the same link-embed / GIF-only handling
* the live chat row uses. Default false so the pins popover and
* pin confirmation modal stay as they are — opted in by the
* delete confirmation modal so the preview matches what the user
* sees in chat.
*/
showEmbeds?: boolean;
}
function formatTimestamp(ts: number): string {
@@ -65,7 +78,31 @@ export function PinnedMessageRow({
onUnpin,
showHoverActions = false,
canUnpin = true,
showEmbeds = false,
}: PinnedMessageRowProps) {
// When embeds are on, lift the chat's GIF-only + extract-urls
// rules so the preview matches. Up to 3 URL previews, same cap
// MessageGroup uses.
const embedUrls =
showEmbeds && message.content ? extractUrls(message.content).slice(0, 3) : [];
const hideText =
showEmbeds && message.content ? isGifOnlyContent(message.content) : false;
// Custom emoji catalog is server-wide and small, and Convex dedupes
// identical subscriptions across components — so fetching here lets
// the popover, pin confirmation, and delete confirmation all render
// `:shortcode:` tokens as images without the caller plumbing the
// list through props.
const customEmojiDocs =
(useQuery(api.customEmojis.list, {}) ?? []) as Array<{
_id: string;
name: string;
src: string;
}>;
const customEmojiList = customEmojiDocs.map((e) => ({
name: e.name,
url: e.src,
}));
const isClickable = !showHoverActions && !!onJumpTo;
const handleCardClick = () => {
@@ -137,13 +174,30 @@ export function PinnedMessageRow({
</div>
)}
</div>
{message.content ? (
<div className={styles.content}>{message.content}</div>
) : !message.attachments || message.attachments.length === 0 ? (
{message.content && !hideText ? (
<div className={styles.content}>
<MessageContent
content={message.content}
customEmojis={customEmojiList}
/>
</div>
) : !message.content &&
(!message.attachments || message.attachments.length === 0) &&
embedUrls.length === 0 ? (
<div className={`${styles.content} ${styles.undecryptable}`}>
(no preview)
</div>
) : null}
{embedUrls.length > 0 && (
<div
className={styles.embeds}
onClick={(e) => e.stopPropagation()}
>
{embedUrls.map((url, i) => (
<LinkEmbed key={`${message.id}-embed-${i}`} url={url} />
))}
</div>
)}
{message.attachments && message.attachments.length > 0 && (
<div
className={styles.attachments}

View File

@@ -30,11 +30,21 @@ export function TwemojiImg({ emoji, size = 22, className }: TwemojiImgProps) {
alt={emoji}
width={size}
height={size}
loading="lazy"
// No `loading="lazy"` — deferred decode of a 22×22 CDN image
// produces exactly the per-emoji reflow this app is tuned to
// avoid. Eager load lets the browser pipeline them naturally.
decoding="async"
draggable={false}
className={className}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
onError={() => setBroken(true)}
onLoad={() => {
// Each emoji's decode can nudge line height by a fraction
// of a pixel (subpixel rounding), which the ResizeObserver
// sometimes misses. Fire the shared attachment-loaded event
// so Messages re-pins if the user is anchored to bottom.
window.dispatchEvent(new CustomEvent('brycord:attachment-loaded'));
}}
data-emoji-codepoint={emojiToCodepoint(emoji)}
/>
);

View File

@@ -8,11 +8,12 @@
* subscribers update automatically once the mutation completes.
*/
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { useAction, useQuery } from 'convex/react';
import { Check, X } from '@phosphor-icons/react';
import { BottomSheet } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { usePlatform } from '../../platform';
import styles from './MobileSetStatusSheet.module.css';
export type UiPresence = 'online' | 'idle' | 'dnd' | 'invisible';
@@ -59,7 +60,8 @@ export function MobileSetStatusSheet({ isOpen, onClose }: MobileSetStatusSheetPr
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
const me = allUsers.find((u) => u.id === userId);
const current = (me?.status ?? 'online') as UiPresence;
const updateStatus = useMutation(api.auth.updateStatus);
const { crypto } = usePlatform();
const updateStatus = useAction(api.authActions.updateStatus);
const [busy, setBusy] = useState<UiPresence | null>(null);
const handleSelect = async (next: UiPresence) => {
@@ -68,9 +70,24 @@ export function MobileSetStatusSheet({ isOpen, onClose }: MobileSetStatusSheetPr
onClose();
return;
}
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) return;
setBusy(next);
try {
await updateStatus({ userId: userId as Id<'userProfiles'>, status: next });
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`updateStatus:${userId}:${next}:${authTimestamp}`,
);
await updateStatus({
userId: userId as Id<'userProfiles'>,
status: next,
authTimestamp,
authSignature,
});
onClose();
} catch (err) {
console.warn('Failed to set presence:', err);

View File

@@ -17,7 +17,7 @@
*/
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMutation, useQuery } from 'convex/react';
import { useAction, useQuery } from 'convex/react';
import {
Circle,
Moon,
@@ -30,6 +30,7 @@ import {
} from '@phosphor-icons/react';
import { Avatar, Button } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import styles from './UserAreaProfilePopout.module.css';
type StatusId = 'online' | 'idle' | 'dnd' | 'invisible';
@@ -141,7 +142,8 @@ export function UserAreaProfilePopout({
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
const me = allUsers.find((u) => u.id === userId);
const updateStatus = useMutation(api.auth.updateStatus);
const { crypto } = usePlatform();
const updateStatus = useAction(api.authActions.updateStatus);
// Close on click-outside / Escape.
useEffect(() => {
@@ -188,8 +190,26 @@ export function UserAreaProfilePopout({
};
const handleStatusSelect = async (status: StatusId) => {
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) {
setStatusPickerOpen(false);
return;
}
try {
await updateStatus({ userId: userId as any, status });
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`updateStatus:${userId}:${status}:${authTimestamp}`,
);
await updateStatus({
userId: userId as any,
status,
authTimestamp,
authSignature,
});
} catch (err) {
console.error('Failed to update status:', err);
}

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { useAction, useMutation, useQuery } from 'convex/react';
import { Avatar, Button, Modal } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { usePlatform } from '../../platform';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useOnlineUsers } from '../../contexts/PresenceContext';
import { MobileMemberProfileSheet } from './MobileMemberProfileSheet';
@@ -122,7 +123,8 @@ export function MemberListContainer({ channelId, variant = 'panel' }: MemberList
setContextMenu({ x: e.clientX, y: e.clientY, member });
};
const setNickname = useMutation(api.auth.setNickname);
const setNickname = useAction(api.authActions.setNickname);
const { crypto } = usePlatform();
const openNicknameEditor = (member: MemberRow) => {
setNicknameTarget(member);
@@ -147,13 +149,28 @@ export function MemberListContainer({ channelId, variant = 'panel' }: MemberList
? localStorage.getItem('userId')
: null;
if (!localUserId) return;
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) {
setNicknameError('Not signed in');
return;
}
setNicknameSaving(true);
setNicknameError(null);
try {
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`setNickname:${localUserId}:${nicknameTarget.userId}:${authTimestamp}`,
);
await setNickname({
actorUserId: localUserId as any,
targetUserId: nicknameTarget.userId as any,
displayName: nicknameDraft.trim(),
authTimestamp,
authSignature,
});
setNicknameTarget(null);
} catch (err: any) {

View File

@@ -1,4 +1,4 @@
import { useMutation, useQuery } from 'convex/react';
import { useAction, useMutation, useQuery } from 'convex/react';
import {
Bell,
Keyboard,
@@ -171,7 +171,30 @@ export function AccountTab() {
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
const me = allUsers.find((u) => u.id === userId);
const updateProfile = useMutation(api.auth.updateProfile);
const { crypto } = usePlatform();
const updateProfileAction = useAction(api.authActions.updateProfile);
// Wraps the signed action so the three call sites below stay readable.
// Signs `updateProfile:${userId}:${authTimestamp}` with the session's
// Ed25519 key so the server can prove the caller controls `userId`.
const updateProfile = async (
patch: Record<string, unknown> & { userId: string },
) => {
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) throw new Error('Not signed in');
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`updateProfile:${patch.userId}:${authTimestamp}`,
);
return updateProfileAction({
...patch,
authTimestamp,
authSignature,
} as any);
};
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const validateUpload = useMutation(api.files.validateUpload);

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Room, RoomEvent, VideoPresets, ConnectionQuality, DisconnectReason } from 'livekit-client';
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { useQuery, useConvex } from 'convex/react';
@@ -99,13 +99,42 @@ export const VoiceProvider = ({ children }) => {
const convex = useConvex();
// Single source of truth for the signed-in user id. All the
// voice/presence effects below used to read localStorage
// directly — that worked but wasn't reactive, so a
// logout-then-login in the same tab could leave effects
// operating on a stale id until something else triggered a
// rerender. The `storage` and `brycord:auth-change` listeners
// keep this state in sync across tabs (the former) and
// within-tab login/logout (the latter, emitted by
// hooks/useLogout + the login page).
const [myUserId, setMyUserId] = useState(
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null,
);
useEffect(() => {
if (typeof window === 'undefined') return;
const sync = () => {
setMyUserId(
typeof localStorage !== 'undefined'
? localStorage.getItem('userId')
: null,
);
};
window.addEventListener('storage', sync);
window.addEventListener('brycord:auth-change', sync);
return () => {
window.removeEventListener('storage', sync);
window.removeEventListener('brycord:auth-change', sync);
};
}, []);
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
const setWatchingStreamOf = useCallback((identity) => {
setWatchingStreamOfRaw(identity);
// Sync to backend
const userId = localStorage.getItem('userId');
const userId = myUserId;
if (userId) {
convex.mutation(api.voiceState.setWatchingStream, {
userId,
@@ -120,7 +149,7 @@ export const VoiceProvider = ({ children }) => {
const clearWatchingStream = useCallback(() => {
setWatchingStreamOfRaw(null);
const userId = localStorage.getItem('userId');
const userId = myUserId;
if (userId) {
convex.mutation(api.voiceState.setWatchingStream, { userId }).catch(
e => console.error('Failed to clear watching stream:', e)
@@ -205,7 +234,7 @@ export const VoiceProvider = ({ children }) => {
const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
const serverMute = async (targetUserId, isServerMuted) => {
const actorUserId = localStorage.getItem('userId');
const actorUserId = myUserId;
if (!actorUserId) return;
try {
await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted });
@@ -215,7 +244,7 @@ export const VoiceProvider = ({ children }) => {
};
const disconnectUser = async (targetUserId) => {
const actorUserId = localStorage.getItem('userId');
const actorUserId = myUserId;
if (!actorUserId) return;
try {
await convex.mutation(api.voiceState.disconnectUser, { actorUserId, targetUserId });
@@ -236,7 +265,6 @@ export const VoiceProvider = ({ children }) => {
const serverSettings = useQuery(api.serverSettings.get);
// Subscribe to own join sound URL for self-join playback
const myUserId = localStorage.getItem('userId');
const myJoinSoundUrl = useQuery(
api.auth.getMyJoinSoundUrl,
myUserId ? { userId: myUserId } : "skip"
@@ -248,7 +276,7 @@ export const VoiceProvider = ({ children }) => {
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
async function updateVoiceState(fields) {
const userId = localStorage.getItem('userId');
const userId = myUserId;
if (!userId || !activeChannelId) return;
try {
await convex.mutation(api.voiceState.updateState, { userId, ...fields });
@@ -280,12 +308,36 @@ export const VoiceProvider = ({ children }) => {
return;
}
const { token: lkToken } = await convex.action(api.voice.getToken, {
// Prove we control `userId` by signing the (userId, channelId,
// timestamp) tuple with the Ed25519 key decrypted at login.
// The server verifies with our public signing key before minting
// a LiveKit JWT, so voice rooms can't be joined by forging args.
const signingKey = sessionStorage.getItem('signingKey');
if (!signingKey) {
console.error('Missing signing key — cannot authorize voice join');
setConnectionState('error');
setActiveChannelId(null);
return;
}
const timestamp = Date.now();
const message = `voice-token:${userId}:${channelId}:${timestamp}`;
const signature = await platform.crypto.signMessage(signingKey, message);
const tokenResult = await convex.action(api.voice.getToken, {
channelId,
userId,
username: localStorage.getItem('username') || 'Unknown'
timestamp,
signature,
});
if ('error' in tokenResult) {
console.error('Voice token rejected:', tokenResult.error);
setConnectionState('error');
setActiveChannelId(null);
return;
}
const lkToken = tokenResult.token;
if (!lkToken) throw new Error('Failed to get token');
setToken(lkToken);
@@ -515,7 +567,7 @@ export const VoiceProvider = ({ children }) => {
// Heartbeat: send periodic heartbeat to prevent ghost voice states
useEffect(() => {
if (!activeChannelId) return;
const userId = localStorage.getItem('userId');
const userId = myUserId;
if (!userId) return;
const sendHeartbeat = () => {
@@ -530,10 +582,17 @@ export const VoiceProvider = ({ children }) => {
return () => clearInterval(interval);
}, [activeChannelId, convex]);
// Handle notification action buttons (Android foreground service)
// Handle notification action buttons (Android foreground service).
// Capacitor's plugin API has historically shifted between returning
// a listener handle synchronously and returning a Promise, so we
// normalize both shapes into the same cleanup code instead of
// leaving a silent no-op when neither matches.
useEffect(() => {
if (!voiceService) return;
const listener = voiceService.addNotificationActionListener((event) => {
let cancelled = false;
let resolvedHandle = null;
const handle = voiceService.addNotificationActionListener((event) => {
switch (event.action) {
case 'disconnect':
disconnectVoice();
@@ -546,17 +605,43 @@ export const VoiceProvider = ({ children }) => {
break;
}
});
if (handle && typeof handle.then === 'function') {
handle.then((l) => {
if (cancelled) {
l?.remove?.();
} else {
resolvedHandle = l;
}
}).catch(() => { /* nothing to clean up */ });
} else {
resolvedHandle = handle;
}
return () => {
if (listener && listener.remove) listener.remove();
else if (listener && typeof listener.then === 'function') {
listener.then(l => l?.remove?.());
cancelled = true;
try {
resolvedHandle?.remove?.();
} catch (e) {
console.warn('Failed to remove notification listener:', e);
}
};
}, [voiceService, activeChannelId]);
// Detect when another user moves us to a different voice channel
// Detect when another user moves us to a different voice channel.
//
// `connectToVoice` is a plain function (not useCallback), so listing
// it as a dep would re-run this effect on every render — an infinite
// reconnect loop. We stash the latest reference in a ref so the
// effect can call the freshest copy without triggering itself. The
// audit flagged the old setup for potentially using stale
// credentials if the move fired mid-render; the ref closes that gap.
const connectToVoiceRef = useRef(connectToVoice);
useEffect(() => {
connectToVoiceRef.current = connectToVoice;
});
useEffect(() => {
const myUserId = localStorage.getItem('userId');
if (!myUserId || !activeChannelId || isMovingRef.current) return;
// Find which channel the server says we're in
@@ -571,11 +656,12 @@ export const VoiceProvider = ({ children }) => {
// If server says we're in a different channel, reconnect
if (serverChannelId && serverChannelId !== activeChannelId) {
isMovingRef.current = true;
const currentRoom = room;
(async () => {
try {
const channel = await convex.query(api.channels.get, { id: serverChannelId });
if (room) await room.disconnect();
await connectToVoice(serverChannelId, channel?.name || 'Voice', myUserId);
if (currentRoom) await currentRoom.disconnect();
await connectToVoiceRef.current(serverChannelId, channel?.name || 'Voice', myUserId);
} catch (e) {
console.error('Failed to reconnect after move:', e);
} finally {
@@ -583,18 +669,17 @@ export const VoiceProvider = ({ children }) => {
}
})();
}
}, [voiceStates, activeChannelId]);
}, [voiceStates, activeChannelId, room, convex, myUserId]);
// Enforce server mute: force-disable mic when server muted, restore when lifted
useEffect(() => {
const myUserId = localStorage.getItem('userId');
if (!myUserId || !room) return;
if (isServerMuted(myUserId)) {
room.localParticipant.setMicrophoneEnabled(false);
} else if (!isMuted && !isDeafened) {
room.localParticipant.setMicrophoneEnabled(true);
}
}, [voiceStates, room]);
}, [voiceStates, room, myUserId]);
// Re-apply personal mutes/volumes when room or participants change
useEffect(() => {
@@ -637,12 +722,25 @@ export const VoiceProvider = ({ children }) => {
}
if (idleSeconds >= afkTimeout) {
const userId = localStorage.getItem('userId');
const userId = myUserId;
if (!userId) return;
// On Capacitor, also set user status to idle
if (isCapacitor) {
await convex.mutation(api.auth.updateStatus, { userId, status: 'idle' });
const signingKey = sessionStorage.getItem('signingKey');
if (signingKey) {
const authTimestamp = Date.now();
const authSignature = await platform.crypto.signMessage(
signingKey,
`updateStatus:${userId}:idle:${authTimestamp}`,
);
await convex.action(api.authActions.updateStatus, {
userId,
status: 'idle',
authTimestamp,
authSignature,
});
}
}
await convex.mutation(api.voiceState.afkMove, {
@@ -670,7 +768,7 @@ export const VoiceProvider = ({ children }) => {
return;
}
const selfId = localStorage.getItem('userId');
const selfId = myUserId;
const channelUsers = voiceStates[activeChannelId] || [];
const currentUserIds = new Set(channelUsers.map(u => u.userId));
@@ -702,7 +800,7 @@ export const VoiceProvider = ({ children }) => {
}
prevChannelUsersRef.current = currentUserIds;
}, [voiceStates, activeChannelId]);
}, [voiceStates, activeChannelId, myUserId]);
// Manage screen share subscriptions — only subscribe when actively watching
useEffect(() => {
@@ -794,7 +892,6 @@ export const VoiceProvider = ({ children }) => {
return;
}
const myUserId = localStorage.getItem('userId');
// Collect all users currently watching the same stream
const currentViewers = new Set();
for (const users of Object.values(voiceStates)) {
@@ -831,7 +928,7 @@ export const VoiceProvider = ({ children }) => {
}
prevViewersRef.current = currentViewers;
}, [voiceStates, watchingStreamOf]);
}, [voiceStates, watchingStreamOf, myUserId]);
// Detect screen-share publications starting / stopping across the
// active voice channel (including the local user) and play a
@@ -884,29 +981,54 @@ export const VoiceProvider = ({ children }) => {
};
const toggleMute = async () => {
const myUserId = localStorage.getItem('userId');
// Block unmute if server muted or in AFK channel
if (isMuted && myUserId && isServerMuted(myUserId)) return;
if (isMuted && isInAfkChannel) return;
const nextState = !isMuted;
// Flip LiveKit first. If this rejects we bail before committing any
// UI state — otherwise the user sees "muted" while their mic is
// still publishing to everyone in the room (a privacy leak, not
// just a UX nit).
if (room) {
try {
await room.localParticipant.setMicrophoneEnabled(!nextState);
} catch (e) {
console.error('Failed to toggle microphone:', e);
return;
}
}
setIsMuted(nextState);
playSound(nextState ? 'mute' : 'unmute');
voiceService?.updateNotification({ isMuted: nextState });
if (room) {
room.localParticipant.setMicrophoneEnabled(!nextState);
try {
await updateVoiceState({ isMuted: nextState });
} catch (e) {
// LiveKit is already in the right state, so audio is safe;
// the server's voice-states row is just stale. Other clients
// will pick up the correct value from our next successful
// mutation or heartbeat. Log and move on.
console.error('Failed to sync mute state to server:', e);
}
await updateVoiceState({ isMuted: nextState });
};
const toggleDeafen = async () => {
const nextState = !isDeafened;
if (room && !isMuted) {
try {
await room.localParticipant.setMicrophoneEnabled(!nextState);
} catch (e) {
console.error('Failed to toggle microphone for deafen:', e);
return;
}
}
setIsDeafened(nextState);
playSound(nextState ? 'deafen' : 'undeafen');
voiceService?.updateNotification({ isDeafened: nextState });
if (room && !isMuted) {
room.localParticipant.setMicrophoneEnabled(!nextState);
try {
await updateVoiceState({ isDeafened: nextState });
} catch (e) {
console.error('Failed to sync deafen state to server:', e);
}
await updateVoiceState({ isDeafened: nextState });
};
// Actually flip the LiveKit screen-share publication on/off. The
@@ -917,15 +1039,32 @@ export const VoiceProvider = ({ children }) => {
// resulting track, and tears it down on false.
const setScreenSharing = async (active) => {
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);
}
}
});
}
}
try {
await room.localParticipant.setScreenShareEnabled(active, {
audio: true,
});
} catch (e) {
console.warn('Failed to toggle screen share:', e);
// User cancelled the picker or permission was denied —
// keep local state in sync with whatever actually happened
// on the LiveKit side.
const published = !!room.localParticipant.getTrackPublication?.(
'screen_share',
);
@@ -933,6 +1072,11 @@ export const VoiceProvider = ({ children }) => {
await updateVoiceState({ isScreenSharing: published });
return;
}
if (!active) {
for (const mst of toStop) {
try { mst.stop(); } catch { /* already stopped */ }
}
}
setIsScreenSharingLocal(active);
await updateVoiceState({ isScreenSharing: active });
};
@@ -1054,54 +1198,109 @@ export const VoiceProvider = ({ children }) => {
}, [room, stopRecording]);
// Stable callback so the Provider value doesn't churn on a fresh arrow
// function every render.
const clearRecordingError = useCallback(() => setRecordingError(null), []);
// Memoize the provider value so components that subscribe via
// `useVoice()` don't re-render on every parent render. Inline object
// literals caused every voice-aware component (sidebar, chat header,
// user tiles, voice bar) to re-render whenever *anything* upstream
// changed — a huge perf hit during active voice sessions.
const value = useMemo(() => ({
activeChannelId,
activeChannelName,
connectionState,
connectToVoice,
disconnectVoice,
room,
token,
voiceStates,
activeSpeakers,
isMuted,
isDeafened,
toggleMute,
toggleDeafen,
isScreenSharing,
setScreenSharing,
isCameraOn,
setCamera,
toggleCamera,
personallyMutedUsers,
togglePersonalMute,
isPersonallyMuted,
userVolumes,
setUserVolume,
getUserVolume,
serverMute,
disconnectUser,
isServerMuted,
isInAfkChannel,
serverSettings,
watchingStreamOf,
setWatchingStreamOf,
switchDevice,
globalOutputVolume,
setGlobalOutputVolume,
isReceivingScreenShareAudio,
isReconnecting,
connectionQualities,
isRecording,
recordingStartedAt,
recordingSessionId,
recordingError,
startRecording,
stopRecording,
clearRecordingError,
}), [
activeChannelId,
activeChannelName,
connectionState,
connectToVoice,
disconnectVoice,
room,
token,
voiceStates,
activeSpeakers,
isMuted,
isDeafened,
toggleMute,
toggleDeafen,
isScreenSharing,
setScreenSharing,
isCameraOn,
setCamera,
toggleCamera,
personallyMutedUsers,
togglePersonalMute,
isPersonallyMuted,
userVolumes,
setUserVolume,
getUserVolume,
serverMute,
disconnectUser,
isServerMuted,
isInAfkChannel,
serverSettings,
watchingStreamOf,
setWatchingStreamOf,
switchDevice,
globalOutputVolume,
setGlobalOutputVolume,
isReceivingScreenShareAudio,
isReconnecting,
connectionQualities,
isRecording,
recordingStartedAt,
recordingSessionId,
recordingError,
startRecording,
stopRecording,
clearRecordingError,
]);
return (
<VoiceContext.Provider value={{
activeChannelId,
activeChannelName,
connectionState,
connectToVoice,
disconnectVoice,
room,
token,
voiceStates,
activeSpeakers,
isMuted,
isDeafened,
toggleMute,
toggleDeafen,
isScreenSharing,
setScreenSharing,
isCameraOn,
setCamera,
toggleCamera,
personallyMutedUsers,
togglePersonalMute,
isPersonallyMuted,
userVolumes,
setUserVolume,
getUserVolume,
serverMute,
disconnectUser,
isServerMuted,
isInAfkChannel,
serverSettings,
watchingStreamOf,
setWatchingStreamOf,
switchDevice,
globalOutputVolume,
setGlobalOutputVolume,
isReceivingScreenShareAudio,
isReconnecting,
connectionQualities,
// Voice recording
isRecording,
recordingStartedAt,
recordingSessionId,
recordingError,
startRecording,
stopRecording,
clearRecordingError: () => setRecordingError(null),
}}>
<VoiceContext.Provider value={value}>
{children}
{room && (
<LiveKitRoom

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { usePlatform } from '../platform';
import { clearDecryptionCache } from '../components/channel/Messages';
/**
* useLogout — wipes all client-side auth state and hard-reloads the
@@ -16,6 +17,16 @@ export function useLogout(): () => Promise<void> {
const { session } = usePlatform();
return useCallback(async () => {
// Flush in-memory plaintext before anything else so a failure in a
// later step (e.g. platform session clear throws, reload never
// fires) still wipes decrypted message content from this tab's
// heap. The hard reload below normally covers this already.
try {
clearDecryptionCache();
} catch (err) {
console.warn('Failed to clear decryption cache:', err);
}
try {
await session?.clear?.();
} catch (err) {

View File

@@ -5,9 +5,9 @@
* @property {(data: string) => Promise<string>} sha256 - Returns hex hash
* @property {(privateKey: string, message: string) => Promise<string>} signMessage
* @property {(publicKey: string, message: string, signature: string) => Promise<boolean>} verifySignature
* @property {(password: string, salt: string) => Promise<{dek: string, dak: string}>} deriveAuthKeys
* @property {(data: string, key: string) => Promise<{content: string, iv: string, tag: string}>} encryptData
* @property {(encryptedData: string, key: string, iv: string, tag: string, options?: object) => Promise<string>} decryptData
* @property {(password: string, salt: string) => Promise<{dek: Uint8Array, dak: string}>} deriveAuthKeys - `dak` is a hex string, `dek` is raw bytes (Uint8Array on web, Buffer on Electron — both accepted by encryptData)
* @property {(data: string, key: string | Uint8Array) => Promise<{content: string, iv: string, tag: string}>} encryptData
* @property {(encryptedData: string, key: string | Uint8Array, iv: string, tag: string, options?: object) => Promise<string>} decryptData
* @property {(items: Array) => Promise<Array>} decryptBatch
* @property {(items: Array) => Promise<Array>} verifyBatch
* @property {(publicKey: string, data: string) => Promise<string>} publicEncrypt

View File

@@ -0,0 +1,33 @@
/**
* Message URL helpers shared between the live chat row
* (`MessageGroup`) and the confirmation-modal preview card
* (`PinnedMessageRow`'s `showEmbeds` mode). Keeping them here means
* the "which URLs count as embed-worthy" rule stays consistent
* wherever a message is rendered.
*/
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
/** All distinct http(s) URLs in `text`, with trailing punctuation
* like `),.;!?` stripped — those almost never belong to the URL
* but commonly butt up against one in prose. */
export function extractUrls(text: string): string[] {
const matches = text.match(URL_REGEX) ?? [];
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
return Array.from(new Set(cleaned));
}
/** True when a message body is entirely made up of one or more GIF
* URLs plus whitespace — i.e. the user posted a GIF from the
* picker and there's nothing worth showing as text. Callers hide
* the text block in that case so only the embed renders. */
export function isGifOnlyContent(text: string): boolean {
const urls = extractUrls(text);
if (urls.length === 0) return false;
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
let remainder = text;
for (const u of urls) {
remainder = remainder.split(u).join('');
}
return remainder.trim().length === 0;
}

View File

@@ -1,27 +1,90 @@
// In-memory preferences cache per userId. Sitting in front of localStorage
// prevents the read-modify-write race that used to lose settings when two
// rapid calls both read the same stale blob and one overwrote the other.
// Writes now merge into this cache and flush synchronously to localStorage.
const memoryCache = new Map();
function storageKey(userId) {
return `userPrefs_${userId}`;
}
function loadFromStorage(userId) {
try {
const raw = localStorage.getItem(storageKey(userId));
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function getPrefs(userId) {
const cached = memoryCache.get(userId);
if (cached) return cached;
const fresh = loadFromStorage(userId);
memoryCache.set(userId, fresh);
return fresh;
}
export function getUserPref(userId, key, defaultValue) {
if (!userId) return defaultValue;
try {
const raw = localStorage.getItem(`userPrefs_${userId}`);
if (!raw) return defaultValue;
const prefs = JSON.parse(raw);
return prefs[key] !== undefined ? prefs[key] : defaultValue;
} catch {
return defaultValue;
}
const prefs = getPrefs(userId);
return prefs[key] !== undefined ? prefs[key] : defaultValue;
}
export function setUserPref(userId, key, value, settings) {
if (!userId) return;
// Mutate the in-memory copy first so a concurrent setUserPref reads
// our update instead of the stale localStorage blob. Flushing to
// localStorage happens after so a QuotaExceededError doesn't roll
// back the in-memory state.
const prefs = getPrefs(userId);
prefs[key] = value;
try {
const raw = localStorage.getItem(`userPrefs_${userId}`);
const prefs = raw ? JSON.parse(raw) : {};
prefs[key] = value;
localStorage.setItem(`userPrefs_${userId}`, JSON.stringify(prefs));
// Also persist to disk via platform settings (fire-and-forget)
if (settings) {
settings.set(`userPrefs_${userId}`, prefs);
}
localStorage.setItem(storageKey(userId), JSON.stringify(prefs));
} catch {
// Silently fail on corrupt data or full storage
// Quota / serialization failure — the in-memory cache still has
// the new value so this session stays consistent. Settings persist
// fallback handles disk.
}
if (settings) {
// Fire-and-forget disk persistence via platform settings. Clone
// the blob so the platform layer can't mutate our cached ref.
// `settings.set` can reject on quota overflow — swallow here so
// an unhandled rejection doesn't pollute the console, but let the
// localStorage write above still take effect.
try {
const p = settings.set(storageKey(userId), { ...prefs });
if (p && typeof p.catch === 'function') {
p.catch(() => { /* platform persistence is best-effort */ });
}
} catch {
/* platform settings sync failure is non-fatal */
}
}
}
// Cross-tab sync: another tab writing to the same userPrefs_<userId>
// fires a `storage` event here. Drop the stale cache entry so the next
// read picks up the freshly-written value.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (!e.key || !e.key.startsWith('userPrefs_')) return;
const userId = e.key.slice('userPrefs_'.length);
if (e.newValue === null) {
memoryCache.delete(userId);
return;
}
try {
const parsed = JSON.parse(e.newValue);
if (parsed && typeof parsed === 'object') {
memoryCache.set(userId, parsed);
} else {
memoryCache.delete(userId);
}
} catch {
memoryCache.delete(userId);
}
});
}