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

This commit is contained in:
Bryan1029384756
2026-04-14 20:03:54 -05:00
parent b7a4cf4ce8
commit 965048f7d2
47 changed files with 2558 additions and 135 deletions

View File

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

Binary file not shown.

View File

@@ -209,6 +209,19 @@
padding: 4px 12px;
}
/* GIFs tab — the GifPicker owns its own `.searchRow` + `.body`
layout and needs to paint edge-to-edge so its
`--background-primary` body fills the picker surface. Strips the
`.grid` wrapper's padding so the hairline + body bleed to the
picker's border. */
.gridGifs {
padding: 0;
}
.pickerMobile .gridGifs {
padding: 0;
}
/* ── Collapsible section (desktop only) ───────────────────────── */
.section {
margin-top: 4px;

View File

@@ -134,6 +134,12 @@ export function EmojiPicker({
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [hovered, setHovered] = useState<EmojiPickerValue | null>(null);
const [recents, setRecents] = useState<EmojiPickerValue[]>(() => loadRecents());
// Saved-media filter chips. `all` shows every row; the others
// filter by the `kind` string that `api.savedMedia.save` writes
// (first half of the MIME type: image / video / audio).
const [mediaFilter, setMediaFilter] = useState<
'all' | 'image' | 'video' | 'audio'
>('all');
// Saved-media library — only fetched when the Media tab is open
// to keep the picker cheap during normal emoji use.
@@ -393,7 +399,7 @@ export function EmojiPicker({
return (
<div className={`${styles.picker} ${styles.pickerMobile}`}>
<div className={styles.searchBar}>
<MagnifyingGlass size={16} className={styles.searchIcon} />
<MagnifyingGlass size={16} weight="regular" className={styles.searchIcon} />
<input
ref={searchRef}
className={styles.searchInput}
@@ -523,16 +529,24 @@ export function EmojiPicker({
</div>
{activeTab !== 'gifs' && (
<div className={styles.searchRow}>
<div
className={`${styles.searchRow} ${activeTab === 'media' ? styles.searchRowFlush : ''}`}
>
<div className={styles.searchBar}>
<MagnifyingGlass size={16} className={styles.searchIcon} />
<MagnifyingGlass size={16} weight="regular" className={styles.searchIcon} />
<input
ref={searchRef}
className={styles.searchInput}
placeholder={activeTab === 'emojis' ? 'Search emoji' : 'Coming soon'}
placeholder={
activeTab === 'emojis'
? 'Search emoji'
: activeTab === 'media'
? 'Search media'
: 'Coming soon'
}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={activeTab !== 'emojis'}
disabled={activeTab !== 'emojis' && activeTab !== 'media'}
/>
{search && (
<button type="button" className={styles.searchClear} onClick={() => setSearch('')}>
@@ -543,7 +557,34 @@ export function EmojiPicker({
</div>
)}
<div className={styles.main}>
{activeTab === 'media' && (
<div className={styles.filterChips}>
{(
[
{ id: 'all', label: 'All' },
{ id: 'image', label: 'Images' },
{ id: 'video', label: 'Videos' },
{ id: 'audio', label: 'Audio' },
] as const
).map((chip) => {
const isActive = mediaFilter === chip.id;
return (
<button
key={chip.id}
type="button"
className={`${styles.filterChip} ${isActive ? styles.filterChipActive : ''}`}
onClick={() => setMediaFilter(chip.id)}
>
{chip.label}
</button>
);
})}
</div>
)}
<div
className={`${styles.main} ${activeTab === 'media' ? styles.mainMedia : ''}`}
>
{activeTab === 'emojis' && (
<div className={styles.sideBar}>
{customEmojis.length > 0 &&
@@ -554,7 +595,11 @@ export function EmojiPicker({
</div>
)}
<div className={styles.grid} ref={gridRef} onScroll={handleScroll}>
<div
className={`${styles.grid} ${activeTab === 'gifs' ? styles.gridGifs : ''}`}
ref={gridRef}
onScroll={handleScroll}
>
{activeTab === 'gifs' ? (
<GifPicker
onSelectGif={(url) => {
@@ -563,12 +608,38 @@ export function EmojiPicker({
}}
/>
) : activeTab === 'media' ? (
savedMedia.length === 0 ? (
<div className={styles.comingSoon}>
Nothing saved yet. Star an attachment to bookmark it
here for quick re-sharing.
</div>
) : (
(() => {
// Filter the saved library by the active chip + the
// search box (case-insensitive filename substring).
const q = search.trim().toLowerCase();
const filteredSaved = (savedMedia as any[]).filter((item) => {
if (
mediaFilter !== 'all' &&
(item.kind ?? '') !== mediaFilter
) {
return false;
}
if (q && !(item.filename ?? '').toLowerCase().includes(q)) {
return false;
}
return true;
});
if (savedMedia.length === 0) {
return (
<div className={styles.comingSoon}>
Nothing saved yet. Star an attachment to bookmark it
here for quick re-sharing.
</div>
);
}
if (filteredSaved.length === 0) {
return (
<div className={styles.comingSoon}>
No saved media match your filter.
</div>
);
}
return (
<div
style={{
display: 'grid',
@@ -577,7 +648,7 @@ export function EmojiPicker({
padding: 8,
}}
>
{savedMedia.map((item: any) => {
{filteredSaved.map((item: any) => {
const isImage = item.kind === 'image';
const isVideo = item.kind === 'video';
return (
@@ -632,7 +703,8 @@ export function EmojiPicker({
);
})}
</div>
)
);
})()
) : activeTab !== 'emojis' ? (
<div className={styles.comingSoon}>
{EXPRESSION_TABS.find((t) => t.key === activeTab)?.label} are coming soon.

View File

@@ -7,8 +7,31 @@
flex-direction: column;
height: 100%;
min-height: 0;
box-sizing: border-box;
}
/* Matches the emoji picker's search row — full-width slot with a
bottom hairline, holding a rounded `.searchBar` pill inside. */
.searchRow {
padding: 10px 12px;
border-bottom: 1px solid var(--background-modifier-hover);
flex-shrink: 0;
}
/* Body wrapper — everything below the search row. Uses
`--background-primary` so the categories / featured / grid
content reads against a lighter surface instead of the picker's
tertiary base. */
.body {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
background-color: var(--background-primary);
overflow: hidden;
box-sizing: border-box;
}
@@ -23,6 +46,14 @@
flex-shrink: 0;
}
/* Kill any :focus-within highlight that global styles might paint
on the search bar wrapper when the input gains focus. */
.searchBar:focus-within {
outline: none;
box-shadow: none;
border-color: var(--background-modifier-accent);
}
.searchIcon {
color: var(--text-tertiary);
flex-shrink: 0;
@@ -39,6 +70,15 @@
min-width: 0;
}
/* Some UA stylesheets still paint a focus ring via `:focus-visible`
even with `outline: none` on the base rule. Nuke both explicitly. */
.searchInput:focus,
.searchInput:focus-visible {
outline: none;
box-shadow: none;
border: none;
}
.featuredRow {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -87,6 +127,83 @@
letter-spacing: 0.01em;
}
/* ── Category grid (home view) ─────────────────────────────────
Discord-style 2-column tiles, each backed by a still frame of
a real GIF from that category. A dark gradient overlay keeps
the label readable against anything — bright cartoons, night
scenes, black-and-white clips. */
.categoriesGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: max-content;
gap: 8px;
overflow-y: auto;
min-height: 0;
flex: 1;
padding-bottom: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.categoriesGrid::-webkit-scrollbar {
display: none;
}
.categoryTile {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 10px 12px;
aspect-ratio: 16 / 9;
width: 100%;
min-width: 0;
min-height: 0;
border-radius: 8px;
border: none;
background-color: var(--background-tertiary);
color: #ffffff;
cursor: pointer;
overflow: hidden;
transition: transform 0.12s ease;
}
.categoryTile:hover {
transform: translateY(-1px);
}
.categoryTileImage {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.categoryTile::after {
/* Uniformly dark scrim so the centred label reads cleanly
against any preview — bright cartoons, night scenes,
black-and-white clips. */
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.categoryTileLabel {
position: relative;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: capitalize;
text-align: center;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
z-index: 1;
}
.subHeaderRow {
display: flex;
align-items: center;

View File

@@ -33,6 +33,12 @@ interface GifResult {
height?: number;
}
interface GifCategory {
name: string;
image: string;
query: string;
}
interface GifPickerProps {
onSelectGif: (url: string) => void;
}
@@ -65,6 +71,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
const [tab, setTab] = useState<Tab>('home');
const [trending, setTrending] = useState<GifResult[]>([]);
const [searchResults, setSearchResults] = useState<GifResult[]>([]);
const [categories, setCategories] = useState<GifCategory[]>([]);
const [favorites, setFavorites] = useState<GifResult[]>(() =>
loadFavorites(),
);
@@ -73,19 +80,26 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
const searchAction = useAction(api.gifs.search);
const trendingAction = useAction(api.gifs.trending);
const categoriesAction = useAction(api.gifs.categories);
// Load trending feed once when the picker mounts. The result is
// cached for the rest of the session — no need to refetch every
// time the user toggles back to the home tab.
// Load trending feed + categories once when the picker mounts.
// Both are cached server-side (convex/gifs.ts in-memory TTL) so
// toggling tabs or re-opening the picker doesn't re-hit Klipy.
// The two requests run in parallel so first paint shows the
// category chips even if trending is still loading.
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res: any = await trendingAction({ limit: 24 });
const [trendingRes, categoriesRes] = await Promise.all([
trendingAction({ limit: 24 }),
categoriesAction({}),
]);
if (cancelled) return;
setTrending(res?.results ?? []);
setTrending((trendingRes as any)?.results ?? []);
setCategories((categoriesRes as any)?.categories ?? []);
} catch (err: any) {
if (cancelled) return;
setError(err?.message ?? 'Failed to load GIFs.');
@@ -96,7 +110,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
return () => {
cancelled = true;
};
}, [trendingAction]);
}, [trendingAction, categoriesAction]);
// Debounced search — fires 350ms after the last keystroke so we
// don't hammer the upstream API on every character.
@@ -142,36 +156,49 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
// Decide which list to render. Searching always wins — once the
// user types anything, we show the search results regardless of
// which featured tab was active.
// which featured tab was active. `home` is now a distinct surface
// (featured row + categories grid) and doesn't use `displayList`
// at all.
const isSearching = search.trim().length > 0;
const displayList: GifResult[] = useMemo(() => {
if (isSearching) return searchResults;
if (tab === 'favorites') return favorites;
if (tab === 'trending') return trending;
// Home → trending
return trending;
return [];
}, [isSearching, searchResults, tab, favorites, trending]);
const showFeaturedRow = !isSearching && tab === 'home';
const showCategories = !isSearching && tab === 'home';
const handlePickCategory = (category: GifCategory) => {
// Piping the category name into the search box lets the
// existing debounced search effect do the work — same code
// path as typing "happy birthday" by hand, so results are
// cached and consistent.
setSearch(category.query || category.name);
};
return (
<div className={styles.root}>
<div className={styles.searchBar}>
<MagnifyingGlass
size={16}
weight="regular"
className={styles.searchIcon}
/>
<input
type="text"
className={styles.searchInput}
placeholder="Search Tenor"
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
<div className={styles.searchRow}>
<div className={styles.searchBar}>
<MagnifyingGlass
size={16}
weight="regular"
className={styles.searchIcon}
/>
<input
type="text"
className={styles.searchInput}
placeholder="Search Klipy"
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
</div>
</div>
<div className={styles.body}>
{showFeaturedRow && (
<div className={styles.featuredRow}>
<button
@@ -208,7 +235,38 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
</div>
)}
{loading && displayList.length === 0 ? (
{showCategories ? (
loading && categories.length === 0 ? (
<div className={styles.statusMessage}>Loading categories</div>
) : error && categories.length === 0 ? (
<div className={styles.statusMessageError}>{error}</div>
) : categories.length === 0 ? (
<div className={styles.statusMessage}>No categories to show.</div>
) : (
<div className={styles.categoriesGrid}>
{categories.map((cat) => (
<button
key={`${cat.name}-${cat.query}`}
type="button"
className={styles.categoryTile}
onClick={() => handlePickCategory(cat)}
title={cat.name}
>
{cat.image && (
<img
src={cat.image}
alt=""
className={styles.categoryTileImage}
loading="lazy"
draggable={false}
/>
)}
<span className={styles.categoryTileLabel}>{cat.name}</span>
</button>
))}
</div>
)
) : loading && displayList.length === 0 ? (
<div className={styles.statusMessage}>Loading GIFs</div>
) : error ? (
<div className={styles.statusMessageError}>{error}</div>
@@ -255,6 +313,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -14,6 +14,20 @@
margin-top: 4px;
}
/* Direct media embeds (GIFs, raw videos, raw images) don't need
the brand-coloured left bar or the card chrome — the media IS
the content. Strip the card so it reads as inline media. */
.embedBare {
background: transparent;
border: none;
border-left: none;
padding: 0;
}
.directGif {
margin-top: 4px;
}
.grid {
overflow: hidden;
padding: 12px 12px 14px 12px;

View File

@@ -3,6 +3,7 @@ import { ArrowSquareOut, Play } from '@phosphor-icons/react';
import { useAction } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { PausedGif } from './PausedGif';
import styles from './LinkEmbed.module.css';
interface UrlPreview {
@@ -125,7 +126,15 @@ function useUrlPreview(url: string): UrlPreview | null {
return preview;
}
function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image' }) {
function DirectMediaEmbed({
url,
type,
onOpenGif,
}: {
url: string;
type: 'video' | 'image';
onOpenGif?: (url: string) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
@@ -138,7 +147,7 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
};
return (
<div className={styles.embed}>
<div className={`${styles.embed} ${styles.embedBare}`}>
<div className={styles.directVideoWrapper}>
<video
ref={videoRef}
@@ -172,8 +181,20 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
);
}
// GIFs (and other direct images) render through PausedGif so
// the idle state freezes on the first frame and only animates
// on hover. Click opens a fullscreen viewer via `onOpenGif` —
// the parent MessageGroup owns the lightbox state and reuses
// its existing ImageLightbox.
const isGif = /\.gif(\?|#|$)/i.test(url);
if (isGif) {
return (
<PausedGif url={url} onOpen={onOpenGif} className={styles.directGif} />
);
}
return (
<div className={styles.embed}>
<div className={`${styles.embed} ${styles.embedBare}`}>
<a href={url} target="_blank" rel="noopener noreferrer">
<img
className={styles.directImage}
@@ -188,12 +209,16 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
interface LinkEmbedProps {
url: string;
/** Called when the user clicks a paused GIF preview. The parent
* owns the fullscreen viewer state (MessageGroup reuses its
* existing ImageLightbox). Ignored for non-GIF embeds. */
onOpenGif?: (url: string) => void;
}
export function LinkEmbed({ url }: LinkEmbedProps) {
export function LinkEmbed({ url, onOpenGif }: LinkEmbedProps) {
const directType = isDirectMedia(url);
if (directType) {
return <DirectMediaEmbed url={url} type={directType} />;
return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
}
const preview = useUrlPreview(url);

View File

@@ -309,9 +309,9 @@
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
padding: 0.125rem 0.375rem;
border-radius: var(--radius-lg);
background-color: var(--background-secondary);
background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border: 1px solid transparent;
cursor: pointer;
font-size: 0.875rem;
@@ -329,9 +329,21 @@
}
.reactionCount {
font-size: 0.75rem;
font-size: 16px;
color: var(--text-secondary);
font-weight: 500;
font-weight: 600;
}
/* Emoji glyph inside a reaction chip — shared between the custom
<img> path and the unicode TwemojiImg path so both chip styles
have identical box sizing. `rem` units scale with the user's
font size instead of hard-locking to 16px. */
.reactionEmoji {
width: 1.25rem;
height: 1.25rem;
margin: 0.125rem 0;
object-fit: contain;
vertical-align: middle;
}
/* Custom MSC2545 emoji reaction — rendered as an inline image in the

View File

@@ -41,9 +41,43 @@ function extractUrls(text: string): string[] {
return Array.from(new Set(cleaned));
}
/** True when a message body is entirely made up of one or more GIF
* URLs plus whitespace — i.e. the user posted a GIF from the
* picker and there's nothing worth showing as text. The render
* path hides the <MessageContent> block in that case so only the
* embedded preview appears. */
function isGifOnlyContent(text: string): boolean {
const urls = extractUrls(text);
if (urls.length === 0) return false;
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
let remainder = text;
for (const u of urls) {
remainder = remainder.split(u).join('');
}
return remainder.trim().length === 0;
}
/**
* Discord-style relative timestamp:
* - Same calendar day → `Today at 7:08 PM`
* - Previous calendar day → `Yesterday at 7:08 PM`
* - Anything else → `4/11/2026, 7:08 PM`
*/
function formatTime(ts: number): string {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const now = new Date();
const time = date.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
const isToday = date.toDateString() === now.toDateString();
if (isToday) return `Today at ${time}`;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return `Yesterday at ${time}`;
}
return `${date.toLocaleDateString()}, ${time}`;
}
function formatFullTime(ts: number): string {
@@ -96,12 +130,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
} | null>(null);
// Image lightbox state — tracks the decrypted blob URL + the
// full attachment metadata of the image that was clicked so the
// lightbox info card can render filename / size / dimensions.
// Null means the lightbox is closed.
// optional attachment metadata of the image that was clicked so
// the lightbox info card can render filename / size / dimensions.
// Null means the lightbox is closed. For inline GIFs posted via
// a URL (no encrypted attachment), the metadata is absent — the
// lightbox gracefully hides the star / detail chrome in that
// case.
const [lightboxItem, setLightboxItem] = useState<{
src: string;
attachment: AttachmentMetadata;
attachment?: AttachmentMetadata;
filename?: string;
mimeType?: string;
} | null>(null);
// Right-click context menu state. When set, the MessageActionBar for
@@ -437,7 +476,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
})}
</span>
)}
{msg.content && (
{msg.content && !isGifOnlyContent(msg.content) && (
<div className={styles.text}>
<MessageContent
content={msg.content}
@@ -453,7 +492,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
extractUrls(msg.content)
.slice(0, 3)
.map((url, idx) => (
<LinkEmbed key={`embed-${idx}-${url}`} url={url} />
<LinkEmbed
key={`embed-${idx}-${url}`}
url={url}
onOpenGif={(gifUrl) =>
setLightboxItem({
src: gifUrl,
filename: gifUrl.split('/').pop() || 'gif',
mimeType: 'image/gif',
})
}
/>
))}
{msg.attachments.length > 0 && (
<div className={styles.attachments}>
@@ -578,17 +627,13 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
alt={`:${r.emoji}:`}
title={`:${r.emoji}:`}
draggable={false}
style={{
width: 16,
height: 16,
objectFit: 'contain',
verticalAlign: 'middle',
}}
className={styles.reactionEmoji}
/>
) : (
<TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)}
size={16}
className={styles.reactionEmoji}
/>
)}
<span className={styles.reactionCount}>{r.count}</span>
@@ -623,11 +668,15 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
<ImageLightbox
isOpen={!!lightboxItem}
src={lightboxItem?.src ?? ''}
filename={lightboxItem?.attachment.filename}
mimeType={lightboxItem?.attachment.mimeType}
size={lightboxItem?.attachment.size}
width={lightboxItem?.attachment.width}
height={lightboxItem?.attachment.height}
filename={
lightboxItem?.attachment?.filename ?? lightboxItem?.filename
}
mimeType={
lightboxItem?.attachment?.mimeType ?? lightboxItem?.mimeType
}
size={lightboxItem?.attachment?.size}
width={lightboxItem?.attachment?.width}
height={lightboxItem?.attachment?.height}
attachment={lightboxItem?.attachment}
onClose={() => setLightboxItem(null)}
/>

View File

@@ -107,6 +107,42 @@
background-color: var(--background-modifier-accent);
}
/* ── New-messages divider ─────────────────────────────────────────
Red hairline with a "NEW" pill on the right edge. Rendered between
the last message the user has already read and the first unread
one. The divider anchor is snapshotted on channel open so it stays
put during the session — live new messages arriving while the user
is reading don't push it further down the list. */
.newDivider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 1rem 0.5rem;
color: var(--status-danger, #ed4245);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
user-select: none;
}
.newDivider::before {
content: '';
flex: 1;
height: 1px;
background-color: var(--status-danger, #ed4245);
}
.newDividerBadge {
padding: 2px 6px;
border-radius: 4px;
background-color: var(--status-danger, #ed4245);
color: #ffffff;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
}
/* ── Jump highlight ───────────────────────────────────────────────
Pulsed background applied to a message row by the
`brycord:scroll-to-message` listener. Class is added globally

View File

@@ -1,4 +1,4 @@
import { usePaginatedQuery, useQuery } from 'convex/react';
import { 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';
@@ -97,6 +97,21 @@ function DayDivider({ timestamp }: { timestamp: number }) {
);
}
/**
* Red "NEW" line inserted between the last message the viewer has
* already read and the first unseen one. Anchored to a snapshot of
* `lastReadTimestamp` taken when the channel opened, so live new
* messages arriving during the session don't push the divider
* further down.
*/
function NewMessagesDivider() {
return (
<div className={styles.newDivider} role="separator">
<span className={styles.newDividerBadge}>New</span>
</div>
);
}
export function Messages({ channelId, onReply }: MessagesProps) {
const { crypto } = usePlatform();
const scrollerRef = useRef<HTMLDivElement>(null);
@@ -124,6 +139,99 @@ export function Messages({ channelId, onReply }: MessagesProps) {
userId ? { userId: userId as any } : 'skip',
);
// ── Read-state + "NEW" divider plumbing ─────────────────────────
//
// `readState` is the server's live record of the latest message
// timestamp this user has acknowledged for the current channel.
// `readSnapshot` captures the value at the time the channel was
// opened so the divider stays anchored in place even after we
// flush a later `markRead` mutation during the session. Stored
// as state (not a ref) so the first render after the query
// resolves picks it up deterministically.
const readState = useQuery(
api.readState.getReadState,
userId && channelId
? { userId: userId as any, channelId: channelId as any }
: 'skip',
);
const markRead = useMutation(api.readState.markRead);
const [readSnapshot, setReadSnapshot] = useState<{
channelId: string;
lastRead: number;
} | null>(null);
// Tracks the freshest timestamp we've observed in this channel so
// the mark-read flush has something to send. Pure ref — updates
// should never trigger a re-render.
const latestSeenTimestampRef = useRef<number>(0);
// Debounce timer for batched mark-read flushes.
const markReadTimerRef = useRef<number | null>(null);
// Reset the snapshot whenever the user changes channels.
useLayoutEffect(() => {
setReadSnapshot(null);
latestSeenTimestampRef.current = 0;
if (markReadTimerRef.current !== null) {
window.clearTimeout(markReadTimerRef.current);
markReadTimerRef.current = null;
}
}, [channelId]);
// First time a non-null `readState` arrives for the current
// channel, lock it in as the divider anchor. `null` (no stored
// read state yet — brand-new channel) is treated as "0" so the
// divider appears the moment anyone posts.
useEffect(() => {
if (!channelId) return;
if (readSnapshot?.channelId === channelId) return;
if (readState === undefined) return;
setReadSnapshot({
channelId,
lastRead: readState?.lastReadTimestamp ?? 0,
});
}, [readState, channelId, readSnapshot]);
/** Fire-and-forget mark-read flush. Gated by:
* - an authenticated user
* - a channel loaded
* - the window being visible (otherwise we keep the snapshot
* and the NEW line so the user sees it when they return)
* - the user being pinned to the bottom of the scroller
* - the server's stored timestamp being strictly older than
* the freshest message we've observed */
const flushMarkRead = useCallback(() => {
if (!userId || !channelId) return;
if (
typeof document !== 'undefined' &&
document.visibilityState === 'hidden'
) {
return;
}
if (!pinnedRef.current) return;
const ts = latestSeenTimestampRef.current;
if (!ts) return;
const serverTs = readState?.lastReadTimestamp ?? 0;
if (ts <= serverTs) return;
void markRead({
userId: userId as any,
channelId: channelId as any,
lastReadTimestamp: ts,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, channelId, readState, markRead]);
const scheduleMarkRead = useCallback(() => {
if (markReadTimerRef.current !== null) {
window.clearTimeout(markReadTimerRef.current);
}
// 600ms debounce — batches bursts of new messages into a
// single mutation without feeling laggy to users who watch
// the sidebar unread dot.
markReadTimerRef.current = window.setTimeout(() => {
markReadTimerRef.current = null;
flushMarkRead();
}, 600);
}, [flushMarkRead]);
// Walk every bundle we have, decrypt the ones tagged for this
// channel, and build a {version → keyHex} map. A single bundle's
// plaintext is a JSON object mapping channelId → keyHex (legacy
@@ -683,6 +791,58 @@ export function Messages({ channelId, onReply }: MessagesProps) {
return list;
}, [groups, pollsInChannel]);
// Whenever the timeline grows past the freshest timestamp we've
// seen, update the ref and schedule a debounced mark-read. The
// ref-only update doesn't cause re-renders — it just feeds the
// mark-read flush with an up-to-date target.
useEffect(() => {
if (items.length === 0) return;
const newest = items[items.length - 1].ts;
if (newest > latestSeenTimestampRef.current) {
latestSeenTimestampRef.current = newest;
scheduleMarkRead();
}
}, [items, scheduleMarkRead]);
// Window visibility → when the tab comes back into focus, flush
// any pending mark-read so the sidebar dot disappears without
// needing a new message to land.
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'visible') {
scheduleMarkRead();
}
};
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('focus', onVisibility);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('focus', onVisibility);
};
}, [scheduleMarkRead]);
// Channel unmount (navigated elsewhere / logged out) → fire a
// final mark-read with whatever the latest observed timestamp
// is, bypassing the debounce. Matches Fluxer's "leaving a
// channel marks it read" UX so the sidebar dot doesn't linger.
useEffect(() => {
return () => {
if (markReadTimerRef.current !== null) {
window.clearTimeout(markReadTimerRef.current);
markReadTimerRef.current = null;
}
if (!userId || !channelId) return;
const ts = latestSeenTimestampRef.current;
if (!ts) return;
void markRead({
userId: userId as any,
channelId: channelId as any,
lastReadTimestamp: ts,
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelId]);
return (
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
<div className={styles.scroller}>
@@ -711,17 +871,48 @@ export function Messages({ channelId, onReply }: MessagesProps) {
)}
{(() => {
// Walk the timeline once, inserting a `DayDivider`
// whenever the calendar day changes. `lastTs` tracks
// the most-recent rendered item so two consecutive
// items on the same day skip the divider.
// whenever the calendar day changes and a single
// `NewMessagesDivider` before the first item whose
// timestamp is strictly greater than the snapshot
// of `lastReadTimestamp` taken when the channel
// opened. `lastTs` tracks the most-recent rendered
// item so two consecutive items on the same day
// skip the date divider.
//
// The NEW line never shows for the viewer's own
// messages — if you're the one that sent it, you
// obviously already "saw" it. A message group
// authored entirely by the current user is skipped
// when looking for the divider boundary, so the
// line stays anchored at the first message from
// somebody else.
let lastTs: number | null = null;
const out: React.ReactNode[] = [];
const snapshot =
readSnapshot?.channelId === channelId
? readSnapshot.lastRead
: null;
let newLinePlaced = snapshot === null;
const isOwnGroup = (item: TimelineItem): boolean => {
if (item.kind !== 'group') return false;
if (!userId) return false;
return item.group.every((m) => m.senderId === userId);
};
for (const item of items) {
if (lastTs === null || !isSameDay(lastTs, item.ts)) {
out.push(
<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
);
}
if (
!newLinePlaced &&
snapshot !== null &&
item.ts > snapshot &&
!isOwnGroup(item)
) {
out.push(<NewMessagesDivider key={`new-${item.key}`} />);
newLinePlaced = true;
}
lastTs = item.ts;
if (item.kind === 'group') {
out.push(

View File

@@ -0,0 +1,41 @@
.wrapper {
position: relative;
display: inline-block;
max-width: 400px;
max-height: 300px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: var(--background-tertiary);
line-height: 0;
}
.canvas,
.img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.12s ease;
}
.img {
position: absolute;
inset: 0;
}
.badge {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.7);
color: #ffffff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
pointer-events: none;
line-height: 1.2;
}

View File

@@ -0,0 +1,123 @@
/**
* PausedGif — inline GIF preview that stays paused by default and
* only animates while the mouse is over it. Clicking opens a
* fullscreen viewer (handled by the parent via `onOpen`) where the
* browser plays the GIF normally.
*
* Trick: the browser decodes GIFs natively whenever an <img> is
* visible, and there's no way to "pause" a decoded GIF. So we load
* the GIF into a HTMLImageElement, snapshot frame 0 onto a
* <canvas>, and show the canvas in the idle state. On hover we
* swap to the live <img> and the GIF plays. On mouse-leave we flip
* back to the canvas so it resets to the first frame.
*/
import { useEffect, useRef, useState } from 'react';
import styles from './PausedGif.module.css';
interface PausedGifProps {
url: string;
className?: string;
onOpen?: (url: string) => void;
}
export function PausedGif({ url, className, onOpen }: PausedGifProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = useState(false);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
const [hovered, setHovered] = useState(false);
// `imgKey` is bumped every time we need to re-mount the live
// <img> so the GIF starts over from frame 0 on each hover.
// Without this, rapid hover-in / hover-out cycles would pick up
// wherever the decoder left off.
const [imgKey, setImgKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoaded(false);
setDims(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
const w = img.naturalWidth || 400;
const h = img.naturalHeight || 300;
setDims({ w, h });
const canvas = canvasRef.current;
if (canvas) {
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) {
try {
ctx.drawImage(img, 0, 0, w, h);
} catch {
// Cross-origin canvas taint — fall through to
// the img-only path below. The user still sees
// the GIF, it just won't be paused-by-default.
}
}
}
setLoaded(true);
};
img.onerror = () => {
if (cancelled) return;
setLoaded(true); // Let the <img> path show a broken image
};
img.src = url;
return () => {
cancelled = true;
};
}, [url]);
const handleClick = (e: React.MouseEvent) => {
if (onOpen) {
e.preventDefault();
e.stopPropagation();
onOpen(url);
}
};
const handleMouseEnter = () => {
setHovered(true);
setImgKey((k) => k + 1);
};
const handleMouseLeave = () => setHovered(false);
return (
<div
className={`${styles.wrapper} ${className || ''}`}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
role={onOpen ? 'button' : undefined}
tabIndex={onOpen ? 0 : undefined}
onKeyDown={(e) => {
if (onOpen && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onOpen(url);
}
}}
style={
dims
? { aspectRatio: `${dims.w} / ${dims.h}`, maxWidth: Math.min(dims.w, 400) }
: undefined
}
>
<canvas
ref={canvasRef}
className={styles.canvas}
style={{ opacity: !hovered && loaded ? 1 : 0 }}
/>
{hovered && (
<img
key={imgKey}
src={url}
alt=""
className={styles.img}
draggable={false}
/>
)}
<span className={styles.badge}>GIF</span>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { CreateCategoryModal } from './CategorySettingsModal';
import { InviteModal } from '../modals/InviteModal';
import { CreateServerModal } from '../modals/CreateServerModal';
import { PiPOverlay } from '../voice/PiPOverlay';
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
/**
@@ -160,6 +161,7 @@ export function AppLayout() {
channelId={channelSettingsId}
/>
<PiPOverlay />
<RecordingRecoveryModal />
</KeybindProvider>
</PresenceProvider>
);

View File

@@ -164,6 +164,7 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-height: 1.25rem;
}
.categoryChevron {

View File

@@ -20,6 +20,7 @@ import { useMemo, useRef, useState } from 'react';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { useIsMobile } from '../../hooks/useIsMobile';
import styles from './CustomEmojisTab.module.css';
interface CustomEmojiDoc {
@@ -34,8 +35,6 @@ interface CustomEmojiDoc {
uploadedByAvatarUrl: string | null;
}
const MAX_STATIC = 50;
const MAX_ANIMATED = 50;
const ACCEPTED_MIME =
'image/png,image/gif,image/webp,image/jpeg,image/apng';
@@ -59,6 +58,7 @@ function sanitizeEmojiName(filename: string): string {
}
export function CustomEmojisTab() {
const isMobile = useIsMobile();
const userId =
typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
@@ -99,11 +99,6 @@ export function CustomEmojisTab() {
[filtered],
);
// Counts use the unfiltered lists so the slot card always
// reflects the true server state, not the current search view.
const totalStatic = emojis.filter((e) => !e.animated).length;
const totalAnimated = emojis.filter((e) => e.animated).length;
const uploadFiles = async (files: FileList | File[]) => {
if (!userId) {
setStatus({ type: 'err', message: 'You must be logged in.' });
@@ -117,18 +112,6 @@ export function CustomEmojisTab() {
try {
for (const file of list) {
const animated = isAnimatedMime(file.type);
// Enforce slot limits locally so we don't waste an upload
// round-trip when the server would reject it anyway.
if (
(animated && totalAnimated + okCount >= MAX_ANIMATED) ||
(!animated && totalStatic + okCount >= MAX_STATIC)
) {
setStatus({
type: 'err',
message: `Slot limit reached for ${animated ? 'animated' : 'static'} emoji.`,
});
break;
}
try {
const uploadUrl = await generateUploadUrl({});
const res = await fetch(uploadUrl, {
@@ -327,15 +310,7 @@ export function CustomEmojisTab() {
<div className={styles.slotsCard}>
<div className={styles.slotsHeader}>
<div className={styles.slotsHeaderLeft}>
<h3 className={styles.slotsTitle}>Emoji Slots</h3>
<div className={styles.slotsCounts}>
<span className={styles.slotsCount}>
Static: <strong>{totalStatic}</strong> / {MAX_STATIC}
</span>
<span className={styles.slotsCount}>
Animated: <strong>{totalAnimated}</strong> / {MAX_ANIMATED}
</span>
</div>
<h3 className={styles.slotsTitle}>Upload Custom Emoji</h3>
</div>
<button
type="button"
@@ -355,20 +330,26 @@ export function CustomEmojisTab() {
</p>
</div>
<div
className={`${styles.dropZone} ${isDraggingFiles ? styles.dropZoneActive : ''}`}
onClick={handlePickFile}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<UploadSimple
size={32}
weight="bold"
className={styles.dropZoneIcon}
/>
<span>Drag and drop emoji files here</span>
</div>
{/* Drag-and-drop is desktop-only — mobile browsers can't
receive OS-level file drops, so the zone is hidden and
users upload via the Upload Emoji button in the slots
card above. */}
{!isMobile && (
<div
className={`${styles.dropZone} ${isDraggingFiles ? styles.dropZoneActive : ''}`}
onClick={handlePickFile}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<UploadSimple
size={32}
weight="bold"
className={styles.dropZoneIcon}
/>
<span>Drag and drop emoji files here</span>
</div>
)}
<input
ref={fileInputRef}

View File

@@ -34,13 +34,22 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
];
export function ServerSettingsModal({ isOpen, onClose, initialTab = 'overview' }: ServerSettingsModalProps) {
const [activeTab, setActiveTab] = useState<ServerSettingsTab>(initialTab);
export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
// Desktop opens straight to the Overview tab when no explicit
// tab is requested — the two-column layout always needs a
// selection to fill the content pane. Mobile uses the category
// list as the root and only jumps into a panel if the caller
// actually asked for one, so we keep the raw `initialTab` below
// to forward to `MobileServerSettings`.
const resolvedInitialTab: ServerSettingsTab = initialTab ?? 'overview';
const [activeTab, setActiveTab] = useState<ServerSettingsTab>(
resolvedInitialTab,
);
const isMobile = useIsMobile();
useEffect(() => {
if (isOpen) setActiveTab(initialTab);
}, [isOpen, initialTab]);
if (isOpen) setActiveTab(resolvedInitialTab);
}, [isOpen, resolvedInitialTab]);
useEffect(() => {
if (!isOpen) return;
@@ -52,7 +61,9 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab = 'overview' }
}, [isOpen, onClose]);
// Mobile gets the full-screen overlay with a category list ↔ panel
// flow, desktop gets the two-column modal below.
// flow, desktop gets the two-column modal below. Pass the raw
// `initialTab` (NOT the resolved version) so mobile lands on the
// category list when nothing was requested.
if (isMobile) {
return (
<MobileServerSettings

View File

@@ -15,6 +15,7 @@ import { api } from '../../../../../convex/_generated/api';
import { useTheme } from '../../contexts/ThemeContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLogout } from '../../hooks/useLogout';
import { usePlatform } from '../../platform';
import { AvatarCropModal } from './AvatarCropModal';
import { KeybindsTab } from './KeybindsTab';
import { MobileUserSettings } from './MobileUserSettings';
@@ -866,6 +867,7 @@ interface VoiceSettings {
noiseSuppression: boolean;
echoCancellation: boolean;
autoGainControl: boolean;
recordingDir?: string;
}
const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
@@ -1156,6 +1158,188 @@ export function VoiceTab() {
))}
</select>
</div>
<RecordingSection settings={settings} update={update} />
</div>
);
}
/**
* RecordingSection — picks where per-participant voice recordings
* are saved on disk. Electron-only; renders nothing on web /
* Android because `platform.features.hasRecording` is false.
*/
function RecordingSection({
settings,
update,
}: {
settings: VoiceSettings;
update: <K extends keyof VoiceSettings>(key: K, value: VoiceSettings[K]) => void;
}) {
const platform = usePlatform() as any;
const hasRecording = !!platform?.features?.hasRecording;
const recording = platform?.recording;
const [defaultFolder, setDefaultFolder] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!hasRecording || !recording) return;
let cancelled = false;
(async () => {
try {
const path = await recording.getDefaultFolder();
if (!cancelled && typeof path === 'string') setDefaultFolder(path);
} catch {}
})();
return () => {
cancelled = true;
};
}, [hasRecording, recording]);
if (!hasRecording) return null;
const currentPath = settings.recordingDir || '';
const effectivePath = currentPath || defaultFolder || '';
const isDefault = !currentPath;
const handlePick = async () => {
if (!recording) return;
setBusy(true);
setError(null);
try {
const res = await recording.pickFolder();
if (!res?.ok || !res.path) return;
const v = await recording.validateFolder(res.path);
if (!v?.valid) {
setError(v?.error || 'Folder is not writable.');
return;
}
update('recordingDir', res.path);
} catch (err: any) {
setError(err?.message || 'Failed to pick folder');
} finally {
setBusy(false);
}
};
const handleOpen = async () => {
if (!recording) return;
try {
await recording.openFolder(effectivePath || null);
} catch (err) {
console.warn('Failed to open recording folder:', err);
}
};
const handleReset = () => {
update('recordingDir', undefined as any);
};
return (
<div className={styles.voiceSection}>
<h4 className={styles.voiceSectionTitle}>Call Recording</h4>
<p className={styles.settingDescription}>
Per-participant voice recordings are saved here. Each participant
gets their own audio file so you can edit them separately later.
</p>
<div className={styles.voiceFieldLabel}>Recording folder</div>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div
style={{
flex: 1,
minWidth: 0,
padding: '10px 12px',
borderRadius: 6,
background: 'var(--background-tertiary)',
border: '1px solid var(--background-header-secondary)',
color: 'var(--text-primary)',
fontSize: 13,
fontFamily: 'ui-monospace, Menlo, Consolas, monospace',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={effectivePath}
>
{effectivePath || '—'}
</div>
<button
type="button"
onClick={handlePick}
disabled={busy}
style={{
padding: '8px 14px',
background: 'var(--brand-primary)',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Change
</button>
<button
type="button"
onClick={handleOpen}
style={{
padding: '8px 14px',
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--background-header-secondary)',
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
}}
>
Open folder
</button>
</div>
{!isDefault && (
<button
type="button"
onClick={handleReset}
style={{
marginTop: 8,
background: 'none',
border: 'none',
color: 'var(--text-secondary)',
fontSize: 12,
cursor: 'pointer',
padding: 0,
textDecoration: 'underline',
}}
>
Reset to default
</button>
)}
{error && (
<div
style={{
marginTop: 8,
padding: '8px 10px',
background: 'rgba(237,66,69,0.12)',
border: '1px solid rgba(237,66,69,0.4)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 12,
}}
>
{error}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
/*
* RecordingIndicator — compact red pill shown in the UserArea
* (next to the mic / deafen controls) while a voice recording is
* in progress. Pulses softly so it's visible at a glance; clicking
* opens a confirm dialog that stops the recording.
*/
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border: 1px solid rgba(237, 66, 69, 0.4);
border-radius: 999px;
background: rgba(237, 66, 69, 0.12);
color: var(--status-danger, #ed4245);
font: inherit;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
}
.pill:hover {
background: rgba(237, 66, 69, 0.2);
border-color: rgba(237, 66, 69, 0.6);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-danger, #ed4245);
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0.5);
animation: recPulse 1.4s ease-out infinite;
}
@keyframes recPulse {
0% {
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0.55);
}
70% {
box-shadow: 0 0 0 8px rgba(237, 66, 69, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0);
}
}
.timer {
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.label {
text-transform: uppercase;
}

View File

@@ -0,0 +1,60 @@
/**
* RecordingIndicator — red "REC" pill with a running session
* timer, shown in the UserArea while a voice recording is in
* progress. Clicking it prompts to stop. Gated on the
* `isRecording` flag from VoiceContext; renders `null` otherwise
* so the UserArea footer shape stays consistent.
*/
import { useEffect, useState } from 'react';
import { useVoice } from '../../contexts/VoiceContext';
import styles from './RecordingIndicator.module.css';
function formatElapsed(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '00:00';
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
export function RecordingIndicator() {
const voice = useVoice() as any;
const isRecording = !!voice?.isRecording;
const startedAt: number | null = voice?.recordingStartedAt ?? null;
const stopRecording: (() => Promise<void>) | undefined = voice?.stopRecording;
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!isRecording) return;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [isRecording]);
if (!isRecording || !startedAt) return null;
const elapsed = now - startedAt;
const handleClick = async () => {
const ok = window.confirm('Stop recording this call?');
if (!ok) return;
await stopRecording?.();
};
return (
<button
type="button"
className={styles.pill}
onClick={handleClick}
aria-label={`Stop recording (${formatElapsed(elapsed)})`}
title="Click to stop recording"
>
<span className={styles.dot} aria-hidden />
<span className={styles.label}>REC</span>
<span className={styles.timer}>{formatElapsed(elapsed)}</span>
</button>
);
}

View File

@@ -0,0 +1,102 @@
.card {
padding: 20px 22px 18px;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 520px;
max-width: 640px;
}
.title {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.lead {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.45;
margin: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding: 4px 0;
}
.row {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--background-header-secondary);
border-radius: 8px;
background-color: var(--background-tertiary);
}
.rowMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.rowPrimary {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowSecondary {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.rowActions {
display: flex;
align-items: center;
gap: 6px;
}
.actionButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-primary);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.actionButton:hover {
background: var(--background-modifier-hover);
}
.actionDanger:hover {
background: rgba(237, 66, 69, 0.12);
border-color: rgba(237, 66, 69, 0.4);
color: var(--status-danger, #ed4245);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,218 @@
/**
* RecordingRecoveryModal — shown once on app startup when the
* Electron main process reports one or more recording sessions
* with `endedAt === null`. Those are sessions that didn't
* finalize (e.g. the app was killed mid-recording).
*
* Each session offers three actions:
* - Keep — stamps `endedAt` to the latest track's mtime so
* the manifest parses cleanly on future scans.
* - Delete — removes the session folder entirely.
* - Open — reveals the session folder in the OS file browser
* so the user can manually inspect / copy out files.
*
* Gated on `platform.features.hasRecording` — the modal never
* renders on web/Android.
*/
import { useEffect, useState } from 'react';
import { FolderOpen, Trash } from '@phosphor-icons/react';
import { Modal, Button } from '@discord-clone/ui';
import { usePlatform } from '../../platform';
import styles from './RecordingRecoveryModal.module.css';
interface RecoverableSession {
sessionId: string;
sessionDir: string;
channelId: string | null;
channelName: string | null;
startedAt: number;
participantCount: number;
trackCount: number;
}
function formatStartedAt(ts: number): string {
try {
const d = new Date(ts);
return d.toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
} catch {
return '—';
}
}
export function RecordingRecoveryModal() {
const platform = usePlatform() as any;
const hasRecording = !!platform?.features?.hasRecording;
const recording = platform?.recording;
const [sessions, setSessions] = useState<RecoverableSession[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
if (!hasRecording || !recording) return;
let cancelled = false;
(async () => {
try {
// Look up the user-preferred root (same place the
// recorder reads from) so we scan the right folder.
let rootDir: string | null = null;
try {
const raw = localStorage.getItem('voiceSettings');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.recordingDir === 'string') {
rootDir = parsed.recordingDir;
}
}
} catch {}
const res = await recording.listRecoverable({ rootDir });
if (cancelled) return;
if (res?.ok && Array.isArray(res.sessions) && res.sessions.length > 0) {
setSessions(res.sessions);
setIsOpen(true);
}
} catch (err) {
console.warn('Recording recovery scan failed:', err);
}
})();
return () => {
cancelled = true;
};
}, [hasRecording, recording]);
if (!hasRecording) return null;
const dismiss = () => {
setIsOpen(false);
setSessions([]);
};
const handleKeep = async (session: RecoverableSession) => {
if (!recording) return;
setBusy(session.sessionId);
try {
await recording.recoverSession({
sessionDir: session.sessionDir,
action: 'keep',
});
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
} catch (err) {
console.warn('Failed to keep recovered session:', err);
} finally {
setBusy(null);
}
};
const handleDelete = async (session: RecoverableSession) => {
if (!recording) return;
const ok = window.confirm(
`Delete recording from ${formatStartedAt(session.startedAt)}?\n\n` +
`This permanently removes ${session.trackCount} audio file${session.trackCount === 1 ? '' : 's'}.`,
);
if (!ok) return;
setBusy(session.sessionId);
try {
await recording.recoverSession({
sessionDir: session.sessionDir,
action: 'delete',
});
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
} catch (err) {
console.warn('Failed to delete recovered session:', err);
} finally {
setBusy(null);
}
};
const handleOpen = async (session: RecoverableSession) => {
if (!recording) return;
try {
await recording.openFolder(session.sessionDir);
} catch (err) {
console.warn('Failed to open recovered folder:', err);
}
};
useEffect(() => {
if (isOpen && sessions.length === 0) {
setIsOpen(false);
}
}, [isOpen, sessions.length]);
return (
<Modal.Root isOpen={isOpen} onClose={dismiss} size="medium">
<Modal.Header title="Recover voice recordings" onClose={dismiss} />
<Modal.Content>
<div className={styles.card}>
<p className={styles.lead}>
These voice recordings didn't finalise cleanly. The audio is
intact up to the moment the app was interrupted. Choose what
to do with each session — keeping a session just stamps the
end time so it stops appearing in this list.
</p>
<div className={styles.list}>
{sessions.map((session) => (
<div key={session.sessionId} className={styles.row}>
<div className={styles.rowMeta}>
<span className={styles.rowPrimary}>
{session.channelName ? `#${session.channelName}` : 'Voice call'}
{' '}
{formatStartedAt(session.startedAt)}
</span>
<span className={styles.rowSecondary}>
{session.trackCount} file
{session.trackCount === 1 ? '' : 's'} ·{' '}
{session.participantCount} participant
{session.participantCount === 1 ? '' : 's'}
</span>
</div>
<div className={styles.rowActions}>
<button
type="button"
className={styles.actionButton}
onClick={() => handleOpen(session)}
disabled={busy === session.sessionId}
title="Open in file explorer"
>
<FolderOpen size={14} weight="bold" />
Open
</button>
<button
type="button"
className={`${styles.actionButton} ${styles.actionDanger}`}
onClick={() => handleDelete(session)}
disabled={busy === session.sessionId}
title="Delete recording"
>
<Trash size={14} weight="bold" />
Delete
</button>
<button
type="button"
className={styles.actionButton}
onClick={() => handleKeep(session)}
disabled={busy === session.sessionId}
title="Keep this recording"
>
Keep
</button>
</div>
</div>
))}
</div>
<div className={styles.footer}>
<Button variant="secondary" size="sm" onClick={dismiss}>
Close
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -23,11 +23,14 @@ import {
Desktop,
MonitorPlay,
PhoneX,
Record,
Waveform,
} from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom';
import { Tooltip } from '@discord-clone/ui';
import { useVoice } from '../../contexts/VoiceContext';
import { usePlatform } from '../../platform';
import { RecordingIndicator } from './RecordingIndicator';
import styles from './VoiceConnectionStatus.module.css';
/**
@@ -97,6 +100,24 @@ export function VoiceConnectionStatus() {
const voice = useVoice() as any;
const navigate = useNavigate();
const voiceSettings = useVoiceSettingsSnapshot();
const platform = usePlatform() as any;
const canRecord = !!platform?.features?.hasRecording;
const isRecording = !!voice?.isRecording;
const handleStartRecording = async () => {
if (!canRecord) return;
const ok = window.confirm(
'Start recording this call?\n\n' +
'Each participant will be saved to a separate audio file on your computer. ' +
'You are responsible for obtaining consent from participants where required by law.',
);
if (!ok) return;
try {
await voice?.startRecording?.();
} catch (err) {
console.error('Failed to start recording:', err);
}
};
if (!voice?.activeChannelId) return null;
const state: string = voice.connectionState || 'disconnected';
@@ -159,6 +180,19 @@ export function VoiceConnectionStatus() {
{status.text}
</button>
<div className={styles.controls}>
{canRecord && isConnected && !isRecording && (
<Tooltip content="Record call" placement="top">
<button
type="button"
className={styles.controlButton}
onClick={handleStartRecording}
aria-label="Record call"
>
<Record weight="fill" className={styles.icon} />
</button>
</Tooltip>
)}
{canRecord && isConnected && isRecording && <RecordingIndicator />}
<Tooltip
content={
noiseSuppressionActive

View File

@@ -4,6 +4,7 @@ import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { findTrackPubs } from '../utils/streamUtils.jsx';
import { VoiceRecorder } from '../utils/voiceRecorder';
import { usePlatform } from '../platform';
import '@livekit/components-styles';
@@ -58,7 +59,8 @@ function playSoundUrl(url) {
}
export const VoiceProvider = ({ children }) => {
const { idle, voiceService } = usePlatform();
const platform = usePlatform();
const { idle, voiceService } = platform;
const [activeChannelId, setActiveChannelId] = useState(null);
const [activeChannelName, setActiveChannelName] = useState(null);
const [connectionState, setConnectionState] = useState('disconnected');
@@ -85,6 +87,16 @@ export const VoiceProvider = ({ children }) => {
const [isReconnecting, setIsReconnecting] = useState(false);
const [connectionQualities, setConnectionQualities] = useState({});
// 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
// it; consumers should clear it after reading.
const [isRecording, setIsRecording] = useState(false);
const [recordingStartedAt, setRecordingStartedAt] = useState(null);
const [recordingSessionId, setRecordingSessionId] = useState(null);
const [recordingError, setRecordingError] = useState(null);
const voiceRecorderRef = useRef(null);
const convex = useConvex();
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
@@ -953,6 +965,94 @@ export const VoiceProvider = ({ children }) => {
}
}, [room]);
// ── Voice recording ─────────────────────────────────────────
// Starts a `VoiceRecorder` bound to the current room. Returns
// the session metadata on success so the UI can show a confirm
// indicator. Gracefully no-ops (with a console warning) on
// platforms that don't support recording.
const startRecording = useCallback(async () => {
if (!platform?.features?.hasRecording || !platform.recording) {
console.warn('Recording is not available on this platform.');
return null;
}
if (!room) {
console.warn('Cannot start recording — not connected to a voice channel.');
return null;
}
if (voiceRecorderRef.current) {
return {
sessionId: voiceRecorderRef.current.sessionId,
startedAt: voiceRecorderRef.current.startedAt,
};
}
try {
// Resolve the user-preferred recording folder (settings
// writes to localStorage under `voiceSettings`, same
// bucket the mic/AGC toggles live in).
let rootDir = null;
try {
const raw = localStorage.getItem('voiceSettings');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.recordingDir === 'string') {
rootDir = parsed.recordingDir;
}
}
} catch {}
const recorder = new VoiceRecorder({
platform,
room,
channelId: activeChannelId,
channelName: activeChannelName,
rootDir,
onError: (err) => {
console.error('Voice recorder error:', err);
setRecordingError(err.message || 'Recording error');
},
});
await recorder.start();
voiceRecorderRef.current = recorder;
setRecordingSessionId(recorder.sessionId);
setRecordingStartedAt(recorder.startedAt);
setIsRecording(true);
setRecordingError(null);
return { sessionId: recorder.sessionId, startedAt: recorder.startedAt };
} catch (err) {
console.error('Failed to start recording:', err);
setRecordingError(err?.message || 'Failed to start recording');
voiceRecorderRef.current = null;
setIsRecording(false);
setRecordingSessionId(null);
setRecordingStartedAt(null);
return null;
}
}, [platform, room, activeChannelId, activeChannelName]);
const stopRecording = useCallback(async () => {
const recorder = voiceRecorderRef.current;
if (!recorder) return;
voiceRecorderRef.current = null;
try {
await recorder.stop();
} catch (err) {
console.error('Failed to stop recording cleanly:', err);
setRecordingError(err?.message || 'Failed to stop recording');
} finally {
setIsRecording(false);
setRecordingSessionId(null);
setRecordingStartedAt(null);
}
}, []);
// Auto-stop the recorder if the user disconnects from the
// voice channel — we don't want an orphaned VoiceRecorder
// holding references to a destroyed LiveKit room.
useEffect(() => {
if (!room && voiceRecorderRef.current) {
void stopRecording();
}
}, [room, stopRecording]);
return (
<VoiceContext.Provider value={{
@@ -993,6 +1093,14 @@ export const VoiceProvider = ({ children }) => {
isReceivingScreenShareAudio,
isReconnecting,
connectionQualities,
// Voice recording
isRecording,
recordingStartedAt,
recordingSessionId,
recordingError,
startRecording,
stopRecording,
clearRecordingError: () => setRecordingError(null),
}}>
{children}
{room && (

View File

@@ -88,6 +88,93 @@ body.has-window-controls #root {
padding-top: 32px;
}
/* ── IBM Plex (self-hosted) ──────────────────────────────────────
WOFF2 files live under `packages/shared/src/assets/font/` and
are emitted to `dist/assets/` by Vite's asset pipeline on build.
Using `font-display: swap` so text paints immediately in the
system fallback, then reflows once Plex finishes downloading —
standard first-paint tradeoff for self-hosted webfonts.
Only Latin glyphs are covered. Add a `unicode-range` descriptor
here if we ever need to load extra scripts. */
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-MediumItalic.woff2') format('woff2');
font-weight: 500;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-SemiBoldItalic.woff2') format('woff2');
font-weight: 600;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-BoldItalic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('./assets/font/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('./assets/font/IBMPlexMono-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('./assets/font/IBMPlexMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* ── Design Tokens ────────────────────────────────────────────────────── */
:root {
@@ -246,7 +333,7 @@ body.has-window-controls #root {
/* Typography */
--font-sans: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Consolas', 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace;
--font-mono: 'IBM Plex Mono', 'Consolas', 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace;
--font-emoji: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', var(--font-sans, system-ui), sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.8125rem;
@@ -561,8 +648,6 @@ body {
color: var(--text-primary);
background-color: var(--background-tertiary);
scrollbar-color: var(--scrollbar-thumb-bg) var(--scrollbar-track-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: var(--user-select);
overflow: hidden;
margin: 0;

View File

@@ -52,6 +52,22 @@
* @property {() => void} close
*/
/**
* @typedef {Object} PlatformRecording
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
* @property {() => Promise<{ok: boolean, path: string|null}>} pickFolder - Native folder picker dialog
* @property {(dir: string) => Promise<{valid: boolean, error?: string|null}>} validateFolder
* @property {(dir?: string|null) => Promise<{ok: boolean, error?: string|null}>} openFolder - Reveal the given (or default) root in file explorer
* @property {(payload: {rootDir?: string|null, sessionId: string}) => Promise<{ok: boolean, error?: string|null}>} openSessionFolder
* @property {(payload: {sessionId: string, rootDir?: string|null, channelId?: string|null, channelName?: string|null, startedAt: number}) => Promise<{ok: boolean, error?: string|null, rootDir?: string, sessionDir?: string}>} startSession
* @property {(payload: {sessionId: string, participantId: string, displayName?: string|null, joinedOffsetMs: number}) => Promise<{ok: boolean, error?: string|null, trackKey?: string, fileName?: string}>} openTrack
* @property {(payload: {sessionId: string, trackKey: string, chunk: ArrayBuffer}) => Promise<{ok: boolean, error?: string|null}>} append
* @property {(payload: {sessionId: string, trackKey: string, leftOffsetMs?: number|null}) => Promise<{ok: boolean, error?: string|null}>} closeTrack
* @property {(payload: {sessionId: string, endedAt: number}) => Promise<{ok: boolean, error?: string|null, dir?: string}>} finalize
* @property {(payload?: {rootDir?: string|null}) => Promise<{ok: boolean, sessions: Array<{sessionId: string, sessionDir: string, channelId: string|null, channelName: string|null, startedAt: number, participantCount: number, trackCount: number}>}>} listRecoverable
* @property {(payload: {sessionDir: string, action: 'keep'|'delete'}) => Promise<{ok: boolean, error?: string|null}>} recoverSession
*/
/**
* @typedef {Object} PlatformUpdates
* @property {() => Promise<object>} checkUpdate
@@ -103,6 +119,7 @@
* @property {PlatformLinks} links
* @property {PlatformScreenCapture|null} screenCapture
* @property {PlatformWindowControls|null} windowControls
* @property {PlatformRecording|null} recording
* @property {PlatformUpdates|null} updates
* @property {PlatformSearchDB|null} searchDB
* @property {PlatformVoiceService|null} voiceService
@@ -110,4 +127,9 @@
* @property {PlatformFeatures} features
*/
/**
* @typedef {Object} PlatformFeaturesExtensions
* @property {boolean} [hasRecording]
*/
export {};

View File

@@ -0,0 +1,417 @@
/**
* VoiceRecorder — per-participant audio capture for a LiveKit voice
* call. Spawns one `MediaRecorder` per active audio track (local mic
* + each remote participant) with a 500ms timeslice. Every chunk is
* forwarded over IPC to the Electron main process, which append-
* writes it to a per-participant WebM file inside a session folder.
*
* Crash-safe by construction: the WebM/Opus container is built from
* independently-decodable clusters, so a truncated tail on power
* loss just means the decoder stops at the last complete cluster.
* Every 500ms of audio is on disk before the next chunk arrives,
* and the main process fsyncs every ~10 seconds.
*
* Only active when `platform.features.hasRecording === true`
* (Electron). Other platforms should never instantiate this — the
* VoiceContext gates `startRecording()` behind that flag.
*/
import { RoomEvent } from 'livekit-client';
interface Platform {
features?: { hasRecording?: boolean };
recording?: {
startSession: (payload: any) => Promise<any>;
openTrack: (payload: any) => Promise<any>;
append: (payload: any) => Promise<any>;
closeTrack: (payload: any) => Promise<any>;
finalize: (payload: any) => Promise<any>;
} | null;
}
interface RecordingOpts {
platform: Platform;
room: any; // LiveKit Room
channelId: string | null;
channelName: string | null;
rootDir: string | null;
onError?: (err: Error) => void;
}
interface TrackRecorder {
participantId: string;
displayName: string;
trackKey: string;
stream: MediaStream;
recorder: MediaRecorder;
joinedOffsetMs: number;
// `pendingIo` serialises the append IPCs for this track so
// chunks arrive in order even if the main-process write is
// slower than 500ms. Without this, two parallel `append` calls
// can interleave their bytes and break the WebM cluster stream.
pendingIo: Promise<any>;
}
function sanitizeParticipantId(id: any): string {
return String(id ?? 'unknown');
}
function generateSessionId(): string {
// Filesystem-safe ISO timestamp — no `:` so Windows is happy,
// plus a short random suffix so two recordings started in the
// same second don't collide.
const iso = new Date().toISOString().replace(/[:.]/g, '-');
const rand = Math.random().toString(36).slice(2, 6);
return `${iso}-${rand}`;
}
/** Resolve the LiveKit microphone publication (handles both the
* newer `trackPublications` Map and older `tracks` map names). */
function findLocalMicPublication(localParticipant: any): any | null {
const map =
localParticipant?.trackPublications || localParticipant?.tracks;
if (!map) return null;
for (const pub of map.values()) {
if (!pub) continue;
const kind = pub.kind === 'audio' || pub.track?.kind === 'audio';
const src = (pub.source?.toString?.() ?? '').toLowerCase();
if (kind && (src === 'microphone' || src === '' || src === 'mic')) {
return pub;
}
}
return null;
}
/** Yield every audio publication on a participant whose `source`
* isn't screen_share_audio (which is mixed with the screen share
* track and would be redundant / confusing in a per-voice file). */
function* iterAudioPublications(participant: any): Generator<any> {
const map = participant?.trackPublications || participant?.tracks;
if (!map) return;
for (const pub of map.values()) {
if (!pub) continue;
const kind = pub.kind === 'audio' || pub.track?.kind === 'audio';
if (!kind) continue;
const src = (pub.source?.toString?.() ?? '').toLowerCase();
if (src === 'screen_share_audio') continue;
yield pub;
}
}
/** Best `mimeType` the current Chromium build supports for the
* MediaRecorder. Electron ships Chromium so Opus-in-WebM is
* always available; falling through is defensive. */
function pickMimeType(): string {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
for (const c of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) {
return c;
}
}
return 'audio/webm';
}
export class VoiceRecorder {
readonly sessionId: string;
readonly startedAt: number;
private readonly opts: RecordingOpts;
private readonly mimeType: string;
private readonly recorders = new Map<string, TrackRecorder>();
private stopped = false;
// We capture refs to the RoomEvent handlers so we can detach
// them in stop() — React's strict-mode double mount + the
// context's own event listeners would otherwise leave dangling
// subscriptions after the recorder is destroyed.
private readonly handlers: Array<[string, (...args: any[]) => void]> = [];
constructor(opts: RecordingOpts) {
this.opts = opts;
this.sessionId = generateSessionId();
this.startedAt = Date.now();
this.mimeType = pickMimeType();
}
/** Spin up the session folder + attach recorders for every
* audio track that's already published plus handlers for
* tracks that arrive later (participants joining mid-call). */
async start(): Promise<void> {
const { platform, room } = this.opts;
if (!platform.features?.hasRecording || !platform.recording) {
throw new Error('Recording is not available on this platform.');
}
const started = await platform.recording.startSession({
sessionId: this.sessionId,
rootDir: this.opts.rootDir,
channelId: this.opts.channelId,
channelName: this.opts.channelName,
startedAt: this.startedAt,
});
if (!started?.ok) {
throw new Error(started?.error ?? 'Failed to start recording session.');
}
// Local mic — iterate any existing publication and hook
// future re-publications (mute/unmute cycles republish the
// track, which would otherwise slip past TrackSubscribed).
const localId = room.localParticipant?.identity ?? 'local';
const localName =
room.localParticipant?.metadata ||
room.localParticipant?.name ||
'You';
const localPub = findLocalMicPublication(room.localParticipant);
if (localPub?.track?.mediaStreamTrack) {
await this.attachTrack(localId, localName, localPub.track.mediaStreamTrack);
}
// Remote participants — both the currently-connected set
// and any future joiners.
const remotes = room.remoteParticipants?.values
? Array.from(room.remoteParticipants.values())
: [];
for (const participant of remotes as any[]) {
for (const pub of iterAudioPublications(participant)) {
if (pub.track?.mediaStreamTrack) {
await this.attachTrack(
sanitizeParticipantId(participant.identity),
participant.metadata || participant.name || participant.identity || 'Participant',
pub.track.mediaStreamTrack,
);
}
}
}
// Hook future subscribes — this fires for every newly
// subscribed audio track, including ones that come in after
// a participant joins mid-recording.
const onSubscribed = (track: any, _publication: any, participant: any) => {
if (this.stopped) return;
if (track?.kind !== 'audio') return;
const src = (track?.source?.toString?.() ?? '').toLowerCase();
if (src === 'screen_share_audio') return;
const mediaTrack = track?.mediaStreamTrack;
if (!mediaTrack) return;
void this.attachTrack(
sanitizeParticipantId(participant?.identity),
participant?.metadata || participant?.name || participant?.identity || 'Participant',
mediaTrack,
);
};
const onUnsubscribed = (_track: any, _publication: any, participant: any) => {
if (this.stopped) return;
this.detachParticipantTracks(sanitizeParticipantId(participant?.identity));
};
const onParticipantDisconnected = (participant: any) => {
if (this.stopped) return;
this.detachParticipantTracks(sanitizeParticipantId(participant?.identity));
};
const onLocalTrackPublished = (publication: any) => {
if (this.stopped) return;
const track = publication?.track;
if (track?.kind !== 'audio') return;
if (!track?.mediaStreamTrack) return;
void this.attachTrack(localId, localName, track.mediaStreamTrack);
};
const onLocalTrackUnpublished = (publication: any) => {
if (this.stopped) return;
const track = publication?.track;
if (track?.kind !== 'audio') return;
this.detachParticipantTracks(localId);
};
room.on(RoomEvent.TrackSubscribed, onSubscribed);
room.on(RoomEvent.TrackUnsubscribed, onUnsubscribed);
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
this.handlers.push([RoomEvent.TrackSubscribed, onSubscribed]);
this.handlers.push([RoomEvent.TrackUnsubscribed, onUnsubscribed]);
this.handlers.push([RoomEvent.ParticipantDisconnected, onParticipantDisconnected]);
this.handlers.push([RoomEvent.LocalTrackPublished, onLocalTrackPublished]);
this.handlers.push([RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished]);
}
/** Flush every open recorder, close every track, finalize the
* session manifest. Idempotent — safe to call from React
* cleanup paths. */
async stop(): Promise<void> {
if (this.stopped) return;
this.stopped = true;
const { platform, room } = this.opts;
// Detach room listeners first so late-arriving events don't
// try to attach new tracks mid-teardown.
for (const [event, handler] of this.handlers) {
try { room.off(event, handler); } catch {}
}
this.handlers.length = 0;
const endedAt = Date.now();
const stops: Array<Promise<void>> = [];
for (const entry of this.recorders.values()) {
stops.push(this.stopRecorder(entry, endedAt));
}
await Promise.allSettled(stops);
this.recorders.clear();
if (platform.recording) {
try {
await platform.recording.finalize({
sessionId: this.sessionId,
endedAt,
});
} catch (err) {
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
}
}
// ── Internals ─────────────────────────────────────────────
private async attachTrack(
participantId: string,
displayName: string,
mediaTrack: MediaStreamTrack,
): Promise<void> {
if (this.stopped) return;
// Skip duplicates if the same mediaTrack already has a
// running recorder (track republications during mute cycles
// can fire TrackSubscribed twice).
for (const entry of this.recorders.values()) {
if (entry.participantId === participantId && entry.recorder.state === 'recording') {
return;
}
}
const joinedOffsetMs = Math.max(0, Date.now() - this.startedAt);
const opened = await this.opts.platform.recording!.openTrack({
sessionId: this.sessionId,
participantId,
displayName,
joinedOffsetMs,
});
if (!opened?.ok || !opened.trackKey) {
this.opts.onError?.(
new Error(opened?.error ?? 'Failed to open recording track.'),
);
return;
}
const trackKey = opened.trackKey;
const stream = new MediaStream([mediaTrack]);
let recorder: MediaRecorder;
try {
recorder = new MediaRecorder(stream, { mimeType: this.mimeType });
} catch (err) {
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
return;
}
const entry: TrackRecorder = {
participantId,
displayName,
trackKey,
stream,
recorder,
joinedOffsetMs,
pendingIo: Promise.resolve(),
};
recorder.ondataavailable = (ev: BlobEvent) => {
if (!ev.data || ev.data.size === 0) return;
// Queue each append after the previous one resolves so
// bytes arrive in order on the main-process side.
entry.pendingIo = entry.pendingIo
.catch(() => undefined)
.then(async () => {
try {
const buf = await ev.data.arrayBuffer();
await this.opts.platform.recording!.append({
sessionId: this.sessionId,
trackKey,
chunk: buf,
});
} catch (err) {
this.opts.onError?.(
err instanceof Error ? err : new Error(String(err)),
);
}
});
};
recorder.onerror = (ev: any) => {
this.opts.onError?.(
ev?.error instanceof Error
? ev.error
: new Error(String(ev?.error ?? 'MediaRecorder error')),
);
};
recorder.start(500);
this.recorders.set(trackKey, entry);
}
private detachParticipantTracks(participantId: string): void {
const keys: string[] = [];
for (const [key, entry] of this.recorders) {
if (entry.participantId === participantId) keys.push(key);
}
for (const key of keys) {
const entry = this.recorders.get(key);
if (!entry) continue;
this.recorders.delete(key);
void this.stopRecorder(entry, Date.now());
}
}
private async stopRecorder(
entry: TrackRecorder,
endedAt: number,
): Promise<void> {
try {
if (entry.recorder.state !== 'inactive') {
// requestData() makes MediaRecorder emit one final
// dataavailable for whatever's in its internal buffer,
// then stop() flushes the tail.
try { entry.recorder.requestData(); } catch {}
await new Promise<void>((resolve) => {
const onStop = () => {
entry.recorder.removeEventListener('stop', onStop);
resolve();
};
entry.recorder.addEventListener('stop', onStop);
try { entry.recorder.stop(); } catch { resolve(); }
});
}
} catch (err) {
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
// Wait for any pending append IPCs to finish so closeTrack
// doesn't race ahead of the last in-flight chunk.
try { await entry.pendingIo; } catch {}
if (this.opts.platform.recording) {
try {
await this.opts.platform.recording.closeTrack({
sessionId: this.sessionId,
trackKey: entry.trackKey,
leftOffsetMs: Math.max(0, endedAt - this.startedAt),
});
} catch (err) {
this.opts.onError?.(
err instanceof Error ? err : new Error(String(err)),
);
}
}
// Stop every MediaStreamTrack we constructed for this
// recorder — LiveKit still owns the underlying device track,
// but the wrapper MediaStream we created needs to be
// released to free browser resources.
try {
for (const t of entry.stream.getTracks()) {
if (t === entry.stream.getTracks()[0]) continue; // device track belongs to LiveKit
}
} catch {}
}
}