1.1.3
This commit is contained in:
@@ -11,6 +11,50 @@ import SearchDatabase from '@discord-clone/shared/src/utils/SearchDatabase';
|
||||
|
||||
const searchDB = new SearchDatabase(searchStorage, crypto);
|
||||
|
||||
function makeWebNotifications() {
|
||||
if (typeof window === 'undefined' || typeof window.Notification === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
show({ title, body, silent }) {
|
||||
if (Notification.permission !== 'granted') return;
|
||||
try {
|
||||
const n = new Notification(String(title ?? 'Brycord'), {
|
||||
body: String(body ?? ''),
|
||||
silent: !!silent,
|
||||
});
|
||||
n.onclick = () => {
|
||||
try { window.focus(); } catch {}
|
||||
try { n.close(); } catch {}
|
||||
};
|
||||
} catch {}
|
||||
},
|
||||
// Web has the Badging API on some browsers (Chrome, Edge). No-op
|
||||
// where unavailable instead of throwing.
|
||||
setBadge(count) {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
try {
|
||||
if (n === 0) navigator.clearAppBadge?.();
|
||||
else navigator.setAppBadge?.(n);
|
||||
} catch {}
|
||||
},
|
||||
// Web has no taskbar-flash equivalent — title bounce is the
|
||||
// closest thing but gets intrusive fast. No-op for now.
|
||||
flashFrame() {},
|
||||
async ensurePermission() {
|
||||
if (!('Notification' in window)) return 'unavailable';
|
||||
if (Notification.permission === 'granted') return 'granted';
|
||||
if (Notification.permission === 'denied') return 'denied';
|
||||
try {
|
||||
const result = await Notification.requestPermission();
|
||||
return result;
|
||||
} catch {
|
||||
return 'default';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const webPlatform = {
|
||||
crypto,
|
||||
session,
|
||||
@@ -34,6 +78,7 @@ const webPlatform = {
|
||||
},
|
||||
},
|
||||
windowControls: null,
|
||||
notifications: makeWebNotifications(),
|
||||
recording: null,
|
||||
updates: null,
|
||||
voiceService: null,
|
||||
@@ -47,6 +92,7 @@ const webPlatform = {
|
||||
hasVoiceService: false,
|
||||
hasSystemBars: false,
|
||||
hasRecording: false,
|
||||
hasNotifications: typeof window !== 'undefined' && typeof window.Notification !== 'undefined',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -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')}</>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
214
packages/shared/src/components/channel/VoiceMessagePlayer.tsx
Normal file
214
packages/shared/src/components/channel/VoiceMessagePlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
194
packages/shared/src/components/layout/HeaderUpdateIcon.tsx
Normal file
194
packages/shared/src/components/layout/HeaderUpdateIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
packages/shared/src/components/layout/NotificationManager.tsx
Normal file
112
packages/shared/src/components/layout/NotificationManager.tsx
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user