This commit is contained in:
Bryan1029384756
2026-04-18 15:41:51 -05:00
parent 938df217f4
commit 593eaba82e
47 changed files with 3539 additions and 212 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "@discord-clone/shared",
"private": true,
"version": "1.1.2",
"version": "1.1.3",
"type": "module",
"main": "src/App.tsx",
"dependencies": {

View File

@@ -109,8 +109,26 @@ export function AttachmentVideo({
useEffect(() => {
const el = videoRef.current;
if (!el) return;
// Android WebView won't paint any frame for a `<video preload="metadata">`
// backed by a blob URL — the `#t=0.1` media-fragment trick used by
// LinkEmbed doesn't apply to blob URLs. Programmatically seeking to
// 0.1s does the same thing cross-browser: it triggers a decode of
// that frame so the element renders a pseudo-poster instead of a
// black rectangle. Gated on `paused && currentTime === 0` so a user
// who hit play before metadata arrived isn't yanked forward.
let seeded = false;
const onTime = () => setCurrentTime(el.currentTime);
const onDur = () => setDuration(el.duration);
const onDur = () => {
setDuration(el.duration);
if (!seeded && el.paused && el.currentTime === 0) {
seeded = true;
try {
el.currentTime = 0.1;
} catch {
/* some browsers reject the assignment pre-ready — safe to ignore */
}
}
};
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => setIsPlaying(false);
@@ -147,6 +165,15 @@ export function AttachmentVideo({
const handleStartPlay = useCallback(() => {
const el = videoRef.current;
if (!el) return;
// If we seeded currentTime to 0.1s as an Android pseudo-poster,
// rewind so playback starts at the real beginning.
if (el.currentTime > 0 && el.currentTime < 0.2) {
try {
el.currentTime = 0;
} catch {
/* ignore — falling through to play() is fine */
}
}
setHasStarted(true);
void el.play().catch(() => {});
}, []);

View File

@@ -4,10 +4,13 @@ import {
ChartBar,
Gif,
ImageSquare,
Lock,
Microphone,
Paperclip,
PlusCircle,
Smiley,
Sticker,
Trash,
X,
} from '@phosphor-icons/react';
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
@@ -110,6 +113,52 @@ export function ChannelTextarea({
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const mentionRef = useRef<MentionAutocompleteHandle>(null);
// Voice-message recording state. While `isRecording` is true the
// button row swaps into a cancel/stop surface. The MediaRecorder
// writes chunks into `recordingChunksRef`; on stop we assemble
// them into a single File and stage it as a regular attachment —
// the receiver's existing `AttachmentAudio` renderer takes it
// from there. Never persists past the component lifetime.
const [isRecording, setIsRecording] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [recordingError, setRecordingError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const micStreamRef = useRef<MediaStream | null>(null);
const recordingChunksRef = useRef<BlobPart[]>([]);
const recordingMimeRef = useRef<string>('audio/webm');
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// `pendingOutcomeRef.current` is 'send' or 'cancel', captured by
// the click handler and read inside the MediaRecorder 'stop'
// event so the handler can decide whether to stage the file.
const pendingOutcomeRef = useRef<'send' | 'cancel' | null>(null);
// Hold-to-record state (mobile). `isHoldingRef` is set from the
// pointer-down handler and cleared on up / cancel / leave — the
// async `startVoiceRecording` re-checks it after the mic permission
// resolves so a release during the permission prompt cancels cleanly.
// `recordingStartedAtRef` gates the release so a quick tap (<1s)
// doesn't post empty audio.
const isHoldingRef = useRef(false);
const recordingStartedAtRef = useRef(0);
const HOLD_SEND_THRESHOLD_MS = 1000;
// Drag-up-to-lock: once the pointer moves beyond the threshold
// above its start position, we commit to a locked recording
// (composer swaps to the recording bar) and stop tracking the
// hold — release is then via the bar's send/cancel buttons.
const dragStartYRef = useRef<number | null>(null);
const LOCK_DRAG_THRESHOLD_PX = 60;
const [isLocked, setIsLocked] = useState(false);
// Waveform samples driven by an AnalyserNode tapped off the same
// MediaStream we hand to MediaRecorder. Values are 0..1 RMS.
const [waveformLevels, setWaveformLevels] = useState<number[]>([]);
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const sampleIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const WAVEFORM_MAX_SAMPLES = 40;
// Full sample history kept in a ref so React doesn't churn on every
// 80ms tick. Used to compute the final `peaks` array we ship in the
// voice-message metadata (downsampled to a fixed bar count on send).
const allLevelsRef = useRef<number[]>([]);
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const keybinds = useKeybinds();
const username =
@@ -560,7 +609,10 @@ export function ChannelTextarea({
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
};
const uploadOneFile = async (file: File) => {
const uploadOneFile = async (
file: File,
extra?: Record<string, unknown>,
) => {
// 1. Encrypt the file with a fresh per-file AES key.
const fileKey = await crypto.randomBytes(32);
const buf = new Uint8Array(await file.arrayBuffer());
@@ -596,10 +648,235 @@ export function ChannelTextarea({
key: fileKey,
iv: encrypted.iv,
...(dims && { width: dims.width, height: dims.height }),
...(extra || {}),
};
await sendAttachmentMessage(metadata);
};
/**
* Voice messages — records a short clip via MediaRecorder and
* stages it as a regular audio attachment on stop. No new backend
* plumbing: the blob rides the existing encrypt + upload path,
* and the receiver renders it through `AttachmentAudio` like any
* other `audio/*` file. Permission prompt is synchronous with the
* button press so browsers surface the prompt on a user gesture.
*/
function pickRecorderMime(): string {
if (typeof MediaRecorder === 'undefined') return '';
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
'audio/mp4',
];
for (const c of candidates) {
if (MediaRecorder.isTypeSupported(c)) return c;
}
return '';
}
const stopRecordingTimer = () => {
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
};
const stopMicStream = () => {
if (micStreamRef.current) {
for (const track of micStreamRef.current.getTracks()) {
try { track.stop(); } catch {}
}
micStreamRef.current = null;
}
};
const stopWaveformAnalyser = () => {
if (sampleIntervalRef.current) {
clearInterval(sampleIntervalRef.current);
sampleIntervalRef.current = null;
}
if (audioCtxRef.current) {
try { void audioCtxRef.current.close(); } catch {}
audioCtxRef.current = null;
}
analyserRef.current = null;
};
const startWaveformAnalyser = (stream: MediaStream) => {
try {
const Ctx =
(window as any).AudioContext ?? (window as any).webkitAudioContext;
if (!Ctx) return;
const audioCtx: AudioContext = new Ctx();
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
audioCtxRef.current = audioCtx;
analyserRef.current = analyser;
const data = new Uint8Array(analyser.frequencyBinCount);
sampleIntervalRef.current = setInterval(() => {
if (!analyserRef.current) return;
analyserRef.current.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / data.length);
// Amplify so quiet speech still shows a visible bar;
// clamp at 1 to keep the renderer bounded.
const level = Math.min(1, rms * 2.5);
allLevelsRef.current.push(level);
setWaveformLevels((prev) => {
const next =
prev.length >= WAVEFORM_MAX_SAMPLES
? [...prev.slice(prev.length - WAVEFORM_MAX_SAMPLES + 1), level]
: [...prev, level];
return next;
});
}, 80);
} catch {
// Waveform is best-effort; recording continues without it.
}
};
const startVoiceRecording = async () => {
setRecordingError(null);
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
setRecordingError('Recording is not supported here.');
return;
}
if (typeof MediaRecorder === 'undefined') {
setRecordingError('Recording is not supported here.');
return;
}
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err: any) {
setRecordingError(
err?.name === 'NotAllowedError'
? 'Microphone access was denied.'
: 'Could not start recording.',
);
return;
}
const mime = pickRecorderMime();
let rec: MediaRecorder;
try {
rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
} catch (err: any) {
setRecordingError(err?.message ?? 'Could not start recording.');
for (const t of stream.getTracks()) try { t.stop(); } catch {}
return;
}
recordingChunksRef.current = [];
recordingMimeRef.current = rec.mimeType || mime || 'audio/webm';
pendingOutcomeRef.current = null;
rec.ondataavailable = (e) => {
if (e.data && e.data.size > 0) recordingChunksRef.current.push(e.data);
};
rec.onstop = () => {
stopRecordingTimer();
stopMicStream();
stopWaveformAnalyser();
setWaveformLevels([]);
setIsLocked(false);
setIsRecording(false);
const outcome = pendingOutcomeRef.current;
pendingOutcomeRef.current = null;
const chunks = recordingChunksRef.current;
recordingChunksRef.current = [];
if (outcome !== 'send' || chunks.length === 0) return;
const type = recordingMimeRef.current;
const blob = new Blob(chunks, { type });
const ext = type.includes('mp4')
? 'm4a'
: type.includes('ogg')
? 'ogg'
: 'webm';
const filename = `voice-message-${Date.now()}.${ext}`;
const file = new File([blob], filename, { type });
// Finalize peaks + duration for the voice-message metadata.
// We send the full sample array (80ms cadence) and let the
// receiver downsample — keeps the wire payload compact even
// on long recordings while preserving waveform fidelity.
const startedAt = recordingStartedAtRef.current;
const durationSec =
startedAt > 0 ? (Date.now() - startedAt) / 1000 : 0;
const peaks = allLevelsRef.current.slice();
allLevelsRef.current = [];
// Send voice messages immediately instead of staging as a
// pending attachment — parity with WhatsApp / Discord.
void uploadOneFile(file, {
isVoiceMessage: true,
peaks,
durationSec,
}).catch((err) => {
console.error('Voice-message send failed:', err);
setRecordingError(err?.message ?? 'Failed to send voice message.');
});
};
mediaRecorderRef.current = rec;
micStreamRef.current = stream;
setRecordingSeconds(0);
setWaveformLevels([]);
allLevelsRef.current = [];
setIsRecording(true);
rec.start(250);
recordingStartedAtRef.current = Date.now();
recordingTimerRef.current = setInterval(() => {
setRecordingSeconds((s) => s + 1);
}, 1000);
startWaveformAnalyser(stream);
// Hold-to-record: if the user already released while we were
// waiting on the permission prompt, stop immediately. Sending
// still respects the hold-threshold check in the up-handler.
// If a lock was committed during the prompt, we leave the
// recording running and let the bar's buttons finish it.
if (isHoldingRef.current === false && !isLocked) {
stopVoiceRecording('cancel');
}
};
const stopVoiceRecording = (outcome: 'send' | 'cancel') => {
const rec = mediaRecorderRef.current;
if (!rec) {
setIsRecording(false);
stopRecordingTimer();
stopMicStream();
return;
}
pendingOutcomeRef.current = outcome;
try {
rec.stop();
} catch {
// If `stop` throws (already stopped), run the cleanup path
// by hand so the UI doesn't get stuck in the recording state.
stopRecordingTimer();
stopMicStream();
setIsRecording(false);
pendingOutcomeRef.current = null;
}
mediaRecorderRef.current = null;
};
// Stop any active recording on unmount so the mic LED / permission
// indicator doesn't linger after the user leaves the channel.
useEffect(() => {
return () => {
if (mediaRecorderRef.current) {
try { mediaRecorderRef.current.stop(); } catch {}
}
stopRecordingTimer();
stopMicStream();
stopWaveformAnalyser();
};
}, []);
// Stage files as pending attachments above the composer instead of
// uploading immediately. The actual encrypt+upload runs in doSend()
// when the user presses Send.
@@ -824,7 +1101,14 @@ export function ChannelTextarea({
: `Message #${channelName}`;
return (
<div className={styles.outer}>
<div
className={styles.outer}
style={
isMobile && isRecording
? { paddingLeft: 0, paddingRight: 0 }
: undefined
}
>
{replyTo && onCancelReply && (
<div className={styles.replyBar}>
<span className={styles.replyText}>
@@ -854,12 +1138,130 @@ export function ChannelTextarea({
</div>
)}
<PendingAttachmentRow
attachments={pendingAttachments}
onRemove={removePendingAttachment}
/>
{!(isMobile && isRecording) && (
<PendingAttachmentRow
attachments={pendingAttachments}
onRemove={removePendingAttachment}
/>
)}
<div className={styles.mainWrapper}>
<div
className={styles.mainWrapper}
style={
isMobile && isRecording
? {
background: 'var(--brand-primary)',
alignItems: 'center',
gap: 8,
// .outer drops its horizontal padding while recording,
// so the bar fills edge-to-edge. Internal padding keeps
// the trash / send buttons off the screen edges.
padding: '8px',
}
: undefined
}
>
{isMobile && isRecording ? (
<>
<button
type="button"
onClick={() => stopVoiceRecording('cancel')}
aria-label="Cancel voice message"
title="Cancel"
style={{
width: 36,
height: 36,
minWidth: 36,
borderRadius: '50%',
background: 'var(--background-primary)',
color: '#fff',
border: 'none',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
>
<Trash size={18} weight="fill" />
</button>
<div
style={{
flex: 1,
minWidth: 0,
height: 36,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '0 12px',
borderRadius: 999,
background: 'var(--background-primary)',
}}
>
<span
aria-hidden="true"
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: 'var(--status-danger, #da373c)',
animation: 'brycord-record-pulse 1.4s ease-out infinite',
flexShrink: 0,
}}
/>
<span
style={{
color: '#fff',
fontSize: 13,
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
}}
>
{Math.floor(recordingSeconds / 60)}:
{(recordingSeconds % 60).toString().padStart(2, '0')}
</span>
<div
aria-hidden="true"
style={{
flex: 1,
minWidth: 0,
height: 22,
display: 'flex',
alignItems: 'center',
gap: 2,
overflow: 'hidden',
}}
>
{(() => {
const shown =
waveformLevels.length < WAVEFORM_MAX_SAMPLES
? [
...Array(
WAVEFORM_MAX_SAMPLES - waveformLevels.length,
).fill(0),
...waveformLevels,
]
: waveformLevels;
return shown.map((lvl, i) => (
<span
key={i}
style={{
flex: '1 1 auto',
height: `${Math.max(10, lvl * 100)}%`,
background: 'rgba(255, 255, 255, 0.9)',
borderRadius: 2,
minWidth: 2,
opacity: lvl === 0 ? 0.35 : 1,
}}
/>
));
})()}
</div>
</div>
</>
) : (
<>
<div className={styles.uploadColumn}>
<input
ref={fileInputRef}
@@ -896,70 +1298,271 @@ export function ChannelTextarea({
{isEmpty && <div className={styles.placeholder}>{placeholder}</div>}
</div>
<div className={styles.buttonContainer} ref={expressionButtonsRef}>
<Tooltip
content="GIFs"
shortcut={keybinds.getCombo('popouts.openGifPicker')}
placement="top"
{isRecording && !isMobile ? (
<div
className={styles.buttonContainer}
style={{ alignItems: 'center', gap: 10, paddingRight: 6 }}
>
<button
type="button"
className={styles.textareaButton}
onClick={() => openPickerOnTab('gifs')}
aria-label="GIFs"
<span
aria-hidden="true"
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: 'var(--status-danger, #da373c)',
boxShadow: '0 0 0 0 rgba(218, 55, 60, 0.6)',
animation: 'brycord-record-pulse 1.4s ease-out infinite',
}}
/>
<span style={{ color: 'var(--text-primary)', fontSize: 13, fontVariantNumeric: 'tabular-nums' }}>
{Math.floor(recordingSeconds / 60)}:
{(recordingSeconds % 60).toString().padStart(2, '0')}
</span>
<Tooltip content="Cancel" placement="top">
<button
type="button"
className={styles.textareaButton}
onClick={() => stopVoiceRecording('cancel')}
aria-label="Cancel recording"
>
<Trash size={22} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
</div>
) : (
<div className={styles.buttonContainer} ref={expressionButtonsRef}>
<Tooltip
content="GIFs"
shortcut={keybinds.getCombo('popouts.openGifPicker')}
placement="top"
>
<Gif size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
<Tooltip content="Saved Media" placement="top">
<button
type="button"
className={styles.textareaButton}
onClick={() => openPickerOnTab('media')}
aria-label="Saved Media"
<button
type="button"
className={styles.textareaButton}
onClick={() => openPickerOnTab('gifs')}
aria-label="GIFs"
>
<Gif size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
<Tooltip content="Saved Media" placement="top">
<button
type="button"
className={styles.textareaButton}
onClick={() => openPickerOnTab('media')}
aria-label="Saved Media"
>
<ImageSquare size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
<Tooltip content="Stickers" placement="top">
<button
type="button"
className={styles.textareaButton}
onClick={() => openPickerOnTab('stickers')}
aria-label="Stickers"
>
<Sticker size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
<Tooltip
content="Emoji"
shortcut={keybinds.getCombo('popouts.openEmojiPicker')}
placement="top"
>
<ImageSquare size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
<Tooltip content="Stickers" placement="top">
<button
type="button"
className={styles.textareaButton}
onClick={() => openPickerOnTab('stickers')}
aria-label="Stickers"
>
<Sticker size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
<Tooltip
content="Emoji"
shortcut={keybinds.getCombo('popouts.openEmojiPicker')}
placement="top"
>
<button
ref={emojiButtonRef}
type="button"
className={styles.textareaButton}
onClick={toggleEmojiPicker}
aria-label="Emoji"
>
<Smiley size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
</div>
<button
ref={emojiButtonRef}
type="button"
className={styles.textareaButton}
onClick={toggleEmojiPicker}
aria-label="Emoji"
>
<Smiley size={26} className={styles.textareaButtonIcon} />
</button>
</Tooltip>
</div>
)}
</>
)}
<div className={styles.sendColumn}>
<button
type="button"
className={styles.sendButton}
onClick={doSend}
disabled={(isEmpty && pendingAttachments.length === 0) || !channelKey || isUploading}
aria-label="Send message"
>
<ArrowUp size={22} weight="bold" />
</button>
<div className={styles.sendColumn} style={{ position: 'relative' }}>
{isMobile && isEmpty && pendingAttachments.length === 0 && !isLocked ? (
<>
{/* Lock indicator: appears above the mic while the
user is holding but has not yet dragged up far
enough to lock. Fills briefly when the drag
enters the lock zone. */}
{isRecording && !isLocked && isHoldingRef.current && (
<div
aria-hidden="true"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)',
width: 36,
height: 36,
borderRadius: 10,
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'hsl(138.353 calc(1 * 38.117%) 56.275% / 1)',
pointerEvents: 'none',
boxShadow: '0 4px 12px rgba(0,0,0,0.35)',
}}
>
<Lock size={18} weight="fill" />
</div>
)}
<button
type="button"
className={styles.sendButton}
style={
isRecording
? {
background: '#fff',
color: 'var(--brand-primary)',
}
: { background: 'var(--background-tertiary)' }
}
onPointerDown={(e) => {
// Hold-to-record. Pointer capture so the release
// fires on this button even if the finger drifts
// outside — we cancel on explicit pointerleave /
// pointercancel instead.
e.preventDefault();
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
if (isRecording || isHoldingRef.current) return;
isHoldingRef.current = true;
dragStartYRef.current = e.clientY;
void startVoiceRecording();
}}
onPointerMove={(e) => {
if (!isHoldingRef.current || isLocked) return;
if (dragStartYRef.current === null) return;
const deltaY = e.clientY - dragStartYRef.current;
if (deltaY <= -LOCK_DRAG_THRESHOLD_PX) {
// Commit to locked mode — release pointer
// capture so the user can lift their finger
// freely, and drop the hold flag so the up-
// handler doesn't double-stop the recorder.
setIsLocked(true);
isHoldingRef.current = false;
dragStartYRef.current = null;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
}
}}
onPointerUp={(e) => {
if (!isHoldingRef.current) return;
isHoldingRef.current = false;
dragStartYRef.current = null;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
const elapsed = recordingStartedAtRef.current
? Date.now() - recordingStartedAtRef.current
: 0;
stopVoiceRecording(
elapsed >= HOLD_SEND_THRESHOLD_MS ? 'send' : 'cancel',
);
}}
onPointerCancel={() => {
if (!isHoldingRef.current) return;
isHoldingRef.current = false;
dragStartYRef.current = null;
stopVoiceRecording('cancel');
}}
onPointerLeave={() => {
// Leaving the button while still holding is
// treated as a cancel UNLESS the user has
// already committed to locked mode (in which
// case the drag just moved on to the lock
// indicator above).
if (!isHoldingRef.current || isLocked) return;
isHoldingRef.current = false;
dragStartYRef.current = null;
stopVoiceRecording('cancel');
}}
aria-label="Hold to record voice message"
title="Hold to record"
>
{isRecording ? (
<ArrowUp size={22} weight="bold" />
) : (
<Microphone size={22} weight="fill" />
)}
</button>
</>
) : (
<button
type="button"
className={styles.sendButton}
style={
isMobile && isRecording
? { background: '#fff', color: 'var(--brand-primary)' }
: undefined
}
onClick={isRecording ? () => stopVoiceRecording('send') : doSend}
disabled={
isRecording
? recordingSeconds < 1
: (isEmpty && pendingAttachments.length === 0) || !channelKey || isUploading
}
aria-label={isRecording ? 'Send voice message' : 'Send message'}
>
<ArrowUp size={22} weight="bold" />
</button>
)}
</div>
</div>
{recordingError && (
<div
style={{
margin: '4px 12px',
padding: '6px 10px',
borderRadius: 6,
background: 'rgba(218, 55, 60, 0.15)',
color: 'var(--status-danger, #da373c)',
fontSize: 12,
}}
role="status"
>
{recordingError}
</div>
)}
{isMobile &&
isRecording &&
!isLocked &&
createPortal(
<div
role="status"
style={{
position: 'fixed',
top: 'max(12px, env(safe-area-inset-top))',
left: '50%',
transform: 'translateX(-50%)',
padding: '8px 14px',
borderRadius: 999,
background: 'var(--brand-primary, #5865f2)',
color: '#fff',
fontSize: 13,
fontWeight: 600,
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.35)',
zIndex: 15000,
pointerEvents: 'none',
maxWidth: 'calc(100vw - 24px)',
textAlign: 'center',
}}
>
Hold to record. Drag up to lock, or release to send.
</div>,
document.body,
)}
{mentionQuery !== null && (
<MentionAutocomplete
@@ -1055,6 +1658,19 @@ export function ChannelTextarea({
<ChartBar size={20} />
<span>Create Poll</span>
</button>
{!isMobile && (
<button
type="button"
className={styles.plusMenuItem}
onClick={() => {
setShowPlusMenu(false);
void startVoiceRecording();
}}
>
<Microphone size={20} />
<span>Record Voice Message</span>
</button>
)}
</div>
</>
);

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { usePlatform } from '../../platform';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentVideo } from './AttachmentVideo';
import { VoiceMessagePlayer } from './VoiceMessagePlayer';
export interface AttachmentMetadata {
type: 'attachment';
@@ -15,6 +16,15 @@ export interface AttachmentMetadata {
iv: string;
width?: number;
height?: number;
/** True when the attachment was recorded in-app as a voice message
* (distinguishes it from a regular audio upload so the receiver
* renders the Discord-style pill instead of the full audio card). */
isVoiceMessage?: boolean;
/** Pre-computed amplitude samples (0..1) captured during recording.
* Lets the receiver draw the waveform without decoding the blob. */
peaks?: number[];
/** Recording duration in seconds, also captured at send time. */
durationSec?: number;
}
const TAG_HEX_LEN = 32;
@@ -239,14 +249,23 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac
return (
<div
style={{
width: 360,
height: 96,
width: metadata.isVoiceMessage ? 280 : 360,
height: metadata.isVoiceMessage ? 44 : 96,
backgroundColor: 'var(--background-tertiary)',
borderRadius: 'var(--radius-lg)',
borderRadius: metadata.isVoiceMessage ? 999 : 'var(--radius-lg)',
}}
/>
);
}
if (metadata.isVoiceMessage) {
return (
<VoiceMessagePlayer
src={url}
peaks={metadata.peaks ?? []}
durationSec={metadata.durationSec ?? 0}
/>
);
}
return (
<AttachmentAudio
src={url}

View File

@@ -37,6 +37,17 @@
white-space: pre-wrap;
}
/* global.css has a `:focus-visible { outline: 2px solid … }` rule
that ties on specificity with the `.editor` selector above — the
cascade picks whichever loads last, and in practice the global
rule wins, painting a blue ring around the editable box. Bumping
specificity via `.editor:focus` / `:focus-visible` pins the
outline off for this contenteditable. */
.editor:focus,
.editor:focus-visible {
outline: none;
}
.editor:empty::before {
content: attr(data-placeholder);
color: var(--text-tertiary);

View File

@@ -65,10 +65,31 @@ const embedImageDimsCache = new Map<string, { w: number; h: number }>();
// 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';
// Natural `videoWidth` / `videoHeight` (intrinsic pixel dimensions)
// learned from the first `loadedmetadata` event per URL. Fills in
// the wrapper aspect-ratio + size so portrait, square, and other
// non-16:9 videos don't letterbox or distort.
const directVideoDimsCache = new Map<string, { w: number; h: number }>();
// Embed sizing cap. Mirrors the `.directVideo` CSS max-width /
// max-height so the JS-computed wrapper matches the legacy ceiling
// while letting aspect-ratio drive layout below it.
const DIRECT_VIDEO_MAX_W = 400;
const DIRECT_VIDEO_MAX_H = 300;
// Fallback wrapper size used until `loadedmetadata` fires. Pre-sized
// at 16:9 — the overwhelming majority of web video — so the first
// paint reserves real space instead of collapsing to the 16×9 that a
// literal (16, 9) scale-to-fit would produce.
const DIRECT_VIDEO_FALLBACK = { w: 400, h: 225 } as const;
function fitDirectVideo(w: number, h: number) {
if (w <= 0 || h <= 0) return DIRECT_VIDEO_FALLBACK;
// Shrink-only: tiny source videos render at their natural size
// instead of being blown up to the 400×300 cap.
const scale = Math.min(DIRECT_VIDEO_MAX_W / w, DIRECT_VIDEO_MAX_H / h, 1);
return { w: Math.round(w * scale), h: Math.round(h * scale) };
}
function normaliseMetadata(raw: any): UrlPreview | null {
if (!raw || typeof raw !== 'object') return null;
@@ -208,6 +229,12 @@ function DirectMediaEmbed({
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
// Lives at the component root so the hook call order is stable
// regardless of `type`. Image branches don't read it, but it
// still needs to be declared every render.
const [videoDims, setVideoDims] = useState<
{ w: number; h: number } | null
>(() => (type === 'video' ? directVideoDimsCache.get(url) ?? null : null));
if (type === 'video') {
const handlePlay = () => {
@@ -220,10 +247,12 @@ function DirectMediaEmbed({
// `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.
// reserved from the first paint. First mount uses a 16:9
// fallback; subsequent mounts hydrate from the module cache so
// repeat views of the same URL jump straight to the real
// proportions. When `onLoadedMetadata` fires we read
// `videoWidth` / `videoHeight` off the element and swap the
// wrapper if the cache entry was missing or wrong.
//
// Appending `#t=0.1` is a media-fragment hint that forces the
// browser to seek to 0.1s, which makes it decode and paint that
@@ -231,14 +260,20 @@ function DirectMediaEmbed({
// blank rectangle for `<video preload="metadata">` because it
// only fetches container metadata, not frames.
const posterSrc = url.includes('#') ? url : `${url}#t=0.1`;
const fit = videoDims
? fitDirectVideo(videoDims.w, videoDims.h)
: DIRECT_VIDEO_FALLBACK;
const ratio = videoDims
? `${videoDims.w} / ${videoDims.h}`
: `${DIRECT_VIDEO_FALLBACK.w} / ${DIRECT_VIDEO_FALLBACK.h}`;
return (
<div className={`${styles.embed} ${styles.embedBare}`}>
<div
className={styles.directVideoWrapper}
style={{
aspectRatio: DIRECT_VIDEO_FALLBACK_RATIO,
width: 400,
width: fit.w,
maxWidth: '100%',
aspectRatio: ratio,
}}
>
<video
@@ -247,7 +282,17 @@ function DirectMediaEmbed({
src={posterSrc}
preload="metadata"
style={{ width: '100%', height: '100%' }}
onLoadedMetadata={() => {
onLoadedMetadata={(e) => {
const v = e.currentTarget;
const w = v.videoWidth;
const h = v.videoHeight;
if (w > 0 && h > 0) {
const prev = directVideoDimsCache.get(url);
if (!prev || prev.w !== w || prev.h !== h) {
directVideoDimsCache.set(url, { w, h });
setVideoDims({ w, h });
}
}
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);

View File

@@ -44,6 +44,11 @@
font-style: italic;
}
.underline {
text-decoration: underline;
text-underline-offset: 2px;
}
.strikethrough {
text-decoration: line-through;
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { Fragment, useState, type ReactNode } from 'react';
import { getTwemojiUrl } from '../../utils/twemoji';
import styles from './MessageContent.module.css';
@@ -26,21 +26,252 @@ const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/gi;
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
// Escape user-supplied strings for safe inclusion in a regex.
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const CODE_FENCE_REGEX = /```(?:([a-zA-Z0-9_+\-]+)\n)?([\s\S]*?)```/g;
// --- Markdown AST --------------------------------------------------------
type InlineMarkType = 'bold' | 'italic' | 'underline' | 'strike' | 'spoiler';
type MdNode =
| { type: 'text'; text: string }
| { type: InlineMarkType; children: MdNode[] }
| { type: 'inlineCode'; text: string }
| { type: 'codeBlock'; lang: string | null; text: string }
| { type: 'blockquote'; children: MdNode[] };
interface InlineDelim {
open: string;
close: string;
type: InlineMarkType | 'inlineCode';
}
// Longer delimiters first so "**" is checked before "*".
const INLINE_DELIMS: InlineDelim[] = [
{ open: '**', close: '**', type: 'bold' },
{ open: '__', close: '__', type: 'underline' },
{ open: '~~', close: '~~', type: 'strike' },
{ open: '||', close: '||', type: 'spoiler' },
{ open: '`', close: '`', type: 'inlineCode' },
{ open: '*', close: '*', type: 'italic' },
{ open: '_', close: '_', type: 'italic' },
];
function parseMessage(text: string): MdNode[] {
return parseBlocks(text);
}
// Block pass 1: fenced code blocks. Content inside is verbatim.
function parseBlocks(text: string): MdNode[] {
const nodes: MdNode[] = [];
CODE_FENCE_REGEX.lastIndex = 0;
let cursor = 0;
let m: RegExpExecArray | null;
while ((m = CODE_FENCE_REGEX.exec(text))) {
if (m.index > cursor) {
nodes.push(...parseBlockquotes(text.slice(cursor, m.index)));
}
nodes.push({
type: 'codeBlock',
lang: m[1] ?? null,
text: m[2].replace(/\n$/, ''),
});
cursor = m.index + m[0].length;
}
if (cursor < text.length) {
nodes.push(...parseBlockquotes(text.slice(cursor)));
}
return nodes;
}
// Block pass 2: group runs of "> " lines into blockquote nodes.
function parseBlockquotes(text: string): MdNode[] {
const nodes: MdNode[] = [];
const lines = text.split('\n');
const isQuote = (s: string) => s === '>' || s.startsWith('> ');
let i = 0;
while (i < lines.length) {
if (isQuote(lines[i])) {
const quoted: string[] = [];
while (i < lines.length && isQuote(lines[i])) {
quoted.push(lines[i] === '>' ? '' : lines[i].slice(2));
i++;
}
nodes.push({ type: 'blockquote', children: parseInline(quoted.join('\n')) });
} else {
const chunk: string[] = [];
while (i < lines.length && !isQuote(lines[i])) {
chunk.push(lines[i]);
i++;
}
nodes.push(...parseInline(chunk.join('\n')));
}
}
return nodes;
}
function parseInline(text: string): MdNode[] {
const out: MdNode[] = [];
let cursor = 0;
while (cursor < text.length) {
const found = findInlineToken(text, cursor);
if (!found) {
if (cursor < text.length) out.push({ type: 'text', text: text.slice(cursor) });
break;
}
if (found.index > cursor) {
out.push({ type: 'text', text: text.slice(cursor, found.index) });
}
out.push(found.node);
cursor = found.end;
}
return out;
}
// Walk positions left-to-right. At each position, try delimiters longest-first;
// the first one with a valid closer wins.
function findInlineToken(
text: string,
from: number,
): { index: number; end: number; node: MdNode } | null {
for (let i = from; i < text.length; i++) {
// Escape: `\*` means literal `*` — skip delim parsing at this position.
if (i > 0 && text[i - 1] === '\\') continue;
for (const d of INLINE_DELIMS) {
if (!text.startsWith(d.open, i)) continue;
const contentStart = i + d.open.length;
const closeIdx = findUnescapedClose(text, contentStart, d.close);
if (closeIdx < 0 || closeIdx === contentStart) continue;
const inner = text.slice(contentStart, closeIdx);
const end = closeIdx + d.close.length;
let node: MdNode;
if (d.type === 'inlineCode') {
node = { type: 'inlineCode', text: inner };
} else {
node = { type: d.type, children: parseInline(inner) };
}
return { index: i, end, node };
}
}
return null;
}
function findUnescapedClose(text: string, from: number, close: string): number {
let j = from;
while (j <= text.length - close.length) {
if (text.startsWith(close, j) && text[j - 1] !== '\\') return j;
j++;
}
return -1;
}
// --- Render --------------------------------------------------------------
interface RenderCtx {
members: MentionMember[];
customEmojiMap: Map<string, string>;
}
function renderNodes(nodes: MdNode[], ctx: RenderCtx, keyPrefix: string): ReactNode[] {
return nodes.map((n, i) => renderNode(n, ctx, `${keyPrefix}-${i}`));
}
function renderNode(n: MdNode, ctx: RenderCtx, key: string): ReactNode {
switch (n.type) {
case 'text':
return <Fragment key={key}>{renderInlineText(n.text, ctx, key)}</Fragment>;
case 'bold':
return (
<span key={key} className={styles.bold}>
{renderNodes(n.children, ctx, key)}
</span>
);
case 'italic':
return (
<span key={key} className={styles.italic}>
{renderNodes(n.children, ctx, key)}
</span>
);
case 'underline':
return (
<span key={key} className={styles.underline}>
{renderNodes(n.children, ctx, key)}
</span>
);
case 'strike':
return (
<span key={key} className={styles.strikethrough}>
{renderNodes(n.children, ctx, key)}
</span>
);
case 'spoiler':
return <Spoiler key={key}>{renderNodes(n.children, ctx, key)}</Spoiler>;
case 'inlineCode':
return (
<code key={key} className={styles.inlineCode}>
{n.text}
</code>
);
case 'codeBlock':
return <CodeBlock key={key} lang={n.lang} text={n.text} />;
case 'blockquote':
return (
<div key={key} className={styles.blockquote}>
<div className={styles.blockquoteBorder} />
<div className={styles.blockquoteContent}>
{renderNodes(n.children, ctx, key)}
</div>
</div>
);
}
}
function Spoiler({ children }: { children: ReactNode }) {
const [revealed, setRevealed] = useState(false);
const cls = `${styles.spoiler} ${revealed ? styles.spoilerRevealed : styles.spoilerHidden}`;
return (
<span
className={cls}
role="button"
tabIndex={revealed ? -1 : 0}
onClick={(e) => {
if (revealed) return;
e.stopPropagation();
setRevealed(true);
}}
onKeyDown={(e) => {
if (revealed) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setRevealed(true);
}
}}
>
{children}
</span>
);
}
function CodeBlock({ lang, text }: { lang: string | null; text: string }) {
return (
<div className={styles.codeBlock}>
{lang ? <div className={styles.codeBlockHeader}>{lang}</div> : null}
<pre className={styles.codeBlockBody}>
<code>{text}</code>
</pre>
</div>
);
}
// --- Inline text scanner (emoji / mention / url / custom emoji) ----------
interface Token {
type: 'emoji' | 'mention' | 'customEmoji' | 'url';
index: number;
length: number;
text: string;
// For customEmoji / url: the resolved URL.
url?: string;
}
// Find the earliest token (emoji or mention) in `text` starting at `from`.
function findNextToken(
text: string,
from: number,
@@ -49,14 +280,10 @@ function findNextToken(
): Token | null {
let best: Token | null = null;
// URL — earliest match at or after `from`. Trailing punctuation
// is commonly prose, not part of the URL.
URL_REGEX.lastIndex = from;
const urlMatch = URL_REGEX.exec(text);
if (urlMatch) {
let matchText = urlMatch[0];
const trimmed = matchText.replace(/[),.;!?]+$/, '');
matchText = trimmed;
let matchText = urlMatch[0].replace(/[),.;!?]+$/, '');
best = {
type: 'url',
index: urlMatch.index,
@@ -66,7 +293,6 @@ function findNextToken(
};
}
// Emoji — next match at or after `from`.
EMOJI_REGEX.lastIndex = from;
const emojiMatch = EMOJI_REGEX.exec(text);
if (emojiMatch && (!best || emojiMatch.index < best.index)) {
@@ -78,8 +304,6 @@ function findNextToken(
};
}
// Custom emoji `:shortcode:` — match only when we have a registered
// emoji with that name. Unknown shortcodes fall through as plain text.
if (customEmojiMap.size > 0) {
CUSTOM_EMOJI_REGEX.lastIndex = from;
let m: RegExpExecArray | null;
@@ -98,18 +322,19 @@ function findNextToken(
}
break;
}
// Unknown shortcode — keep searching past this match.
}
}
// @everyone
const everyoneIdx = text.indexOf('@everyone', from);
if (everyoneIdx !== -1 && (!best || everyoneIdx < best.index)) {
best = { type: 'mention', index: everyoneIdx, length: '@everyone'.length, text: '@everyone' };
best = {
type: 'mention',
index: everyoneIdx,
length: '@everyone'.length,
text: '@everyone',
};
}
// @{DisplayName} — prefer longest display name match so "@Alice Smith"
// beats "@Alice". Sort members by descending display-name length.
const sorted = [...members].sort(
(a, b) => (b.displayName?.length ?? 0) - (a.displayName?.length ?? 0),
);
@@ -123,7 +348,6 @@ function findNextToken(
}
}
// Generic @word fallback — single run of word chars after an @.
const genericRe = /@[\w]+/g;
genericRe.lastIndex = from;
const gm = genericRe.exec(text);
@@ -134,17 +358,12 @@ function findNextToken(
return best;
}
function renderContent(
text: string,
members: MentionMember[],
customEmojiMap: Map<string, string>,
keyPrefix: string,
): ReactNode[] {
function renderInlineText(text: string, ctx: RenderCtx, keyPrefix: string): ReactNode[] {
const parts: ReactNode[] = [];
let cursor = 0;
let safety = 0;
while (cursor < text.length && safety++ < 10000) {
const tok = findNextToken(text, cursor, members, customEmojiMap);
const tok = findNextToken(text, cursor, ctx.members, ctx.customEmojiMap);
if (!tok) {
parts.push(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor)}</span>);
break;
@@ -197,11 +416,11 @@ function renderContent(
if (parts.length === 0) {
parts.push(<span key={`${keyPrefix}t0`}>{text}</span>);
}
// Silence unused escapeRegex in case linter complains.
void escapeRegex;
return parts;
}
// --- Public component ---------------------------------------------------
export function MessageContent({
content,
members = [],
@@ -209,5 +428,7 @@ export function MessageContent({
}: MessageContentProps) {
const map = new Map<string, string>();
for (const e of customEmojis) map.set(e.name.toLowerCase(), e.url);
return <>{renderContent(content, members, map, 'mc')}</>;
const tree = parseMessage(content);
const ctx: RenderCtx = { members, customEmojiMap: map };
return <>{renderNodes(tree, ctx, 'mc')}</>;
}

View File

@@ -7,6 +7,7 @@ import { useIsMobile } from '../../hooks/useIsMobile';
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
import { ImageLightbox } from './ImageLightbox';
import { InlineMessageEditor } from './InlineMessageEditor';
import { LinkEmbed } from './LinkEmbed';
import type { DecryptedMessage } from './Messages';
import { MessageActionBar } from './MessageActionBar';
@@ -32,6 +33,10 @@ interface MessageGroupProps {
messages: DecryptedMessage[];
channelId: string;
onReply?: (eventId: string, username: string) => void;
/** Supplied by Messages.tsx, which owns the channel keys needed
* to re-encrypt the edited payload. Throws on failure so the
* InlineMessageEditor can surface the error inline. */
onEditMessage?: (messageId: string, newText: string) => Promise<void>;
}
/**
@@ -68,7 +73,7 @@ function formatFullTime(ts: number): string {
});
}
export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps) {
export function MessageGroup({ messages, channelId, onReply, onEditMessage }: MessageGroupProps) {
const first = messages[0];
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
@@ -266,6 +271,32 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
id: string;
preview: PinnedMessage;
} | null>(null);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const handleStartEdit = (messageId: string) => {
const msg = messages.find((m) => m.id === messageId);
if (!msg || msg.senderId !== myUserId) return;
setEditingMessageId(messageId);
};
const handleCancelEdit = () => setEditingMessageId(null);
const handleSaveEdit = async (messageId: string, newText: string) => {
if (!onEditMessage) return;
const msg = messages.find((m) => m.id === messageId);
if (!msg) return;
// Blanking an existing body on save is treated as cancel —
// delete flows through its own confirmation modal so we don't
// accidentally "edit" a message into emptiness.
if (newText.trim().length === 0 && msg.attachments.length === 0) {
setEditingMessageId(null);
return;
}
if (newText === msg.content) {
setEditingMessageId(null);
return;
}
await onEditMessage(messageId, newText);
setEditingMessageId(null);
};
const handleDelete = (messageId: string) => {
const msg = messages.find((m) => m.id === messageId);
if (!msg) return;
@@ -331,6 +362,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
setLocalMenuOpenFor(open ? msg.id : null)
}
onReply={() => onReply?.(msg.id, first.authorName)}
onEdit={onEditMessage ? () => handleStartEdit(msg.id) : undefined}
onDelete={() => handleDelete(msg.id)}
onReact={(e) => openReactPicker(msg.id, e?.currentTarget ?? null)}
onQuickReact={async (emoji) => {
@@ -473,17 +505,27 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
})}
</span>
)}
{msg.content && !isGifOnlyContent(msg.content) && (
{editingMessageId === msg.id ? (
<div className={styles.text}>
<MessageContent
content={msg.content}
members={mentionMembers}
customEmojis={customEmojiList}
<InlineMessageEditor
initialContent={msg.content}
onSave={(newText) => handleSaveEdit(msg.id, newText)}
onCancel={handleCancelEdit}
/>
{msg.editedTimestamp && (
<span className={styles.editedTag}> (edited)</span>
)}
</div>
) : (
msg.content && !isGifOnlyContent(msg.content) && (
<div className={styles.text}>
<MessageContent
content={msg.content}
members={mentionMembers}
customEmojis={customEmojiList}
/>
{msg.editedTimestamp && (
<span className={styles.editedTag}> (edited)</span>
)}
</div>
)
)}
{msg.content &&
extractUrls(msg.content)
@@ -802,9 +844,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
/* forward not implemented yet */
}}
onEdit={
mobileSheetForMsg.senderId === myUserId
mobileSheetForMsg.senderId === myUserId && onEditMessage
? () => {
/* edit not implemented via sheet yet */
const id = mobileSheetForMsg.id;
setMobileSheetForMsg(null);
handleStartEdit(id);
}
: undefined
}

View File

@@ -1,4 +1,4 @@
import { useMutation, usePaginatedQuery, useQuery } from 'convex/react';
import { useAction, useMutation, usePaginatedQuery, useQuery } from 'convex/react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
@@ -57,12 +57,26 @@ const TAG_LENGTH = 32;
const decryptionCache = new Map<string, string>();
const MAX_CACHE = 2000;
function namespacedKey(userId: string | null, id: string): string {
return `${userId ?? 'anon'}:${id}`;
// Cache keys are `user:id[:source]`. Passing `source` (the current
// ciphertext) scopes cache hits to a specific version of the
// message, so when messages.edit swaps the ciphertext a subsequent
// fetch naturally misses and re-decrypts. The orphaned entry under
// the old source ages out via MAX_CACHE LRU.
function namespacedKey(
userId: string | null,
id: string,
source?: string,
): string {
return source ? `${userId ?? 'anon'}:${id}:${source}` : `${userId ?? 'anon'}:${id}`;
}
function cacheSet(userId: string | null, id: string, content: string) {
const key = namespacedKey(userId, id);
function cacheSet(
userId: string | null,
id: string,
content: string,
source?: string,
) {
const key = namespacedKey(userId, id, source);
if (decryptionCache.size >= MAX_CACHE) {
const firstKey = decryptionCache.keys().next().value;
if (firstKey !== undefined) decryptionCache.delete(firstKey);
@@ -70,8 +84,12 @@ function cacheSet(userId: string | null, id: string, content: string) {
decryptionCache.set(key, content);
}
function cacheGet(userId: string | null, id: string): string | undefined {
return decryptionCache.get(namespacedKey(userId, id));
function cacheGet(
userId: string | null,
id: string,
source?: string,
): string | undefined {
return decryptionCache.get(namespacedKey(userId, id, source));
}
// Exposed for the logout hook to flush plaintext from memory proactively,
@@ -320,6 +338,15 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map());
// 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
// matches" — without this, the stale plaintext would stay pinned
// until the next reload. Populated from cache hits and successful
// decrypts. Refs (not state) so staleness checks see the latest
// value without a re-render in between.
const plaintextSourceRef = useRef<Map<string, string>>(new Map());
// Reply previews — separate map keyed by the *child* message id so
// the per-row render can grab the parent's plaintext without
// re-decrypting on every paint. Filled by the effect below.
@@ -347,13 +374,19 @@ export function Messages({ channelId, onReply }: MessagesProps) {
if (!pagedMessages || pagedMessages.length === 0) return;
let changed = false;
let next: Map<string, string> | null = null;
const sourceRef = plaintextSourceRef.current;
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (decryptedMap.has(id)) continue;
const cached = cacheGet(userId, id);
const source = msg.ciphertext as string;
// Already decrypted against this exact ciphertext — nothing
// to hydrate. If the source has changed (edit), fall through
// so we can try the cache keyed by the new source.
if (decryptedMap.has(id) && sourceRef.get(id) === source) continue;
const cached = cacheGet(userId, id, source);
if (cached !== undefined) {
if (!next) next = new Map(decryptedMap);
next.set(id, cached);
sourceRef.set(id, source);
changed = true;
}
}
@@ -450,12 +483,20 @@ export function Messages({ channelId, onReply }: MessagesProps) {
nonce: string;
tag: string;
key: string;
ciphertext: string;
};
const jobs: Job[] = [];
const sourceRef = plaintextSourceRef.current;
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (decryptedMap.has(id)) continue;
if (cacheGet(userId, id) !== undefined) continue;
const source = msg.ciphertext as string;
// Gate on (has decrypted state) AND (source matches).
// The source check is what lets edits re-decrypt: after
// `messages.edit` swaps the ciphertext, the entry in
// `plaintextSourceRef` still points at the old source,
// so this branch falls through to queue a fresh decrypt.
if (decryptedMap.has(id) && sourceRef.get(id) === source) continue;
if (cacheGet(userId, id, source) !== undefined) continue;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' });
continue;
@@ -474,6 +515,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
tag: msg.ciphertext.slice(-TAG_LENGTH),
nonce: msg.nonce,
key: keyForVersion,
ciphertext: msg.ciphertext,
});
}
if (jobs.length === 0) return;
@@ -484,7 +526,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
const results = await Promise.all(
jobs.map(async (j) => {
if (j.kind === 'sentinel') {
return { id: j.id, value: j.value, cache: false };
return { id: j.id, value: j.value, cache: false, source: null as string | null };
}
try {
const plaintext = await crypto.decryptData(
@@ -493,19 +535,22 @@ export function Messages({ channelId, onReply }: MessagesProps) {
j.nonce,
j.tag,
);
return { id: j.id, value: plaintext, cache: true };
return { id: j.id, value: plaintext, cache: true, source: j.ciphertext };
} catch {
return { id: j.id, value: '[Unable to decrypt]', cache: false };
return { id: j.id, value: '[Unable to decrypt]', cache: false, source: null };
}
}),
);
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);
if (r.cache && r.source) cacheSet(userId, r.id, r.value, r.source);
if (r.source) plaintextSourceRef.current.set(r.id, r.source);
}
setDecryptedMap(next);
setDecryptedMap((prev) => {
const next = new Map(prev);
for (const r of results) next.set(r.id, r.value);
return next;
});
})();
return () => {
cancelled = true;
@@ -708,6 +753,55 @@ export function Messages({ channelId, onReply }: MessagesProps) {
});
}, [pagedMessages, decryptedMap, replyPreviewMap, channelId]);
// ── Edit flow ───────────────────────────────────────────────
// Mirrors the send path: we re-encrypt the new plaintext under
// the message's *original* keyVersion key (so the server's
// unchanged `keyVersion` still decrypts), re-sign the ciphertext,
// and produce an `edit:...` auth signature for the action guard.
// Bails silently if we no longer have the key for that version —
// shouldn't happen in practice (rotations keep old entries) but
// we'd rather cancel than corrupt a message.
const editMessageAction = useAction(api.messageActions.edit);
const handleEditMessage = useCallback(
async (messageId: string, newText: string) => {
if (!pagedMessages || !userId) throw new Error('Not ready.');
const signingKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('signingKey')
: null;
if (!signingKey) throw new Error('No signing key in session.');
const raw = (pagedMessages as any[]).find((m: any) => m.id === messageId);
if (!raw) throw new Error('Message not in current page.');
const msgKeyVersion = Number(raw.key_version ?? 1);
const key = channelKeysByVersion.get(msgKeyVersion);
if (!key) throw new Error('Missing channel key for this message.');
// Messages with attachments wrap the caption as { text } — preserve
// that envelope so the attachment list doesn't get dropped. Pure
// text messages stay plain strings for forward-compat with older
// decryption paths that don't `JSON.parse`.
const hadAttachments = Array.isArray(raw?.attachments) && raw.attachments.length > 0;
const payload = hadAttachments ? JSON.stringify({ text: newText }) : newText;
const { content, iv, tag } = await crypto.encryptData(payload, key);
const ciphertext = content + tag;
const signature = await crypto.signMessage(signingKey, ciphertext);
const authTimestamp = Date.now();
const authSignature = await crypto.signMessage(
signingKey,
`edit:${messageId}:${userId}:${authTimestamp}`,
);
await editMessageAction({
id: messageId as any,
userId: userId as any,
ciphertext,
nonce: iv,
signature,
authTimestamp,
authSignature,
});
},
[pagedMessages, userId, channelKeysByVersion, crypto, editMessageAction],
);
// Pinned-to-bottom tracking. Matches the new UI's approach: a single
// boolean in a ref, updated on every onScroll event via isNearBottom.
// MutationObserver + ResizeObserver below drive all auto-scroll
@@ -1172,6 +1266,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
messages={item.group as any}
channelId={channelId}
onReply={onReply}
onEditMessage={handleEditMessage}
/>,
);
} else {

View File

@@ -0,0 +1,214 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Pause, Play } from '@phosphor-icons/react';
interface VoiceMessagePlayerProps {
src: string;
/** 0..1 amplitude samples captured while the message was recorded.
* Empty arrays render a flat row — still useful for rare cases
* where peaks weren't captured (older sends, permission blips). */
peaks: number[];
/** Duration captured at send time. `<audio>`'s own metadata is
* unreliable for short WebM recordings, so we treat this as the
* authoritative duration and only fall back to the element if it's
* finite + nonzero. */
durationSec: number;
}
const BAR_COUNT = 40;
const BAR_GAP = 2;
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Downsample an arbitrary-length peaks array into exactly `BAR_COUNT`
// bars by averaging buckets. Upsamples (repeats) when the input is
// shorter than BAR_COUNT so the row never looks sparse.
function resampleToBars(peaks: number[]): number[] {
if (peaks.length === 0) return new Array(BAR_COUNT).fill(0.1);
if (peaks.length === BAR_COUNT) return peaks;
const out: number[] = new Array(BAR_COUNT);
if (peaks.length < BAR_COUNT) {
for (let i = 0; i < BAR_COUNT; i++) {
const idx = Math.floor((i / BAR_COUNT) * peaks.length);
out[i] = peaks[idx] ?? 0;
}
return out;
}
const bucketSize = peaks.length / BAR_COUNT;
for (let i = 0; i < BAR_COUNT; i++) {
const start = Math.floor(i * bucketSize);
const end = Math.floor((i + 1) * bucketSize);
let sum = 0;
let n = 0;
for (let j = start; j < end; j++) {
sum += peaks[j] ?? 0;
n += 1;
}
out[i] = n > 0 ? sum / n : 0;
}
return out;
}
export function VoiceMessagePlayer({ src, peaks, durationSec }: VoiceMessagePlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [elementDuration, setElementDuration] = useState(0);
const bars = useMemo(() => resampleToBars(peaks), [peaks]);
// Prefer the send-time duration; the `<audio>` element's own
// metadata can return `Infinity` for short WebM clips until playback
// reaches the end, which makes progress math look broken.
const effectiveDuration =
durationSec > 0
? durationSec
: Number.isFinite(elementDuration) && elementDuration > 0
? elementDuration
: 0;
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const onTime = () => setCurrentTime(el.currentTime);
const onDuration = () => setElementDuration(el.duration);
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => {
setIsPlaying(false);
setCurrentTime(0);
};
el.addEventListener('timeupdate', onTime);
el.addEventListener('loadedmetadata', onDuration);
el.addEventListener('durationchange', onDuration);
el.addEventListener('play', onPlay);
el.addEventListener('pause', onPause);
el.addEventListener('ended', onEnded);
return () => {
el.removeEventListener('timeupdate', onTime);
el.removeEventListener('loadedmetadata', onDuration);
el.removeEventListener('durationchange', onDuration);
el.removeEventListener('play', onPlay);
el.removeEventListener('pause', onPause);
el.removeEventListener('ended', onEnded);
};
}, []);
const handleTogglePlay = useCallback(() => {
const el = audioRef.current;
if (!el || !src) return;
if (el.paused) {
void el.play().catch(() => {});
} else {
el.pause();
}
}, [src]);
const handleBarClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const el = audioRef.current;
if (!el || effectiveDuration <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(
0,
Math.min(1, (e.clientX - rect.left) / rect.width),
);
el.currentTime = ratio * effectiveDuration;
setCurrentTime(el.currentTime);
},
[effectiveDuration],
);
const progress =
effectiveDuration > 0
? Math.max(0, Math.min(1, currentTime / effectiveDuration))
: 0;
const playedBarIdx = Math.floor(progress * BAR_COUNT);
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '6px 12px 6px 6px',
background: 'var(--background-secondary)',
borderRadius: 999,
maxWidth: 340,
width: '100%',
}}
>
<button
type="button"
onClick={handleTogglePlay}
aria-label={isPlaying ? 'Pause voice message' : 'Play voice message'}
title={isPlaying ? 'Pause' : 'Play'}
style={{
width: 32,
height: 32,
minWidth: 32,
borderRadius: '50%',
background: 'var(--brand-primary, #5865f2)',
color: '#fff',
border: 'none',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
flexShrink: 0,
}}
>
{isPlaying ? (
<Pause size={16} weight="fill" />
) : (
<Play size={16} weight="fill" />
)}
</button>
<div
onClick={handleBarClick}
style={{
flex: 1,
minWidth: 80,
height: 24,
display: 'flex',
alignItems: 'center',
gap: BAR_GAP,
cursor: effectiveDuration > 0 ? 'pointer' : 'default',
}}
>
{bars.map((lvl, i) => (
<span
key={i}
style={{
flex: '1 1 auto',
height: `${Math.max(15, lvl * 100)}%`,
background:
i < playedBarIdx
? 'var(--brand-primary, #5865f2)'
: 'var(--text-tertiary, rgba(255, 255, 255, 0.35))',
borderRadius: 2,
minWidth: 2,
transition: 'background 0.1s linear',
}}
/>
))}
</div>
<span
style={{
color: 'var(--text-secondary)',
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
}}
>
{formatTime(currentTime)} / {formatTime(effectiveDuration)}
</span>
<audio ref={audioRef} src={src || undefined} preload="metadata" />
</div>
);
}

View File

@@ -19,6 +19,7 @@ import { MobileCreateChannelPage } from './MobileCreateChannelPage';
import { MobileCreateCategoryPage } from './MobileCreateCategoryPage';
import { useIsMobile } from '../../hooks/useIsMobile';
import { UpdateBanner } from './UpdateBanner';
import { NotificationManager } from './NotificationManager';
/**
* AppLayout — checks session via sessionStorage (matches App.tsx AuthGuard),
@@ -273,6 +274,7 @@ export function AppLayout() {
)}
<RecordingRecoveryModal />
<UpdateBanner />
<NotificationManager myUserId={myUserId} />
</KeybindProvider>
</PresenceProvider>
);

View File

@@ -0,0 +1,156 @@
.wrap {
position: relative;
display: flex;
align-items: center;
-webkit-app-region: no-drag;
}
.button {
width: 46px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: hsl(138.353 calc(1 * 38.117%) 56.275% / 1);
transition: background-color 0.12s, filter 0.12s;
}
.button:hover {
background-color: var(--background-modifier-hover);
filter: brightness(1.1);
}
.button:disabled {
cursor: default;
opacity: 0.7;
}
.popover {
position: absolute;
top: 34px;
right: 0;
min-width: 260px;
background: var(--background-floating, var(--background-secondary));
border: 1px solid var(--background-tertiary);
border-radius: var(--radius-md, 8px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 12px 14px;
color: var(--text-primary);
z-index: 20001;
}
.popoverTitle {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.popoverSubtitle {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 10px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
.progressTrack {
height: 4px;
background: var(--background-tertiary);
border-radius: var(--radius-full, 999px);
overflow: hidden;
margin-bottom: 10px;
}
.progressFill {
height: 100%;
background: hsl(138.353 calc(1 * 38.117%) 56.275% / 1);
transition: width 0.2s;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.primaryBtn {
padding: 6px 12px;
border: none;
border-radius: var(--radius-sm, 4px);
background: hsl(138.353 calc(1 * 38.117%) 56.275% / 1);
color: #0a1d12;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.primaryBtn:hover {
filter: brightness(1.08);
}
.primaryBtn:disabled {
opacity: 0.6;
cursor: default;
}
.secondaryBtn {
padding: 6px 12px;
border: none;
border-radius: var(--radius-sm, 4px);
background: transparent;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
}
.secondaryBtn:hover {
color: var(--text-primary);
}
/* Required-update blocker overlay */
.blocker {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 30000;
}
.blockerCard {
background: var(--background-primary);
border-radius: var(--radius-lg, 12px);
padding: 24px 28px;
max-width: 420px;
width: calc(100% - 32px);
text-align: center;
border: 1px solid var(--background-tertiary);
}
.blockerCard h2 {
font-size: 18px;
margin: 0 0 8px;
color: var(--text-primary);
}
.blockerCard p {
font-size: 13px;
color: var(--text-secondary);
margin: 0 0 16px;
line-height: 1.5;
white-space: pre-wrap;
}
.blockerCard .primaryBtn {
width: 100%;
padding: 10px;
font-size: 14px;
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react';
import { usePlatform } from '../../platform';
import styles from './HeaderUpdateIcon.module.css';
interface UpdateStatus {
hasUpdate: boolean;
required: boolean;
latestVersion: string | null;
currentVersion: string | null;
releaseNotes: string | null;
downloading: boolean;
downloaded: boolean;
progress: number;
error: string | null;
}
const INITIAL: UpdateStatus = {
hasUpdate: false,
required: false,
latestVersion: null,
currentVersion: null,
releaseNotes: null,
downloading: false,
downloaded: false,
progress: 0,
error: null,
};
// Strip the `[REQUIRED]` marker from the notes so the popover doesn't
// repeat information the UI itself already conveys.
function cleanNotes(notes: string | null): string {
if (!notes) return '';
return notes.replace(/^\s*\[REQUIRED\]\s*/i, '').trim();
}
function UpdateArrow() {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1M3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2z"
/>
</svg>
);
}
export function HeaderUpdateIcon() {
const platform = usePlatform() as any;
const updates = platform?.updates ?? null;
const hasInApp =
typeof updates?.getStatus === 'function' &&
typeof updates?.downloadAndInstall === 'function';
const [status, setStatus] = useState<UpdateStatus>(INITIAL);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!hasInApp) return;
let cancelled = false;
(async () => {
try {
const current = await updates.getStatus();
if (!cancelled && current) setStatus({ ...INITIAL, ...current });
} catch {}
})();
const off = updates.onStatusChanged?.((next: UpdateStatus) => {
setStatus({ ...INITIAL, ...next });
});
return () => {
cancelled = true;
if (typeof off === 'function') off();
};
}, [hasInApp, updates]);
useEffect(() => {
if (!open) return;
const onDocClick = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [open]);
if (!hasInApp) return null;
if (!status.hasUpdate) return null;
const versionLabel = status.latestVersion ? `v${status.latestVersion}` : 'a new version';
const currentLabel = status.currentVersion ? ` (you have v${status.currentVersion})` : '';
const notes = cleanNotes(status.releaseNotes);
const onInstall = () => {
if (!updates?.downloadAndInstall) return;
void updates.downloadAndInstall();
};
// Required update: render as a blocking overlay instead of a
// silent icon. The user must update to continue.
if (status.required) {
return (
<div className={styles.blocker} role="alertdialog" aria-modal="true">
<div className={styles.blockerCard}>
<h2>Update required</h2>
<p>
{`${versionLabel} is required to keep using the app${currentLabel}.`}
{notes ? `\n\n${notes}` : ''}
</p>
{status.downloading && (
<div className={styles.progressTrack}>
<div
className={styles.progressFill}
style={{ width: `${Math.max(2, status.progress)}%` }}
/>
</div>
)}
<button
type="button"
className={styles.primaryBtn}
onClick={onInstall}
disabled={status.downloading}
>
{status.downloading
? `Downloading… ${Math.round(status.progress)}%`
: status.downloaded
? 'Install and restart'
: 'Update now'}
</button>
</div>
</div>
);
}
return (
<div className={styles.wrap} ref={wrapRef}>
<button
type="button"
className={styles.button}
onClick={() => setOpen((v) => !v)}
aria-label={`Update to ${versionLabel}`}
title={`Update to ${versionLabel}`}
>
<UpdateArrow />
</button>
{open && (
<div className={styles.popover} role="dialog">
<div className={styles.popoverTitle}>{`Update to ${versionLabel}`}</div>
<div className={styles.popoverSubtitle}>
{notes ||
`A new version is available${currentLabel}. You can keep using the current version, or update now.`}
</div>
{status.downloading && (
<div className={styles.progressTrack}>
<div
className={styles.progressFill}
style={{ width: `${Math.max(2, status.progress)}%` }}
/>
</div>
)}
<div className={styles.actions}>
<button
type="button"
className={styles.secondaryBtn}
onClick={() => setOpen(false)}
disabled={status.downloading}
>
Later
</button>
<button
type="button"
className={styles.primaryBtn}
onClick={onInstall}
disabled={status.downloading}
>
{status.downloading
? `${Math.round(status.progress)}%`
: status.downloaded
? 'Restart'
: 'Update now'}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useRef } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
interface Props {
myUserId: string | null;
}
// Silent observer that fires OS notifications when new messages
// arrive in any channel the user can see while the app window is
// unfocused. Own sends are ignored. Nothing is rendered — this
// component is just a place to park the effect at app-layout scope
// so it stays mounted across navigations.
export function NotificationManager({ myUserId }: Props) {
const platform = usePlatform() as any;
const notifications = platform?.notifications ?? null;
const channels = useQuery(api.channels.list);
const channelIds = (channels ?? [])
.filter((c: any) => c.type === 'text' || c.type === 'dm')
.map((c: any) => c._id);
const latest = useQuery(
api.readState.getLatestMessageTimestamps,
channelIds.length > 0 ? { channelIds } : 'skip',
);
// `seenMessageIds` starts populated from the first query result so
// the app doesn't fire a barrage on mount. After initialization,
// every fresh messageId triggers a single notification.
const seenRef = useRef<Map<string, string> | null>(null);
const focusedRef = useRef<boolean>(
typeof document !== 'undefined' ? document.hasFocus() : true,
);
const permissionAskedRef = useRef(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const onFocus = () => {
focusedRef.current = true;
notifications?.setBadge?.(0);
notifications?.flashFrame?.(false);
};
const onBlur = () => {
focusedRef.current = false;
};
const onVisibility = () => {
if (document.visibilityState === 'visible' && document.hasFocus()) {
focusedRef.current = true;
notifications?.setBadge?.(0);
notifications?.flashFrame?.(false);
}
};
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [notifications]);
// One-shot permission request on first mount where we have a real
// notifications API. `ensurePermission` is a no-op on Electron
// (always granted) and prompts the browser on web.
useEffect(() => {
if (permissionAskedRef.current) return;
if (!notifications?.ensurePermission) return;
permissionAskedRef.current = true;
void notifications.ensurePermission();
}, [notifications]);
useEffect(() => {
if (!latest || !myUserId) return;
const channelNameById = new Map<string, string>();
for (const c of channels ?? []) channelNameById.set(c._id, c.name ?? '');
// Initialize on first real payload — no notifications for the
// historical state. From here on, any new `messageId` means a
// genuinely fresh message.
if (seenRef.current === null) {
const init = new Map<string, string>();
for (const row of latest) {
if (row.messageId) init.set(row.channelId, row.messageId);
}
seenRef.current = init;
return;
}
const seen = seenRef.current;
let unreadDelta = 0;
for (const row of latest) {
if (!row.messageId) continue;
const prev = seen.get(row.channelId);
if (prev === row.messageId) continue;
seen.set(row.channelId, row.messageId);
if (prev === undefined) continue; // first sight of a channel mid-session
if (row.senderId === myUserId) continue;
if (focusedRef.current) continue;
unreadDelta += 1;
const name = channelNameById.get(row.channelId) || 'channel';
const isDm = (channels ?? []).find((c: any) => c._id === row.channelId)?.type === 'dm';
const title = isDm ? 'New direct message' : `New message in #${name}`;
notifications?.show?.({ title, body: '' });
notifications?.flashFrame?.(true);
}
if (unreadDelta > 0) notifications?.setBadge?.(unreadDelta);
}, [latest, channels, myUserId, notifications]);
return null;
}

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { Minus, Square, X } from '@phosphor-icons/react';
import { usePlatform } from '../../platform';
import { HeaderUpdateIcon } from './HeaderUpdateIcon';
import styles from './TitleBar.module.css';
/**
@@ -30,6 +31,7 @@ export function TitleBar() {
return (
<div className={styles.bar}>
<HeaderUpdateIcon />
<div className={styles.buttons}>
<button
type="button"

View File

@@ -169,6 +169,7 @@ export function UserAreaProfilePopout({
// row. Reactive via `useQuery` so the popout updates instantly
// when the value changes in Settings → My Account.
const accentColor = (me as any)?.accentColor || DEFAULT_ACCENT_COLOR;
const bannerUrl: string | null = (me as any)?.bannerUrl ?? null;
const presence = me.status || 'online';
const bio = me.aboutMe?.trim();
const displayName = me.displayName || me.username || 'User';
@@ -233,7 +234,15 @@ export function UserAreaProfilePopout({
<div className={styles.header}>
<div
className={styles.banner}
style={{ backgroundColor: accentColor }}
style={
bannerUrl
? {
backgroundImage: `url("${bannerUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: { backgroundColor: accentColor }
}
/>
<div className={styles.avatarWrap}>
<Avatar

View File

@@ -136,6 +136,7 @@ export function MemberProfileModal({
const avatarUrl = profile?.avatarUrl ?? null;
const bio = profile?.aboutMe?.trim();
const accent = (profile as any)?.accentColor || DEFAULT_ACCENT;
const bannerUrl: string | null = (profile as any)?.bannerUrl ?? null;
const storedStatus = (profile?.status as string | undefined) || 'offline';
const livePresence = resolveStatus(storedStatus, member.userId);
const statusDisplay = getStatusDisplay(livePresence);
@@ -225,7 +226,15 @@ export function MemberProfileModal({
<div
className={styles.banner}
style={{ backgroundColor: accent }}
style={
bannerUrl
? {
backgroundImage: `url("${bannerUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: { backgroundColor: accent }
}
/>
<div className={styles.headerRow}>

View File

@@ -108,6 +108,7 @@ export function MemberProfilePopout({
// My Account. Falls back to the brand default when the user
// hasn't picked one yet.
const accent = (fullUser as any)?.accentColor || DEFAULT_ACCENT;
const bannerUrl: string | null = (fullUser as any)?.bannerUrl ?? null;
// Position: anchor to the LEFT of the clicked row. The member
// list sits on the right edge so the popout flows inward. Clamp
@@ -153,10 +154,20 @@ export function MemberProfilePopout({
style={positionStyle}
onClick={(e) => e.stopPropagation()}
>
{/* Banner — solid accent color. */}
{/* Banner — uploaded image if present, otherwise a solid
accent color. `object-fit: cover` is handled via CSS
so the band doesn't stretch the image. */}
<div
className={styles.banner}
style={{ backgroundColor: accent }}
style={
bannerUrl
? {
backgroundImage: `url("${bannerUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: { backgroundColor: accent }
}
/>
<div className={styles.avatarWrap}>

View File

@@ -117,6 +117,7 @@ export function MobileMemberProfileSheet({
const avatarUrl = profile?.avatarUrl ?? null;
const bio = profile?.aboutMe?.trim();
const accent = (profile as any)?.accentColor || DEFAULT_ACCENT;
const bannerUrl: string | null = (profile as any)?.bannerUrl ?? null;
const storedStatus = (profile?.status as string | undefined) || 'offline';
const presence = mapPresence(resolveStatus(storedStatus, member.userId));
@@ -172,7 +173,15 @@ export function MobileMemberProfileSheet({
<div className={styles.root}>
<div
className={styles.banner}
style={{ backgroundColor: accent }}
style={
bannerUrl
? {
backgroundImage: `url("${bannerUrl}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
: { backgroundColor: accent }
}
>
<div className={styles.bannerHandle} />
</div>

View File

@@ -16,13 +16,17 @@ import { useQuery } from 'convex/react';
import {
CaretLeft,
CaretRight,
ClockCounterClockwise,
Gear,
Prohibit,
ShieldStar,
Smiley,
X,
} from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import {
AuditLogTab,
BansTab,
EmojisTab,
OverviewTab,
type ServerSettingsTab,
@@ -48,6 +52,8 @@ const TABS: Array<{
{ id: 'overview', label: 'Overview', icon: Gear },
{ id: 'roles', label: 'Roles & Permissions', icon: ShieldStar },
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
{ id: 'bans', label: 'Bans', icon: Prohibit },
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
];
function getInitials(name: string): string {
@@ -227,6 +233,8 @@ export function MobileServerSettings({
<div className={`${styles.body} ${styles.bodyPanel}`}>
{activeTab === 'overview' && <OverviewTab />}
{activeTab === 'emojis' && <EmojisTab />}
{activeTab === 'bans' && <BansTab />}
{activeTab === 'audit' && <AuditLogTab />}
</div>
)}
</div>,

View File

@@ -9,7 +9,17 @@
* still hold the old Matrix-based code and are not imported.
*/
import { useMutation, useQuery } from 'convex/react';
import { Gear, Plus, ShieldStar, Smiley, Trash, UploadSimple, X } from '@phosphor-icons/react';
import {
ClockCounterClockwise,
Gear,
Plus,
Prohibit,
ShieldStar,
Smiley,
Trash,
UploadSimple,
X,
} from '@phosphor-icons/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../../../../../convex/_generated/api';
@@ -20,7 +30,7 @@ import { MobileServerSettings } from './MobileServerSettings';
import { useRolesView } from './RolesView';
import userStyles from './UserSettingsModal.module.css';
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis';
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis' | 'bans' | 'audit';
interface ServerSettingsModalProps {
isOpen: boolean;
@@ -32,6 +42,8 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
{ id: 'overview', label: 'Overview', icon: Gear },
{ id: 'roles', label: 'Roles', icon: ShieldStar },
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
{ id: 'bans', label: 'Bans', icon: Prohibit },
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
];
export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
@@ -150,6 +162,8 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
{activeTab === 'overview' && <OverviewTab />}
{inRolesView && rolesView.content}
{activeTab === 'emojis' && <CustomEmojisTab />}
{activeTab === 'bans' && <BansTab />}
{activeTab === 'audit' && <AuditLogTab />}
</div>
</div>
</div>
@@ -342,6 +356,7 @@ const PERMISSION_KEYS = [
'move_members',
'mute_members',
'manage_nicknames',
'ban_members',
] as const;
type PermissionKey = (typeof PERMISSION_KEYS)[number];
@@ -892,3 +907,362 @@ const dangerBtnStyle: React.CSSProperties = {
fontSize: 14,
};
/* ------------------------------------------------------------------- */
/* Bans */
/* ------------------------------------------------------------------- */
function formatRelative(ts: number): string {
const diff = Date.now() - ts;
const s = Math.max(0, Math.floor(diff / 1000));
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 30) return `${d}d ago`;
return new Date(ts).toLocaleDateString();
}
export function BansTab() {
const myUserId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
: null;
const bans = useQuery(api.bans.list, myUserId ? { actorId: myUserId } : 'skip');
const allUsers = useQuery(api.auth.getPublicKeys, {}) ?? [];
const banMutation = useMutation(api.bans.ban);
const unbanMutation = useMutation(api.bans.unban);
const [pickerOpen, setPickerOpen] = useState(false);
const [pickedUserId, setPickedUserId] = useState<string>('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const bannedIdSet = useMemo(
() => new Set((bans ?? []).map((b: any) => b.userId)),
[bans],
);
const bannableUsers = useMemo(
() =>
(allUsers as any[]).filter(
(u) => u.id !== myUserId && !bannedIdSet.has(u.id),
),
[allUsers, bannedIdSet, myUserId],
);
const handleBan = async () => {
if (!myUserId || !pickedUserId) return;
setBusy(true);
setError(null);
try {
await banMutation({
actorId: myUserId,
userId: pickedUserId as Id<'userProfiles'>,
reason: reason.trim() || undefined,
});
setPickerOpen(false);
setPickedUserId('');
setReason('');
} catch (err: any) {
setError(err?.message ?? 'Failed to ban user.');
} finally {
setBusy(false);
}
};
const handleUnban = async (userId: string) => {
if (!myUserId) return;
try {
await unbanMutation({
actorId: myUserId,
userId: userId as Id<'userProfiles'>,
});
} catch (err: any) {
setError(err?.message ?? 'Failed to unban user.');
}
};
return (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Bans</h2>
<p className={userStyles.profileDescription}>
Banned users can't log in or send messages. Unbanning restores access.
</p>
</div>
{error && (
<div
style={{
padding: 10,
marginBottom: 12,
borderRadius: 6,
background: 'rgba(248, 113, 113, 0.12)',
color: '#f87171',
fontSize: 13,
}}
>
{error}
</div>
)}
{pickerOpen ? (
<div
style={{
padding: 16,
marginBottom: 16,
border: '1px solid var(--background-modifier-accent)',
borderRadius: 8,
background: 'var(--background-secondary)',
}}
>
<Label>User</Label>
<select
value={pickedUserId}
onChange={(e) => setPickedUserId(e.target.value)}
style={inputStyle}
>
<option value="">Select a user…</option>
{bannableUsers.map((u: any) => (
<option key={u.id} value={u.id}>
{u.displayName || u.username}
</option>
))}
</select>
<div style={{ height: 12 }} />
<Label>Reason (optional)</Label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
maxLength={200}
style={inputStyle}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<button
type="button"
onClick={handleBan}
disabled={busy || !pickedUserId}
style={dangerBtnStyle}
>
{busy ? 'Banning' : 'Ban user'}
</button>
<button
type="button"
onClick={() => {
setPickerOpen(false);
setPickedUserId('');
setReason('');
setError(null);
}}
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)' }}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
style={{ ...primaryBtnStyle, marginBottom: 16, display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Plus size={16} weight="bold" /> Ban a user
</button>
)}
{bans === undefined ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading bans…</div>
) : bans.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>No one is banned.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{bans.map((b: any) => (
<div
key={b._id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 8,
background: 'var(--background-secondary)',
border: '1px solid var(--background-tertiary)',
}}
>
{b.user?.avatarUrl ? (
<img
src={b.user.avatarUrl}
alt=""
style={{ width: 40, height: 40, borderRadius: '50%' }}
/>
) : (
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-primary)',
fontWeight: 700,
}}
>
{(b.user?.displayName || b.user?.username || '?').slice(0, 1).toUpperCase()}
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
{b.user?.displayName || b.user?.username || 'Unknown user'}
</div>
<div
style={{
color: 'var(--text-secondary)',
fontSize: 12,
marginTop: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{b.reason ? `“${b.reason}” · ` : ''}
banned by {b.actor?.displayName || b.actor?.username || 'unknown'} · {formatRelative(b.createdAt)}
</div>
</div>
<button
type="button"
onClick={() => handleUnban(b.userId)}
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)', color: 'var(--text-primary)' }}
>
Unban
</button>
</div>
))}
</div>
)}
</>
);
}
/* ------------------------------------------------------------------- */
/* Audit Log */
/* ------------------------------------------------------------------- */
const AUDIT_LABELS: Record<string, string> = {
'channel.create': 'created channel',
'channel.delete': 'deleted channel',
'channel.rename': 'renamed channel',
'channel.update_topic': 'updated channel topic',
'role.create': 'created role',
'role.delete': 'deleted role',
'role.update': 'updated role',
'role.assign': 'assigned role',
'role.unassign': 'removed role',
'server.settings_update': 'updated server settings',
'ban.add': 'banned',
'ban.remove': 'unbanned',
};
export function AuditLogTab() {
const myUserId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
: null;
const entries = useQuery(
api.audit.list,
myUserId ? { actorId: myUserId, limit: 200 } : 'skip',
);
return (
<>
<div className={userStyles.profileHeader}>
<h2 className={userStyles.profileSubheading}>Audit Log</h2>
<p className={userStyles.profileDescription}>
Recent admin actions, newest first.
</p>
</div>
{entries === undefined ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading…</div>
) : entries.length === 0 ? (
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
Nothing logged yet.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{entries.map((e: any) => {
const label = AUDIT_LABELS[e.action] ?? e.action;
const actorName = e.actor?.displayName || e.actor?.username || 'Someone';
return (
<div
key={e._id}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
borderRadius: 6,
background: 'var(--background-secondary)',
}}
>
{e.actor?.avatarUrl ? (
<img
src={e.actor.avatarUrl}
alt=""
style={{ width: 28, height: 28, borderRadius: '50%' }}
/>
) : (
<div
style={{
width: 28,
height: 28,
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-primary)',
fontWeight: 700,
fontSize: 12,
}}
>
{actorName.slice(0, 1).toUpperCase()}
</div>
)}
<div
style={{
flex: 1,
minWidth: 0,
color: 'var(--text-primary)',
fontSize: 13,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<strong>{actorName}</strong> {label}
{e.targetName ? <> <strong>{e.targetName}</strong></> : null}
</div>
<div
style={{
color: 'var(--text-secondary)',
fontSize: 12,
flexShrink: 0,
}}
title={new Date(e.createdAt).toLocaleString()}
>
{formatRelative(e.createdAt)}
</div>
</div>
);
})}
</div>
)}
</>
);
}

View File

@@ -12,6 +12,7 @@ import {
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../../../../../convex/_generated/api';
import { useKeybinds } from '../../contexts/KeybindContext';
import { useTheme } from '../../contexts/ThemeContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLogout } from '../../hooks/useLogout';
@@ -218,6 +219,14 @@ export function AccountTab() {
const [joinSoundError, setJoinSoundError] = useState<string | null>(null);
const [joinSoundFilename, setJoinSoundFilename] = useState<string | null>(null);
// Banner state. Same flow as avatar: pick a file → stage a local
// object URL → on Save upload + patch. "Remove" sets a flag that
// instructs the server to clear the stored blob on next save.
const bannerInputRef = useRef<HTMLInputElement>(null);
const [pendingBannerBlob, setPendingBannerBlob] = useState<Blob | null>(null);
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
const [removeBannerPending, setRemoveBannerPending] = useState(false);
useEffect(() => {
if (me) {
setDisplayName(me.displayName ?? '');
@@ -230,8 +239,9 @@ export function AccountTab() {
useEffect(() => {
return () => {
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
};
}, [avatarPreview]);
}, [avatarPreview, bannerPreview]);
const pickAvatar = () => fileInputRef.current?.click();
@@ -278,6 +288,33 @@ export function AccountTab() {
return storageId;
};
const pickBanner = () => bannerInputRef.current?.click();
const handleBannerFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
setStatus('Pick an image file.');
return;
}
if (file.size > AVATAR_MAX_SIZE) {
setStatus('Banner must be under 10 MB.');
return;
}
setPendingBannerBlob(file);
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
setBannerPreview(URL.createObjectURL(file));
setRemoveBannerPending(false);
};
const handleRemoveBanner = () => {
setPendingBannerBlob(null);
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
setRemoveBannerPending(true);
};
const pickJoinSound = () => joinSoundInputRef.current?.click();
const handleJoinSoundFile = async (
@@ -367,8 +404,20 @@ export function AccountTab() {
const storageId = await uploadAvatar(pendingAvatarBlob);
patch.avatarStorageId = storageId;
}
if (pendingBannerBlob) {
const storageId = await uploadAvatar(pendingBannerBlob);
patch.bannerStorageId = storageId;
} else if (removeBannerPending) {
patch.removeBanner = true;
}
await updateProfile(patch as any);
setPendingAvatarBlob(null);
setPendingBannerBlob(null);
setRemoveBannerPending(false);
if (bannerPreview) {
URL.revokeObjectURL(bannerPreview);
setBannerPreview(null);
}
setStatus('Saved');
setTimeout(() => setStatus(null), 1500);
} catch (err: any) {
@@ -379,6 +428,13 @@ export function AccountTab() {
};
const currentAvatar = avatarPreview ?? me?.avatarUrl ?? null;
// Resolve the banner to show in the preview card. Local blob
// beats server URL; a pending "remove" clears both.
const currentBanner = bannerPreview
? bannerPreview
: removeBannerPending
? null
: ((me as any)?.bannerUrl ?? null);
return (
<>
@@ -405,7 +461,12 @@ export function AccountTab() {
<div
style={{
height: 72,
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
background: currentBanner
? undefined
: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
backgroundImage: currentBanner ? `url("${currentBanner}")` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<div style={{ position: 'absolute', top: 36, left: 18 }}>
@@ -477,6 +538,13 @@ export function AccountTab() {
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<input
ref={bannerInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleBannerFile}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FieldRow label="Username" value={me?.username ?? '—'} readOnly />
@@ -546,6 +614,82 @@ export function AccountTab() {
</div>
</div>
<div>
<div
style={{
fontSize: 12,
fontWeight: 700,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--text-tertiary)',
marginBottom: 8,
}}
>
Banner Image
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
{currentBanner ? (
<img
src={currentBanner}
alt="Banner preview"
style={{
width: 160,
height: 48,
borderRadius: 6,
objectFit: 'cover',
border: '1px solid var(--background-modifier-accent)',
}}
/>
) : (
<div
style={{
width: 160,
height: 48,
borderRadius: 6,
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
border: '1px solid var(--background-modifier-accent)',
}}
/>
)}
<button
type="button"
onClick={pickBanner}
style={{
padding: '8px 14px',
borderRadius: 6,
border: '1px solid var(--background-modifier-accent)',
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
cursor: 'pointer',
fontSize: 13,
fontWeight: 600,
}}
>
Upload Banner
</button>
{currentBanner && (
<button
type="button"
onClick={handleRemoveBanner}
style={{
padding: '8px 14px',
borderRadius: 6,
border: '1px solid var(--background-modifier-accent)',
background: 'transparent',
color: 'var(--text-secondary)',
cursor: 'pointer',
fontSize: 13,
}}
>
Remove
</button>
)}
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
Overrides the accent color behind your profile.
</span>
</div>
</div>
<div>
<div
style={{
@@ -891,6 +1035,13 @@ interface VoiceSettings {
echoCancellation: boolean;
autoGainControl: boolean;
recordingDir?: string;
// Voice activity is the default — the green speaking bubble
// already drives this. Push-to-talk is opt-in and paired with the
// `voice.pushToTalk` keybind.
inputMode: 'voice-activity' | 'push-to-talk';
// Short tail after the PTT key is released so the last syllable
// doesn't get chopped. Stored in ms; exposed as a slider in the UI.
pushToTalkReleaseDelayMs: number;
}
const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
@@ -901,6 +1052,8 @@ const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
noiseSuppression: true,
echoCancellation: false,
autoGainControl: true,
inputMode: 'voice-activity',
pushToTalkReleaseDelayMs: 200,
};
function loadVoiceSettings(): VoiceSettings {
@@ -1112,6 +1265,8 @@ export function VoiceTab() {
<MicTest settings={settings} />
</div>
<VoiceInputModeSection settings={settings} update={update} />
<div className={styles.voiceSection}>
<h4 className={styles.voiceSectionTitle}>Audio Processing</h4>
<p className={styles.settingDescription}>
@@ -1533,6 +1688,89 @@ export function SecurityTab() {
* the test is live tears down and re-creates the graph with the
* new constraints.
*/
/**
* Voice input mode — radio group for Voice Activity (default) vs
* Push to Talk, plus a keybind display + release-delay slider when
* PTT is selected. The keybind itself is rebound from the Keybinds
* tab; this is just a convenient inline pointer + shortcut preview.
*/
function VoiceInputModeSection({
settings,
update,
}: {
settings: VoiceSettings;
update: <K extends keyof VoiceSettings>(key: K, value: VoiceSettings[K]) => void;
}) {
const keybinds = useKeybinds();
const pttCombo = keybinds.getCombo('voice.pushToTalk');
return (
<div className={styles.voiceSection}>
<h4 className={styles.voiceSectionTitle}>Input Mode</h4>
<p className={styles.settingDescription}>
Voice Activity transmits whenever you speak. Push to Talk only
transmits while you hold the bound key.
</p>
<label className={styles.voiceRadioRow}>
<input
type="radio"
name="voice-input-mode"
checked={settings.inputMode === 'voice-activity'}
onChange={() => update('inputMode', 'voice-activity')}
/>
<div>
<div className={styles.voiceRadioLabel}>Voice Activity</div>
<div className={styles.voiceRadioHelp}>
Auto-transmit while speaking (default).
</div>
</div>
</label>
<label className={styles.voiceRadioRow}>
<input
type="radio"
name="voice-input-mode"
checked={settings.inputMode === 'push-to-talk'}
onChange={() => update('inputMode', 'push-to-talk')}
/>
<div>
<div className={styles.voiceRadioLabel}>Push to Talk</div>
<div className={styles.voiceRadioHelp}>
Hold a key to transmit. Binding:{' '}
<strong>{pttCombo || 'Unbound — set in Keybinds tab'}</strong>
</div>
</div>
</label>
{settings.inputMode === 'push-to-talk' && (
<div style={{ marginTop: 12 }}>
<div className={styles.voiceVolumeHeader}>
<span className={styles.voiceFieldLabel}>Release Delay</span>
<span className={styles.voiceVolumeValue}>
{settings.pushToTalkReleaseDelayMs}ms
</span>
</div>
<input
type="range"
min={0}
max={1000}
step={20}
value={settings.pushToTalkReleaseDelayMs}
onChange={(e) =>
update('pushToTalkReleaseDelayMs', Number(e.target.value))
}
className={styles.voiceSlider}
/>
<p className={styles.settingDescription} style={{ marginTop: 4 }}>
How long to keep transmitting after you release the key
avoids clipping the end of a word.
</p>
</div>
)}
</div>
);
}
function MicTest({ settings }: { settings: VoiceSettings }) {
const [testing, setTesting] = useState(false);
const [level, setLevel] = useState(0);

View File

@@ -29,6 +29,13 @@ export interface KeybindAction {
description: string;
category: KeybindCategory;
defaultCombo: string;
/** Press-and-hold actions (push-to-talk, walkie-talkie style).
* Instead of a single `brycord:keybind:<id>` event on keydown,
* the dispatcher fires `brycord:keybind:<id>:down` on the first
* keydown (no `e.repeat`) and `brycord:keybind:<id>:up` on
* keyup. These events do NOT preventDefault, so binding PTT to
* a letter doesn't break typing in text fields. */
pressAndHold?: boolean;
}
const DEFAULT_ACTIONS: KeybindAction[] = [
@@ -53,6 +60,15 @@ const DEFAULT_ACTIONS: KeybindAction[] = [
category: 'voice',
defaultCombo: '',
},
{
id: 'voice.pushToTalk',
label: 'Push to Talk',
description:
'Hold to transmit your mic while the voice input mode is set to Push to Talk.',
category: 'voice',
defaultCombo: '',
pressAndHold: true,
},
{
id: 'navigation.goToDMs',
label: 'Go to Direct Messages',
@@ -242,43 +258,109 @@ export function KeybindProvider({ children }: { children: ReactNode }) {
// wins against components that use keydown for their own shortcuts
// — rebinding in settings disables the default behaviour cleanly.
useEffect(() => {
// Track currently-held press-and-hold actions so keydown repeats
// (browser auto-repeat while the key stays pressed) only fire
// a single `:down` event per physical press, and so we can emit
// a matching `:up` when the key is released.
const heldPressAndHold = new Set<string>();
const isInEditableField = (target: EventTarget | null): boolean => {
const el = target as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
el.isContentEditable
);
};
const onKeyDown = (e: KeyboardEvent) => {
// Never intercept keys typed into inputs / contenteditable —
// a bare `Escape` would otherwise cancel active composition.
const target = e.target as HTMLElement | null;
if (target) {
const tag = target.tagName;
if (
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
target.isContentEditable
) {
// Allow Ctrl- / Ctrl+Shift- combinations through —
// those are deliberate shortcuts, never accidental
// typing. Plain keys still wait for focus to leave.
if (!(e.ctrlKey || e.metaKey)) {
return;
}
}
}
const combo = eventToCombo(e);
if (!combo) return;
const editable = isInEditableField(e.target);
for (const action of DEFAULT_ACTIONS) {
if ((combos[action.id] ?? '') === combo) {
e.preventDefault();
e.stopPropagation();
if ((combos[action.id] ?? '') !== combo) continue;
if (action.pressAndHold) {
// Fire `:down` once per physical press. Deliberately
// DO NOT preventDefault — press-and-hold bindings
// coexist with typing so binding PTT to a letter
// doesn't swallow that letter in an input.
if (e.repeat) return;
if (heldPressAndHold.has(action.id)) return;
heldPressAndHold.add(action.id);
window.dispatchEvent(
new CustomEvent(`brycord:keybind:${action.id}`),
new CustomEvent(`brycord:keybind:${action.id}:down`),
);
return;
}
// Non-hold (one-shot) actions: original behaviour —
// swallow the key and fire the action, but only when
// the target is not an editable field (unless the user
// used a Ctrl/Meta shortcut, which is always deliberate).
if (editable && !(e.ctrlKey || e.metaKey)) return;
e.preventDefault();
e.stopPropagation();
window.dispatchEvent(
new CustomEvent(`brycord:keybind:${action.id}`),
);
return;
}
};
const onKeyUp = (e: KeyboardEvent) => {
// Fire `:up` for any currently-held press-and-hold action
// whose combo key was just released. We match on the single
// key (`e.key`) rather than a full combo because the combo
// includes modifiers that may be released in any order.
if (heldPressAndHold.size === 0) return;
const released = e.key.length === 1 ? e.key.toUpperCase() : e.key;
for (const action of DEFAULT_ACTIONS) {
if (!action.pressAndHold) continue;
if (!heldPressAndHold.has(action.id)) continue;
const combo = combos[action.id] ?? '';
if (!combo) continue;
// `combo` is like "Ctrl+Shift+V" — the final segment is
// the main key. A release of any of the component keys
// counts as "stop holding".
const parts = combo.split('+');
if (parts.includes(released) || released === 'Control' ||
released === 'Shift' || released === 'Alt' ||
released === 'Meta') {
heldPressAndHold.delete(action.id);
window.dispatchEvent(
new CustomEvent(`brycord:keybind:${action.id}:up`),
);
}
}
};
// Safety net — if focus leaves the window while a PTT key is
// held, browsers usually don't fire keyup. Release everything
// so the mic doesn't stay hot forever.
const onBlur = () => {
for (const id of heldPressAndHold) {
window.dispatchEvent(
new CustomEvent(`brycord:keybind:${id}:up`),
);
}
heldPressAndHold.clear();
};
window.addEventListener('keydown', onKeyDown, { capture: true });
window.addEventListener('keyup', onKeyUp, { capture: true });
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown, {
capture: true,
} as EventListenerOptions);
window.removeEventListener('keyup', onKeyUp, {
capture: true,
} as EventListenerOptions);
window.removeEventListener('blur', onBlur);
};
}, [combos]);

View File

@@ -92,6 +92,84 @@ export const VoiceProvider = ({ children }) => {
const [isReconnecting, setIsReconnecting] = useState(false);
const [connectionQualities, setConnectionQualities] = useState({});
// Voice-input mode state. "voice-activity" is the default and the
// LiveKit track stays enabled whenever the user isn't muted. In
// "push-to-talk" we flip the mic off until the bound key is held.
// Settings live in localStorage (see UserSettingsModal) and are
// broadcast via `brycord:voice-settings-changed` on change.
const [inputMode, setInputMode] = useState('voice-activity');
const [pttReleaseDelayMs, setPttReleaseDelayMs] = useState(200);
const [isPttActive, setIsPttActive] = useState(false);
const pttReleaseTimerRef = useRef(null);
useEffect(() => {
const readSettings = () => {
try {
const raw = localStorage.getItem('voiceSettings');
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed?.inputMode === 'push-to-talk' || parsed?.inputMode === 'voice-activity') {
setInputMode(parsed.inputMode);
}
if (typeof parsed?.pushToTalkReleaseDelayMs === 'number') {
setPttReleaseDelayMs(parsed.pushToTalkReleaseDelayMs);
}
} catch {
/* ignore malformed blob */
}
};
readSettings();
const onChange = () => readSettings();
window.addEventListener('brycord:voice-settings-changed', onChange);
return () => window.removeEventListener('brycord:voice-settings-changed', onChange);
}, []);
// Subscribe to the `voice.pushToTalk` keybind's down/up events while
// PTT mode is selected. On release, honor the configured delay
// before flipping the mic off so the last syllable isn't clipped.
useEffect(() => {
if (inputMode !== 'push-to-talk') {
// Flipping back to voice activity resets any pending hold
// so the next keydown starts fresh.
setIsPttActive(false);
if (pttReleaseTimerRef.current) {
clearTimeout(pttReleaseTimerRef.current);
pttReleaseTimerRef.current = null;
}
return;
}
const onDown = () => {
if (pttReleaseTimerRef.current) {
clearTimeout(pttReleaseTimerRef.current);
pttReleaseTimerRef.current = null;
}
setIsPttActive(true);
};
const onUp = () => {
if (pttReleaseDelayMs <= 0) {
setIsPttActive(false);
return;
}
if (pttReleaseTimerRef.current) {
clearTimeout(pttReleaseTimerRef.current);
}
pttReleaseTimerRef.current = setTimeout(() => {
setIsPttActive(false);
pttReleaseTimerRef.current = null;
}, pttReleaseDelayMs);
};
window.addEventListener('brycord:keybind:voice.pushToTalk:down', onDown);
window.addEventListener('brycord:keybind:voice.pushToTalk:up', onUp);
return () => {
window.removeEventListener('brycord:keybind:voice.pushToTalk:down', onDown);
window.removeEventListener('brycord:keybind:voice.pushToTalk:up', onUp);
if (pttReleaseTimerRef.current) {
clearTimeout(pttReleaseTimerRef.current);
pttReleaseTimerRef.current = null;
}
};
}, [inputMode, pttReleaseDelayMs]);
// Voice recording — crash-safe per-participant audio capture.
// Only available on Electron (`platform.features.hasRecording`).
// `recordingError` is surfaced once per failure so UI can toast
@@ -759,15 +837,18 @@ export const VoiceProvider = ({ children }) => {
}
}, [voiceStates, activeChannelId, room, convex, myUserId]);
// Enforce server mute: force-disable mic when server muted, restore when lifted
// Reconcile the mic track against every source of "mic should be
// off": user mute, deafen, server mute, and — when the input mode
// is push-to-talk — the PTT not being currently held. Runs on any
// change so the UI stays in sync without each feature owning its
// own enable/disable path.
useEffect(() => {
if (!myUserId || !room) return;
if (isServerMuted(myUserId)) {
room.localParticipant.setMicrophoneEnabled(false);
} else if (!isMuted && !isDeafened) {
room.localParticipant.setMicrophoneEnabled(true);
}
}, [voiceStates, room, myUserId]);
const serverMuted = isServerMuted(myUserId);
const pttBlocks = inputMode === 'push-to-talk' && !isPttActive;
const shouldEnable = !isMuted && !isDeafened && !serverMuted && !pttBlocks;
room.localParticipant.setMicrophoneEnabled(shouldEnable);
}, [voiceStates, room, myUserId, isMuted, isDeafened, inputMode, isPttActive]);
// Re-apply personal mutes/volumes when room or participants change
useEffect(() => {

View File

@@ -893,6 +893,12 @@ img[alt] {
100% { background-color: transparent; }
}
@keyframes brycord-record-pulse {
0% { box-shadow: 0 0 0 0 rgba(218, 55, 60, 0.55); }
70% { box-shadow: 0 0 0 10px rgba(218, 55, 60, 0); }
100% { box-shadow: 0 0 0 0 rgba(218, 55, 60, 0); }
}
.searchHighlight {
animation: searchFlash 2s ease-out;
}

View File

@@ -52,6 +52,14 @@
* @property {() => void} close
*/
/**
* @typedef {Object} PlatformNotifications
* @property {(opts: {title: string, body?: string, silent?: boolean}) => void|Promise<void>} show - Show a desktop/system notification
* @property {(count: number) => void} setBadge - Set unread badge/overlay count (0 clears)
* @property {(on: boolean) => void} flashFrame - Flash the window/taskbar to draw attention
* @property {() => Promise<'granted'|'denied'|'default'|'unavailable'>} ensurePermission - Request permission if needed; resolves the current state
*/
/**
* @typedef {Object} PlatformRecording
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
@@ -109,6 +117,7 @@
* @property {boolean} hasVoiceService
* @property {boolean} hasSystemBars
* @property {boolean} [hasBackButton]
* @property {boolean} [hasNotifications]
*/
/**
@@ -120,6 +129,7 @@
* @property {PlatformLinks} links
* @property {PlatformScreenCapture|null} screenCapture
* @property {PlatformWindowControls|null} windowControls
* @property {PlatformNotifications|null} notifications
* @property {PlatformRecording|null} recording
* @property {PlatformUpdates|null} updates
* @property {PlatformSearchDB|null} searchDB