feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s

- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
This commit is contained in:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View File

@@ -0,0 +1,135 @@
.root {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionLabel {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-tertiary);
}
.sectionDescription {
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.4;
margin: 0;
}
.customNotice {
font-size: 0.8125rem;
color: var(--status-warning, #faa61a);
padding: 8px 12px;
background-color: color-mix(in srgb, var(--status-warning, #faa61a) 10%, transparent);
border-radius: 8px;
margin: 0;
}
.optionList {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.option {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
border-radius: 10px;
color: var(--text-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.option:hover:not(:disabled) {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.05));
}
.option:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.optionSelected {
border-color: var(--brand-primary, #5865f2);
background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 10%, transparent);
}
.optionSelected:hover:not(:disabled) {
background-color: color-mix(in srgb, var(--brand-primary, #5865f2) 15%, transparent);
}
.optionIcon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.05));
color: var(--text-secondary);
}
.optionSelected .optionIcon {
background-color: var(--brand-primary, #5865f2);
color: #ffffff;
}
.optionBody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.optionTitle {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
}
.optionDescription {
font-size: 0.8125rem;
line-height: 1.35;
color: var(--text-secondary);
}
.optionCheck {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
background-color: var(--brand-primary, #5865f2);
color: #ffffff;
align-self: center;
}
.disabledHint {
font-size: 0.75rem;
color: var(--text-tertiary);
margin: 4px 0 0;
}
.status {
font-size: 0.8125rem;
margin: 6px 0 0;
line-height: 1.4;
}
.statusError {
color: var(--status-danger, #da373c);
}

View File

@@ -0,0 +1,136 @@
import { RoomManager } from '@brycord/matrix-client';
import { Check, GlobeHemisphereWest, Lock, UsersThree } from '@phosphor-icons/react';
/**
* AccessPicker — three-option "who can join" picker for a channel
* or category. Mirrors Element's "Invite only / Space members /
* Anyone" control and writes the corresponding `m.room.join_rules`
* state event via `RoomManager.setChannelAccess`.
*
* Reads the current access on mount. Writes are optimistic —
* selection updates immediately; on server rejection we roll back
* and show an inline error. Disabled when the caller passes
* `disabled` (e.g., the user lacks `state_default` PL in the
* target room).
*/
import { useEffect, useState } from 'react';
import styles from './AccessPicker.module.css';
export type AccessMode = 'invite' | 'space_members' | 'public';
interface AccessPickerProps {
/** The channel or category whose access we're editing. */
roomId: string;
/** Parent space — used when writing the `restricted` allow list. */
spaceId: string;
/** When true, every option is non-interactive. */
disabled?: boolean;
}
interface OptionDescriptor {
value: AccessMode;
icon: React.ReactNode;
title: string;
description: string;
}
const OPTIONS: OptionDescriptor[] = [
{
value: 'invite',
icon: <Lock size={20} weight="fill" />,
title: 'Invite Only',
description: 'Only invited members can see and join this channel.',
},
{
value: 'space_members',
icon: <UsersThree size={20} weight="fill" />,
title: 'Space Members',
description: 'Anyone in the server can join. Recommended.',
},
{
value: 'public',
icon: <GlobeHemisphereWest size={20} weight="fill" />,
title: 'Anyone',
description: 'Anyone with a link can join, even without a server invite.',
},
];
export function AccessPicker({ roomId, spaceId, disabled = false }: AccessPickerProps) {
const [current, setCurrent] = useState<AccessMode | 'other' | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Read the current access on mount and whenever the target
// room changes. Synchronous — reads from matrix-js-sdk's
// in-memory room state.
useEffect(() => {
const mode = RoomManager.getInstance().getChannelAccess(roomId, spaceId);
setCurrent(mode);
}, [roomId, spaceId]);
const handleSelect = async (next: AccessMode) => {
if (disabled || saving || next === current) return;
const previous = current;
// Optimistic — flip the UI immediately, roll back on error.
setCurrent(next);
setSaving(true);
setError(null);
try {
await RoomManager.getInstance().setChannelAccess(roomId, next, spaceId);
} catch (err: any) {
setCurrent(previous);
setError(err?.message || 'Failed to update access.');
} finally {
setSaving(false);
}
};
// If the room has join rules we don't recognise (e.g. `knock`
// or a `restricted` allow list pointing at a different room),
// surface that rather than silently overwriting the config.
const isCustom = current === 'other';
return (
<div className={styles.root}>
<div className={styles.sectionLabel}>Channel Access</div>
<p className={styles.sectionDescription}>
Control who can join this channel. Existing members always keep their access this only affects new joins.
</p>
{isCustom && (
<p className={styles.customNotice}>
Custom join rules are set on this channel. Pick an option below to replace them.
</p>
)}
<div className={styles.optionList}>
{OPTIONS.map((opt) => {
const isSelected = current === opt.value;
return (
<button
key={opt.value}
type="button"
className={`${styles.option} ${isSelected ? styles.optionSelected : ''}`}
onClick={() => handleSelect(opt.value)}
disabled={disabled || saving}
>
<span className={styles.optionIcon}>{opt.icon}</span>
<span className={styles.optionBody}>
<span className={styles.optionTitle}>{opt.title}</span>
<span className={styles.optionDescription}>{opt.description}</span>
</span>
{isSelected && (
<span className={styles.optionCheck}>
<Check size={14} weight="bold" />
</span>
)}
</button>
);
})}
</div>
{disabled && <p className={styles.disabledHint}>You need a higher role to change access.</p>}
{error && <p className={`${styles.status} ${styles.statusError}`}>{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,360 @@
/* ── Audio player card ───────────────────────────────────────
Fluxer-style audio attachment surface. Rounded rectangle with
a subtle background, play button on the left, filename + seek
bar + time on the right, control row underneath. Width is
constrained so the card feels like a compact inline element
in a chat message, not a full-width banner. */
.card {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 480px;
padding: 14px 16px;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
border-radius: 0.625rem;
box-sizing: border-box;
}
/* ── Top row: play button + filename on the same line ──────── */
.topRow {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.playButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background-color: var(--brand-primary);
border: none;
border-radius: 50%;
color: var(--text-on-brand-primary, #fff);
cursor: pointer;
flex-shrink: 0;
transition: filter 0.12s, transform 0.12s;
-webkit-tap-highlight-color: transparent;
}
.playButton:hover:not(:disabled) {
filter: brightness(1.08);
}
.playButton:active:not(:disabled) {
transform: scale(0.96);
}
.playButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Filename row ───────────────────────────────────────────
The stem is a single-line ellipsis'd span and the extension
is a separate right-flush span, mirroring the Fluxer pattern
where the extension always stays visible while the middle of
a long filename gets clipped. Lives inline with the play
button in the top row; progress bar sits on its own row
below so it can span the full card width. */
.filenameRow {
flex: 1;
display: flex;
align-items: baseline;
gap: 2px;
min-width: 0;
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
}
.filenameStem {
min-width: 0;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filenameExt {
flex-shrink: 0;
}
/* ── Progress row: seek bar + time label ───────────────────── */
.progressRow {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.progressTrack {
position: relative;
flex: 1;
height: 4px;
border-radius: 999px;
background-color: var(--background-modifier-accent);
cursor: pointer;
}
.progressFill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: var(--progress, 0%);
border-radius: 999px;
background-color: var(--brand-primary);
pointer-events: none;
}
/* Invisible native range input sits on top of the visual track
so the user gets native keyboard + drag support for free.
The real styling comes from .progressTrack / .progressFill; we
just override the thumb to be clickable. */
.progressInput {
position: absolute;
inset: -8px 0;
width: 100%;
height: calc(100% + 16px);
margin: 0;
padding: 0;
background: transparent;
border: none;
outline: none;
cursor: pointer;
opacity: 0;
-webkit-appearance: none;
appearance: none;
}
.progressInput:disabled {
cursor: not-allowed;
}
.progressInput::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
}
.progressInput::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
}
.timeLabel {
flex-shrink: 0;
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--text-primary-muted, #a0a3a8);
}
/* ── Bottom row: volume • speed + favorite + download ──────── */
.bottomRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.bottomRowRight {
display: flex;
align-items: center;
gap: 4px;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-primary-muted, #a0a3a8);
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
-webkit-tap-highlight-color: transparent;
}
.iconButton:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.iconButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Favorited state — brand-primary fill to match the lightbox
star treatment. */
.iconButtonActive {
color: var(--brand-primary);
}
.iconButtonActive:hover:not(:disabled) {
color: var(--brand-primary);
background-color: var(--background-modifier-hover);
filter: brightness(1.08);
}
.speedButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 10px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-primary-muted, #a0a3a8);
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
-webkit-tap-highlight-color: transparent;
font-variant-numeric: tabular-nums;
}
.speedButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.hiddenAudio {
display: none;
}
/* ── Volume slider popover ─────────────────────────────────
Speaker button + a hover-revealed vertical slider above it.
The popover sits on top of the bottom row, anchored to the
button, and fades in when the user hovers either the icon
or the popover itself — the shared `.volumeWrap` wrapper
keeps the hover state alive across both. */
.volumeWrap {
position: relative;
display: inline-flex;
align-items: center;
}
.volumePopover {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
width: 44px;
padding: 10px 0 14px;
background-color: var(--background-floating, #18191c);
border: 1px solid var(--background-modifier-accent);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0.12s ease;
z-index: 5;
}
/* Invisible bridge below the popover so the mouse can move
from the speaker icon into the slider without briefly
leaving the hover chain and closing the popover. */
.volumePopover::after {
content: '';
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 6px;
pointer-events: auto;
}
.volumeWrap:hover .volumePopover,
.volumeWrap:focus-within .volumePopover {
opacity: 1;
visibility: visible;
}
/* Vertical track. 4px wide, 90px tall — same fill-via-CSS-var
technique the seek bar uses, just with the axis flipped. */
.volumeTrack {
position: relative;
width: 4px;
height: 90px;
border-radius: 999px;
background-color: var(--background-modifier-accent);
}
.volumeFill {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--volume, 0%);
border-radius: 999px;
background-color: var(--brand-primary);
pointer-events: none;
}
/* Rotated native range input layered on top of the visual
track. The -90deg rotation makes it drag vertically while
keeping all the native keyboard + pointer behaviour. The
hit area is deliberately larger than the visible track so
it's easy to grab without pixel-perfect aim. */
.volumeInput {
position: absolute;
width: 90px;
height: 28px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-90deg);
transform-origin: center;
margin: 0;
padding: 0;
background: transparent;
border: none;
outline: none;
cursor: pointer;
opacity: 0;
-webkit-appearance: none;
appearance: none;
}
.volumeInput::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
}
.volumeInput::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
}

View File

@@ -0,0 +1,335 @@
/**
* AttachmentAudio — custom audio player for `audio/*` attachments.
* Ported from the new UI's Fluxer-style layout but simplified for
* our Convex pipeline: takes the already-decrypted blob URL from
* `EncryptedAttachment` instead of resolving an MXC URL itself.
*
* ┌──────────────────────────────────────────────────────────┐
* │ ┌───┐ filename-truncated… .mp3 │
* │ │ ▶ │ ────────────────────────●─── 0:12/3:04│
* │ └───┘ │
* │ ◀) 1x ↓ │
* └──────────────────────────────────────────────────────────┘
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Download,
Pause,
Play,
SpeakerHigh,
SpeakerSlash,
Star,
} from '@phosphor-icons/react';
import { useMutation, useQuery } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import type { AttachmentMetadata } from './EncryptedAttachment';
import styles from './AttachmentAudio.module.css';
interface AttachmentAudioProps {
src: string;
filename: string;
attachment?: AttachmentMetadata;
}
const PLAYBACK_SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
function splitFilename(filename: string): { stem: string; ext: string } {
const dot = filename.lastIndexOf('.');
if (dot <= 0 || dot === filename.length - 1) {
return { stem: filename, ext: '' };
}
return { stem: filename.slice(0, dot), ext: filename.slice(dot) };
}
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')}`;
}
export function AttachmentAudio({ src, filename, attachment }: AttachmentAudioProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [playbackRateIndex, setPlaybackRateIndex] = useState(0);
const { stem, ext } = useMemo(() => splitFilename(filename), [filename]);
// Saved-media wiring — mirrors the ImageLightbox star button so
// audio attachments can be bookmarked into the Media tab.
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const savedList = useQuery(
api.savedMedia.list,
myUserId && attachment
? { userId: myUserId as Id<'userProfiles'> }
: 'skip',
);
const isSaved = !!(
attachment && savedList?.some((m) => m.url === attachment.url)
);
const saveMutation = useMutation(api.savedMedia.save);
const removeMutation = useMutation(api.savedMedia.remove);
const handleToggleSaved = useCallback(async () => {
if (!attachment || !myUserId) return;
try {
if (isSaved) {
await removeMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
});
} else {
await saveMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
kind: attachment.mimeType.split('/')[0],
filename: attachment.filename,
mimeType: attachment.mimeType,
width: attachment.width,
height: attachment.height,
size: attachment.size,
encryptionKey: attachment.key,
encryptionIv: attachment.iv,
});
}
} catch (err) {
console.warn('Failed to toggle saved audio:', err);
}
}, [attachment, isSaved, myUserId, removeMutation, saveMutation]);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const handleTime = () => setCurrentTime(el.currentTime);
const handleDuration = () => setDuration(el.duration);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleEnded = () => setIsPlaying(false);
const handleVolume = () => {
setVolume(el.volume);
setIsMuted(el.muted);
};
el.addEventListener('timeupdate', handleTime);
el.addEventListener('loadedmetadata', handleDuration);
el.addEventListener('durationchange', handleDuration);
el.addEventListener('play', handlePlay);
el.addEventListener('pause', handlePause);
el.addEventListener('ended', handleEnded);
el.addEventListener('volumechange', handleVolume);
return () => {
el.removeEventListener('timeupdate', handleTime);
el.removeEventListener('loadedmetadata', handleDuration);
el.removeEventListener('durationchange', handleDuration);
el.removeEventListener('play', handlePlay);
el.removeEventListener('pause', handlePause);
el.removeEventListener('ended', handleEnded);
el.removeEventListener('volumechange', handleVolume);
};
}, []);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.playbackRate = PLAYBACK_SPEEDS[playbackRateIndex];
}, [playbackRateIndex]);
const handleTogglePlay = useCallback(() => {
const el = audioRef.current;
if (!el || !src) return;
if (el.paused) {
void el.play().catch(() => {});
} else {
el.pause();
}
}, [src]);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const el = audioRef.current;
if (!el) return;
const next = Number(e.target.value);
el.currentTime = next;
setCurrentTime(next);
},
[],
);
const handleToggleMute = useCallback(() => {
const el = audioRef.current;
if (!el) return;
el.muted = !el.muted;
}, []);
const handleVolumeSlider = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const el = audioRef.current;
if (!el) return;
const next = Number(e.target.value);
el.volume = next;
if (next > 0 && el.muted) el.muted = false;
else if (next === 0 && !el.muted) el.muted = true;
},
[],
);
const handleCycleSpeed = useCallback(() => {
setPlaybackRateIndex((i) => (i + 1) % PLAYBACK_SPEEDS.length);
}, []);
const handleDownload = useCallback(() => {
if (!src) return;
const a = document.createElement('a');
a.href = src;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, [src, filename]);
const progressRatio =
duration > 0 ? Math.max(0, Math.min(1, currentTime / duration)) : 0;
const progressPercent = `${(progressRatio * 100).toFixed(2)}%`;
const playbackSpeedLabel = `${PLAYBACK_SPEEDS[playbackRateIndex]}x`;
return (
<div className={styles.card}>
<div className={styles.topRow}>
<button
type="button"
className={styles.playButton}
onClick={handleTogglePlay}
disabled={!src}
aria-label={isPlaying ? 'Pause' : 'Play'}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause size={18} weight="fill" />
) : (
<Play size={18} weight="fill" />
)}
</button>
<div className={styles.filenameRow}>
<span className={styles.filenameStem} title={filename}>
{stem}
</span>
{ext && <span className={styles.filenameExt}>{ext}</span>}
</div>
</div>
<div className={styles.progressRow}>
<div
className={styles.progressTrack}
style={{ ['--progress' as string]: progressPercent }}
>
<div className={styles.progressFill} />
<input
type="range"
className={styles.progressInput}
min={0}
max={duration || 0}
step={0.01}
value={currentTime}
onChange={handleSeek}
disabled={!duration}
aria-label="Seek audio"
/>
</div>
<span className={styles.timeLabel}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<div className={styles.bottomRow}>
<div className={styles.volumeWrap}>
<div className={styles.volumePopover}>
<div
className={styles.volumeTrack}
style={{
['--volume' as string]: `${(isMuted ? 0 : volume) * 100}%`,
}}
>
<div className={styles.volumeFill} />
<input
type="range"
min={0}
max={1}
step={0.01}
value={isMuted ? 0 : volume}
onChange={handleVolumeSlider}
className={styles.volumeInput}
aria-label="Volume"
/>
</div>
</div>
<button
type="button"
className={styles.iconButton}
onClick={handleToggleMute}
aria-label={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
title={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<SpeakerSlash size={18} weight="fill" />
) : (
<SpeakerHigh size={18} weight="fill" />
)}
</button>
</div>
<div className={styles.bottomRowRight}>
<button
type="button"
className={styles.speedButton}
onClick={handleCycleSpeed}
aria-label={`Playback speed: ${playbackSpeedLabel}`}
title="Playback speed"
>
{playbackSpeedLabel}
</button>
{attachment && (
<button
type="button"
className={styles.iconButton}
onClick={() => void handleToggleSaved()}
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
title={isSaved ? 'Unfavorite' : 'Favorite'}
style={
isSaved
? { color: 'var(--brand-primary, #5865f2)' }
: undefined
}
>
<Star size={18} weight={isSaved ? 'fill' : 'regular'} />
</button>
)}
<button
type="button"
className={styles.iconButton}
onClick={handleDownload}
disabled={!src}
aria-label="Download"
title="Download"
>
<Download size={18} weight="regular" />
</button>
</div>
</div>
<audio
ref={audioRef}
src={src || undefined}
preload="metadata"
className={styles.hiddenAudio}
/>
</div>
);
}

View File

@@ -0,0 +1,289 @@
/* ── Video attachment card ────────────────────────────────
Inline video renderer for `video/*` message attachments.
Poster → click → inline playback with a custom control
overlay (top hover bar + bottom bar with seek + play +
volume + time + fullscreen). */
.wrapper {
position: relative;
display: inline-block;
max-width: 100%;
border-radius: var(--radius-lg, 12px);
overflow: hidden;
background-color: var(--background-secondary);
}
.video {
display: block;
width: 100%;
height: 100%;
max-width: 100%;
max-height: inherit;
border-radius: var(--radius-lg, 12px);
/* `object-fit: contain` keeps portrait videos centered inside
the 400 × 300 cap instead of being cropped. */
object-fit: contain;
background-color: #000;
cursor: pointer;
}
/* ── Center play overlay (poster state) ───────────────────
Shown before the user first clicks play. Fades away once
hasStarted is true so the native video surface + custom
control bar take over. */
.playOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.25);
transition: background-color 0.12s;
z-index: 2;
}
.playOverlay:hover {
background-color: rgba(0, 0, 0, 0.35);
}
.playBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.6);
border: none;
color: #fff;
/* Nudge the play triangle 2px right so the visual weight
sits centered inside the circle — the triangle's
geometric centroid is left of its bounding box. */
padding-left: 4px;
box-sizing: border-box;
transition: transform 0.12s;
}
.playOverlay:hover .playBadge {
transform: scale(1.05);
}
/* ── Top hover bar (Trash / Download / Favorite) ──────────
Pinned to the top-right of the card. Hidden at rest,
fades in on wrapper hover or focus-within. */
.topBar {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
gap: 6px;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
z-index: 3;
}
.wrapper:hover .topBar,
.wrapper:focus-within .topBar {
opacity: 1;
visibility: visible;
}
.topBarButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background-color: rgba(0, 0, 0, 0.6);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.topBarButton:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.8);
}
.topBarButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Favorited state — brand-primary fill matches the image
lightbox's star treatment so the visual language stays
consistent across all three attachment viewers. */
.topBarButtonActive {
background-color: var(--brand-primary);
color: var(--text-on-brand-primary, #fff);
}
.topBarButtonActive:hover:not(:disabled) {
background-color: var(--brand-primary);
filter: brightness(1.08);
}
.topBarButtonDanger:hover:not(:disabled) {
background-color: hsl(0, calc(70% * var(--saturation-factor, 1)), 55%);
}
/* ── Bottom control bar (after hasStarted) ────────────────
Seek bar + play + volume + time + fullscreen. Fades in on
wrapper hover so it doesn't clutter a video the user is
actively watching at rest. Permanently visible inside
fullscreen because the wrapper receives the :hover state
from the system cursor whenever it moves. */
.bottomBar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 8px 12px 10px;
/* Gradient so the white controls stay readable against
bright video content without a hard edge. */
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.75) 0%,
rgba(0, 0, 0, 0) 100%
);
display: flex;
flex-direction: column;
gap: 6px;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
z-index: 3;
}
.wrapperPlaying:hover .bottomBar,
.wrapperPlaying:focus-within .bottomBar {
opacity: 1;
visibility: visible;
}
/* ── Seek track ──────────────────────────────────────────
Native range input layered over a visual track+fill so we
get keyboard + drag support for free while keeping full
visual control. Same pattern as AttachmentAudio. */
.seekTrack {
position: relative;
height: 4px;
border-radius: 999px;
background-color: rgba(255, 255, 255, 0.25);
cursor: pointer;
}
.seekFill {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: var(--progress, 0%);
border-radius: 999px;
background-color: var(--brand-primary);
pointer-events: none;
}
.seekInput {
position: absolute;
inset: -8px 0;
width: 100%;
height: calc(100% + 16px);
margin: 0;
padding: 0;
background: transparent;
border: none;
outline: none;
cursor: pointer;
opacity: 0;
-webkit-appearance: none;
appearance: none;
}
.seekInput::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
}
.seekInput::-moz-range-thumb {
width: 14px;
height: 14px;
border: none;
border-radius: 50%;
background: var(--brand-primary);
cursor: pointer;
}
/* ── Control row (play / volume / time / fullscreen) ─────── */
.controlsRow {
display: flex;
align-items: center;
gap: 8px;
}
.controlButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
transition: background-color 0.12s;
}
.controlButton:hover {
background-color: rgba(255, 255, 255, 0.14);
}
.timeLabel {
font-size: 0.75rem;
font-weight: 600;
color: #fff;
font-variant-numeric: tabular-nums;
margin-left: 2px;
}
.controlsSpacer {
flex: 1;
}
/* ── Spoiler state ────────────────────────────────────────
Blurs the poster and swaps the play badge for a SPOILER
label. First click reveals, a second click plays — same
two-step reveal the image attachment uses. */
.wrapperBlurred .video {
filter: blur(44px);
clip-path: inset(0 round var(--radius-lg, 12px));
}
.wrapperBlurred .playOverlay {
background-color: rgba(0, 0, 0, 0.45);
}
.spoilerCoverLabel {
padding: 8px 16px;
background-color: rgba(0, 0, 0, 0.75);
border-radius: 999px;
color: #fff;
font-size: 0.8125rem;
font-weight: 800;
letter-spacing: 0.08em;
pointer-events: none;
}

View File

@@ -0,0 +1,348 @@
/**
* AttachmentVideo — custom video player with Fluxer-style overlay
* controls. Ported from the new UI and adapted for our pipeline:
* takes the already-decrypted blob URL from `EncryptedAttachment`.
*
* Two playback states:
* 1. Not started → poster + center play button.
* 2. Playing → bottom seek bar, play/pause, mute, time, fullscreen.
*
* Top-right hover bar surfaces Download regardless of playback state.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import {
CornersIn,
CornersOut,
Download,
Pause,
Play,
SpeakerHigh,
SpeakerSlash,
Star,
} from '@phosphor-icons/react';
import { useMutation, useQuery } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import type { AttachmentMetadata } from './EncryptedAttachment';
import styles from './AttachmentVideo.module.css';
interface AttachmentVideoProps {
src: string;
filename: string;
width?: number;
height?: number;
attachment?: AttachmentMetadata;
}
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')}`;
}
export function AttachmentVideo({
src,
filename,
width,
height,
attachment,
}: AttachmentVideoProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [hasStarted, setHasStarted] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Saved-media wiring — mirrors ImageLightbox / AttachmentAudio
// so video attachments can be bookmarked into the Media tab.
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const savedList = useQuery(
api.savedMedia.list,
myUserId && attachment
? { userId: myUserId as Id<'userProfiles'> }
: 'skip',
);
const isSaved = !!(
attachment && savedList?.some((m) => m.url === attachment.url)
);
const saveMutation = useMutation(api.savedMedia.save);
const removeMutation = useMutation(api.savedMedia.remove);
const handleToggleSaved = useCallback(async () => {
if (!attachment || !myUserId) return;
try {
if (isSaved) {
await removeMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
});
} else {
await saveMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
kind: attachment.mimeType.split('/')[0],
filename: attachment.filename,
mimeType: attachment.mimeType,
width: attachment.width,
height: attachment.height,
size: attachment.size,
encryptionKey: attachment.key,
encryptionIv: attachment.iv,
});
}
} catch (err) {
console.warn('Failed to toggle saved video:', err);
}
}, [attachment, isSaved, myUserId, removeMutation, saveMutation]);
const widthCap = Math.min(width || 400, 400);
const heightCap = Math.min(height || 300, 300);
useEffect(() => {
const el = videoRef.current;
if (!el) return;
const onTime = () => setCurrentTime(el.currentTime);
const onDur = () => setDuration(el.duration);
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onEnded = () => setIsPlaying(false);
const onVol = () => {
setVolume(el.volume);
setIsMuted(el.muted);
};
el.addEventListener('timeupdate', onTime);
el.addEventListener('loadedmetadata', onDur);
el.addEventListener('durationchange', onDur);
el.addEventListener('play', onPlay);
el.addEventListener('pause', onPause);
el.addEventListener('ended', onEnded);
el.addEventListener('volumechange', onVol);
return () => {
el.removeEventListener('timeupdate', onTime);
el.removeEventListener('loadedmetadata', onDur);
el.removeEventListener('durationchange', onDur);
el.removeEventListener('play', onPlay);
el.removeEventListener('pause', onPause);
el.removeEventListener('ended', onEnded);
el.removeEventListener('volumechange', onVol);
};
}, []);
useEffect(() => {
const handler = () => {
setIsFullscreen(document.fullscreenElement === wrapperRef.current);
};
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
const handleStartPlay = useCallback(() => {
const el = videoRef.current;
if (!el) return;
setHasStarted(true);
void el.play().catch(() => {});
}, []);
const handleTogglePlay = useCallback(() => {
const el = videoRef.current;
if (!el) return;
if (el.paused) void el.play().catch(() => {});
else el.pause();
}, []);
const handleSeek = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const el = videoRef.current;
if (!el) return;
const next = Number(e.target.value);
el.currentTime = next;
setCurrentTime(next);
},
[],
);
const handleToggleMute = useCallback(() => {
const el = videoRef.current;
if (!el) return;
if (el.muted && el.volume === 0) el.volume = 1;
el.muted = !el.muted;
}, []);
const handleToggleFullscreen = useCallback(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void wrapper.requestFullscreen().catch(() => {});
}
}, []);
const handleDownload = useCallback(() => {
if (!src) return;
const a = document.createElement('a');
a.href = src;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}, [src, filename]);
const stop =
(fn: () => void) =>
(e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
const progressRatio = duration > 0 ? currentTime / duration : 0;
const progressPercent = `${Math.max(0, Math.min(100, progressRatio * 100)).toFixed(2)}%`;
return (
<div
ref={wrapperRef}
className={`${styles.wrapper} ${hasStarted ? styles.wrapperPlaying : ''}`}
style={{
maxWidth: `min(${widthCap}px, 100%)`,
maxHeight: `${heightCap}px`,
}}
>
<video
ref={videoRef}
src={src || undefined}
className={styles.video}
preload="metadata"
playsInline
muted={!hasStarted}
onClick={hasStarted ? handleTogglePlay : undefined}
/>
<div className={styles.topBar}>
{attachment && (
<button
type="button"
className={styles.topBarButton}
onClick={stop(() => void handleToggleSaved())}
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
title={isSaved ? 'Unfavorite' : 'Favorite'}
style={
isSaved
? { color: 'var(--brand-primary, #5865f2)' }
: undefined
}
>
<Star size={18} weight={isSaved ? 'fill' : 'regular'} />
</button>
)}
<button
type="button"
className={styles.topBarButton}
onClick={stop(handleDownload)}
disabled={!src}
aria-label="Download"
title="Download"
>
<Download size={18} weight="regular" />
</button>
</div>
{!hasStarted && (
<button
type="button"
className={styles.playOverlay}
onClick={handleStartPlay}
aria-label="Play video"
>
<span className={styles.playBadge}>
<Play size={22} weight="fill" />
</span>
</button>
)}
{hasStarted && (
<div
className={styles.bottomBar}
onClick={(e) => e.stopPropagation()}
>
<div
className={styles.seekTrack}
style={{ ['--progress' as string]: progressPercent }}
>
<div className={styles.seekFill} />
<input
type="range"
className={styles.seekInput}
min={0}
max={duration || 0}
step={0.01}
value={currentTime}
onChange={handleSeek}
disabled={!duration}
aria-label="Seek video"
/>
</div>
<div className={styles.controlsRow}>
<button
type="button"
className={styles.controlButton}
onClick={handleTogglePlay}
aria-label={isPlaying ? 'Pause' : 'Play'}
title={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? (
<Pause size={18} weight="fill" />
) : (
<Play size={18} weight="fill" />
)}
</button>
<button
type="button"
className={styles.controlButton}
onClick={handleToggleMute}
aria-label={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
title={isMuted || volume === 0 ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<SpeakerSlash size={18} weight="fill" />
) : (
<SpeakerHigh size={18} weight="fill" />
)}
</button>
<span className={styles.timeLabel}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className={styles.controlsSpacer} />
<button
type="button"
className={styles.controlButton}
onClick={handleToggleFullscreen}
aria-label={
isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'
}
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{isFullscreen ? (
<CornersIn size={18} weight="bold" />
) : (
<CornersOut size={18} weight="bold" />
)}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
background-color: var(--background-secondary-lighter, var(--background-primary));
}
.messagesWrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
.inputArea {
flex-shrink: 0;
}

View File

@@ -0,0 +1,38 @@
import { useCallback, useState } from 'react';
import { ChannelTextarea } from './ChannelTextarea';
import { Messages } from './Messages';
import { TypingUsers } from './TypingUsers';
import styles from './ChannelChatLayout.module.css';
interface ChannelChatLayoutProps {
channelId: string;
}
interface ReplyState {
eventId: string;
username: string;
}
export function ChannelChatLayout({ channelId }: ChannelChatLayoutProps) {
const [replyTo, setReplyTo] = useState<ReplyState | null>(null);
const handleReply = useCallback((eventId: string, username: string) => {
setReplyTo({ eventId, username });
}, []);
const handleCancelReply = useCallback(() => {
setReplyTo(null);
}, []);
return (
<div className={styles.container}>
<div className={styles.messagesWrapper}>
<Messages channelId={channelId} onReply={handleReply} />
</div>
<div className={styles.inputArea}>
<TypingUsers channelId={channelId} />
<ChannelTextarea channelId={channelId} replyTo={replyTo} onCancelReply={handleCancelReply} />
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
/* ── Channel details drawer — header ─────────────────────────────────── */
.header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.06));
}
.headerMain {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.headerIcon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.05));
color: var(--text-secondary);
flex-shrink: 0;
}
.headerTextBlock {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.headerTitleRow {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.headerTitleIcon {
color: var(--text-secondary);
flex-shrink: 0;
}
.headerTitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.headerSubtitle {
font-size: 0.8125rem;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
.headerCloseButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
flex-shrink: 0;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 50%;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.headerCloseButton:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
}
/* ── Quick actions row (Mute / Search / More) ────────────────────────── */
.quickActions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 16px 20px;
}
.quickAction {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 8px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
border: none;
border-radius: 12px;
color: var(--text-primary);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-weight: 500;
transition: background-color 0.15s;
}
.quickAction:hover:not(:disabled) {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
}
.quickAction:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.quickActionIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-secondary);
}
.quickActionLabel {
line-height: 1;
}
/* ── Tab bar (Members / Pins) ────────────────────────────────────────
Flex row of two equal-width tab buttons. Each button is a column
layout: icon + label side by side, padded to ~12px tall. The
active tab swaps the text + icon colour to --brand-primary-light
and draws a 2px underline via an absolutely-positioned ::after
that hangs off the bottom edge of the button and overlaps the
row's 1px divider. Inactive tabs stay muted. */
.tabBar {
position: relative;
display: flex;
align-items: stretch;
padding: 0;
border-bottom: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.06));
}
.tabButton {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 8px;
background: none;
border: none;
color: var(--text-secondary, var(--text-primary-muted));
cursor: pointer;
font: inherit;
font-size: 0.9375rem;
font-weight: 600;
transition: color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.tabButton svg {
flex-shrink: 0;
color: var(--text-secondary, var(--text-primary-muted));
transition: color 0.15s;
}
.tabButtonActive {
color: var(--brand-primary-light);
}
.tabButtonActive svg {
color: var(--brand-primary-light);
}
/* Active tab underline — a 2px bar drawn at the bottom of the button,
translated down 1px so it overlaps and visually replaces the tab
row's divider underneath that tab. */
.tabButtonActive::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background-color: var(--brand-primary-light);
border-radius: 2px 2px 0 0;
}
.membersWrapper {
padding: 1rem;
}
.emptyState {
padding: 16px 20px;
font-size: 0.875rem;
color: var(--text-tertiary);
text-align: center;
}
/* ── Pins tab ───────────────────────────────────────────────────────*/
.pinsList {
display: flex;
flex-direction: column;
padding: 4px 0 16px;
}
.pinsLoading {
padding: 16px 20px;
text-align: center;
font-size: 0.8125rem;
color: var(--text-primary-muted);
}
/* Fluxer-style empty state: flag icon + "You've reached the end"
title + explanatory body. Matches the reference screenshot. */
.pinsEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 32px 32px;
text-align: center;
}
.pinsEmptyIcon {
color: var(--text-primary-muted);
margin-bottom: 4px;
}
.pinsEmptyTitle {
font-size: 1.125rem;
font-weight: 800;
color: var(--text-primary);
}
.pinsEmptyBody {
font-size: 0.875rem;
color: var(--text-primary-muted);
line-height: 1.4;
max-width: 280px;
}

View File

@@ -0,0 +1,301 @@
/**
* ChannelDetailsDrawer — mobile bottom sheet opened by tapping the
* channel name in the ChannelHeader. Shows a Members/Pins tab switcher
* with the channel's member list and pinned messages.
*/
import { useEffect, useMemo, useState } from 'react';
import { Users, PushPin } from '@phosphor-icons/react';
import { useQuery } from 'convex/react';
import { BottomSheet } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { MemberListContainer } from '../member/MemberListContainer';
import { PinnedMessageRow, ReachedEndNotice, type PinnedMessage } from './PinnedMessageRow';
import type { AttachmentMetadata } from './EncryptedAttachment';
type DrawerTab = 'members' | 'pins';
interface ChannelDetailsDrawerProps {
isOpen: boolean;
onClose: () => void;
channelId: string;
channelName?: string;
channelType?: string;
}
const tabBarStyle: React.CSSProperties = {
display: 'flex',
gap: 8,
padding: '4px 12px 12px',
borderBottom: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
};
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: '10px 12px',
borderRadius: 10,
border: 'none',
background: active ? 'var(--bg-hover, rgba(255,255,255,0.08))' : 'transparent',
color: active ? 'var(--text-primary, #fff)' : 'var(--text-muted, #a0a0a8)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
});
const tabBodyStyle: React.CSSProperties = {
padding: '12px',
minHeight: 240,
};
const emptyStateStyle: React.CSSProperties = {
padding: '24px 12px',
textAlign: 'center',
color: 'var(--text-muted, #a0a0a8)',
fontSize: 14,
};
export function ChannelDetailsDrawer({
isOpen,
onClose,
channelId,
channelName,
channelType,
}: ChannelDetailsDrawerProps) {
const [activeTab, setActiveTab] = useState<DrawerTab>('members');
// Reset to Members whenever the drawer re-opens so a previously
// selected Pins tab doesn't follow across channel changes.
useEffect(() => {
if (isOpen) setActiveTab('members');
}, [isOpen, channelId]);
const title = channelName ?? 'Channel';
return (
<BottomSheet isOpen={isOpen} onClose={onClose} title={title}>
<div style={tabBarStyle}>
<button
type="button"
style={tabButtonStyle(activeTab === 'members')}
onClick={() => setActiveTab('members')}
role="tab"
aria-selected={activeTab === 'members'}
>
<Users size={18} weight="fill" />
<span>Members</span>
</button>
<button
type="button"
style={tabButtonStyle(activeTab === 'pins')}
onClick={() => setActiveTab('pins')}
role="tab"
aria-selected={activeTab === 'pins'}
>
<PushPin size={18} weight="fill" />
<span>Pins</span>
</button>
</div>
<div style={tabBodyStyle}>
{activeTab === 'members' ? (
<MemberListContainer channelId={channelId} variant="drawer" />
) : (
<PinsTabContent
channelId={channelId}
isOpen={isOpen && activeTab === 'pins'}
onClose={onClose}
/>
)}
{/* channelType currently unused; reserved for future voice-specific UI */}
<span style={{ display: 'none' }}>{channelType}</span>
</div>
</BottomSheet>
);
}
// ── Pins tab ────────────────────────────────────────────────────────
const TAG_LENGTH = 32;
interface PinsTabContentProps {
channelId: string;
isOpen: boolean;
onClose: () => void;
}
function PinsTabContent({ channelId, isOpen, onClose }: PinsTabContentProps) {
const { crypto } = usePlatform();
const userId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const privateKeyPem =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('privateKey')
: null;
const allKeys = useQuery(
api.channelKeys.getKeysForUser,
userId && isOpen ? ({ userId: userId as any } as any) : 'skip',
);
// Merge all encrypted key bundles → { channelId: keyHex }
const [channelKey, setChannelKey] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!allKeys || !privateKeyPem) {
setChannelKey(null);
return;
}
(async () => {
const merged: Record<string, string> = {};
for (const item of allKeys) {
try {
const bundleJson = await crypto.privateDecrypt(
privateKeyPem,
(item as any).encrypted_key_bundle,
);
Object.assign(merged, JSON.parse(bundleJson));
} catch (err) {
console.error('Failed to decrypt key bundle:', err);
}
}
if (cancelled) return;
setChannelKey(merged[channelId] ?? null);
})();
return () => {
cancelled = true;
};
}, [allKeys, privateKeyPem, channelId, crypto]);
const pinnedRaw = useQuery(
api.messages.listPinned,
isOpen
? ({
channelId: channelId as any,
userId: (userId as any) ?? undefined,
} as any)
: 'skip',
);
const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map());
useEffect(() => {
if (!channelKey || !pinnedRaw) return;
let cancelled = false;
(async () => {
const next = new Map(decryptedMap);
let changed = false;
for (const msg of pinnedRaw as any[]) {
const id = msg.id as string;
if (next.has(id)) continue;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
next.set(id, '[Invalid Encrypted Message]');
changed = true;
continue;
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
channelKey,
msg.nonce,
tag,
);
if (cancelled) return;
next.set(id, plaintext);
changed = true;
} catch {
next.set(id, '[Unable to decrypt]');
changed = true;
}
}
if (changed && !cancelled) setDecryptedMap(next);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pinnedRaw, channelKey]);
const pinned: PinnedMessage[] = useMemo(() => {
if (!pinnedRaw) return [];
return (pinnedRaw as any[])
.slice()
.sort((a, b) => {
const at = a.created_at ? new Date(a.created_at).getTime() : 0;
const bt = b.created_at ? new Date(b.created_at).getTime() : 0;
return bt - at;
})
.map((msg) => {
const id = msg.id as string;
const rawContent = decryptedMap.get(id) ?? '';
let text = rawContent;
const attachments: AttachmentMetadata[] = [];
try {
const parsed = JSON.parse(rawContent);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item?.type === 'attachment' && item.url && item.key && item.iv) {
attachments.push(item as AttachmentMetadata);
}
}
text = '';
} else if (parsed.type === 'attachment' && parsed.url && parsed.key && parsed.iv) {
attachments.push(parsed as AttachmentMetadata);
text = '';
} else if (parsed.text !== undefined) {
text = String(parsed.text);
}
}
} catch {
// plain text — leave as-is
}
return {
id,
authorName: msg.displayName || msg.username || 'User',
authorAvatarUrl: msg.avatarUrl ?? null,
content: text,
timestamp: msg.created_at
? new Date(msg.created_at).getTime()
: Date.now(),
attachments,
} as PinnedMessage;
});
}, [pinnedRaw, decryptedMap]);
const handleJumpTo = (messageId: string) => {
window.dispatchEvent(
new CustomEvent('brycord:scroll-to-message', {
detail: { channelId, messageId },
}),
);
onClose();
};
if (pinnedRaw === undefined) {
return <div style={emptyStateStyle}>Loading pinned messages</div>;
}
if (pinned.length === 0) {
return <div style={emptyStateStyle}>No pinned messages yet.</div>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{pinned.map((msg) => (
<PinnedMessageRow
key={msg.id}
message={msg}
onClick={() => handleJumpTo(msg.id)}
/>
))}
<ReachedEndNotice />
</div>
);
}

View File

@@ -0,0 +1,405 @@
.container {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
height: var(--layout-header-height, 3.5rem);
min-height: var(--layout-header-height, 3.5rem);
padding: 0 16px;
gap: 16px;
border-bottom: 1px solid var(--user-area-divider-color);
background-color: var(--background-secondary-lighter, var(--background-primary));
z-index: var(--z-index-elevated-3, 30);
flex-shrink: 0;
}
.channelInfo {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
}
/* Desktop 1:1 DM — converts the channelInfo row into a tappable
button that opens the other user's profile modal. Resets the
native button chrome and adds a subtle hover background so the
click target reads as interactive. */
.channelInfoClickable {
padding: 4px 8px;
margin-left: -8px;
background: transparent;
border: none;
border-radius: var(--radius-md, 6px);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.15s ease;
}
.channelInfoClickable:hover {
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
}
.icon {
color: var(--channel-icon, var(--text-muted));
flex-shrink: 0;
}
/* Wrapper for the small user-avatar rendered in place of the
channel icon when viewing a 1:1 DM. Zeroes the layout chrome
around the Avatar so it sits flush in the header row like the
Hash icon it replaces. */
.dmAvatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 2px;
}
/* Mobile-only back button — appears on the left of the channel title
when SelectionStore.isMobileViewport is true. Navigates to the parent
space's channel list (or the DM list). */
.backButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-right: 4px;
margin-left: -8px;
background: transparent;
border: none;
border-radius: var(--radius-md, 6px);
color: var(--interactive-normal);
cursor: pointer;
flex-shrink: 0;
transition: color 0.15s ease, background-color 0.15s ease;
}
.backButton:hover {
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
color: var(--interactive-hover);
}
/* Mobile-only channel info wrapper — holds the back button + the
tappable channel-name-with-caret button side by side. */
.channelInfoMobile {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.channelInfoButton {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 6px 8px;
border-radius: var(--radius-md, 6px);
background: transparent;
border: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.15s ease;
}
.channelInfoButton:hover {
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
}
.channelInfoButton .name {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.caretRight {
color: var(--text-tertiary);
flex-shrink: 0;
}
.name {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5rem;
max-height: 1.5rem;
}
.divider {
width: 1px;
height: 24px;
background-color: var(--background-modifier-accent);
flex-shrink: 0;
}
.topic {
font-size: 0.875rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.headerButtons {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.headerButton {
position: relative;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: var(--interactive-normal);
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease;
}
.headerButton:hover {
background-color: color-mix(in srgb, var(--interactive-normal) 10%, transparent);
color: var(--interactive-hover);
}
.headerButtonActive {
color: var(--interactive-active);
}
/* Circular-background variant used for the DM Phone / Video call
buttons. Matches the Fluxer reference image where the icons sit
inside a solid dark circle. Uses --guild-list-foreground (same
shade as the empty server icon and the DM sidebar action pills)
for visual consistency with the rest of the DM chrome. */
.headerButtonCircle {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var(--guild-list-foreground);
color: var(--text-primary);
}
.headerButtonCircle:hover {
background-color: var(--guild-list-foreground);
filter: brightness(1.15);
color: var(--text-primary);
}
/* Disabled appearance for the members-list toggle at narrow viewports.
We use aria-disabled rather than the native disabled attribute so the
Tooltip wrapper can still fire on hover and explain WHY the button is
unavailable. The class below overrides the hover state so it doesn't
lie about being interactive. */
.headerButtonDisabled {
opacity: 0.5;
cursor: not-allowed;
color: var(--interactive-normal);
}
.headerButtonDisabled:hover {
background: transparent;
color: var(--interactive-normal);
}
/* ── Search bar (always visible) ─────────────────────────────── */
.searchBarWrapper {
position: relative;
}
.searchBar {
display: flex;
align-items: center;
gap: 6px;
width: 244px;
height: 36px;
padding: 0 8px;
border-radius: var(--radius-xl, 12px);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-tertiary);
transition: border-color 0.1s;
}
.searchBar:focus-within {
border-color: var(--background-modifier-accent);
}
.searchBarIcon {
flex-shrink: 0;
color: var(--text-tertiary);
}
.searchBarInput {
flex: 1;
border: none;
background: none;
color: var(--text-primary);
font-size: 0.875rem;
font-family: inherit;
padding: 0;
outline: none;
min-width: 0;
}
.searchBarInput:focus,
.searchBarInput:focus-visible {
outline: none;
box-shadow: none;
}
.searchBarInput::placeholder {
color: var(--text-tertiary);
}
.searchBarClear {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
}
.searchBarClear:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
/* ── Filter dropdown ─────────────────────────────────────────── */
.filterDropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 380px;
border-radius: var(--radius-xl, 12px);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-floating, var(--background-tertiary));
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2), 0 8px 24px rgba(0, 0, 0, 0.28);
z-index: 1000;
padding: 4px;
}
.filterSection {
display: flex;
flex-direction: column;
}
.filterSectionHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.filterSectionIcon {
display: flex;
align-items: center;
color: var(--text-tertiary);
}
.filterOption {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 8px;
border-radius: var(--radius-md);
background: none;
border: none;
cursor: pointer;
text-align: left;
font-family: inherit;
font-size: 0.875rem;
color: var(--text-primary);
transition: background-color 0.1s;
}
.filterOption:hover {
background-color: var(--background-modifier-hover);
}
.filterBadge {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
background-color: var(--background-secondary);
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
border: 1px solid var(--background-modifier-accent);
flex-shrink: 0;
}
.filterDesc {
flex: 1;
color: var(--text-tertiary);
font-size: 0.8125rem;
}
.filterPlus {
flex-shrink: 0;
color: var(--text-tertiary);
}
.filterOption:hover .filterPlus {
color: var(--text-primary);
}
.dmHeaderButton {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 10px 4px 4px;
border-radius: 6px;
background: transparent;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
min-width: 0;
}
.dmHeaderButton:hover {
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
}
.dmHeaderName {
font-size: 16px;
font-weight: 600;
color: var(--text-primary, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,497 @@
import {
ArrowLeft,
CaretRight,
Funnel,
Hash,
MagnifyingGlass,
Phone,
Plus,
PushPin,
SpeakerHigh,
Users,
VideoCamera,
X,
} from '@phosphor-icons/react';
import { Avatar, Tooltip } from '@discord-clone/ui';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useIsMobile } from '../../hooks/useIsMobile';
import { usePlatform } from '../../platform';
import { useOnlineUsers } from '../../contexts/PresenceContext';
import { api } from '../../../../../convex/_generated/api';
import { MemberProfileModal } from '../member/MemberProfileModal';
import { MobileMemberProfileSheet } from '../member/MobileMemberProfileSheet';
import { useKeybinds } from '../../contexts/KeybindContext';
import { ChannelHeaderPinsPopover } from './ChannelHeaderPinsPopover';
import styles from './ChannelHeader.module.css';
const SEARCH_FILTERS = [
{ key: 'from:', desc: 'user' },
{ key: 'mentions:', desc: 'user' },
{ key: 'has:', desc: 'link, embed or file' },
{ key: 'before:', desc: 'specific date' },
{ key: 'during:', desc: 'specific date' },
{ key: 'after:', desc: 'specific date' },
{ key: 'pinned:', desc: 'true or false' },
];
interface ChannelLike {
_id?: string;
name?: string;
type?: string;
topic?: string;
}
interface ChannelHeaderProps {
channel?: ChannelLike | null;
serverId?: string;
onOpenChannelDetails?: () => void;
onOpenSearchDrawer?: () => void;
membersVisible?: boolean;
onToggleMembers?: () => void;
/** Hide the members button entirely. DMs use this since a 1:1
* conversation doesn't need a sidebar member list. */
hideMembersButton?: boolean;
/** When true, the viewport is below the members-list breakpoint. The
* Users button becomes a no-op and gets disabled styling. */
isNarrow?: boolean;
/** Controlled search input. State lives in ChannelView so the sibling
* SearchPanel can read the same query. */
searchQuery?: string;
onSearchChange?: (next: string) => void;
onSearchClear?: () => void;
}
export function ChannelHeader({
channel,
onOpenChannelDetails,
membersVisible = true,
onToggleMembers,
hideMembersButton = false,
isNarrow = false,
searchQuery = '',
onSearchChange,
onSearchClear,
}: ChannelHeaderProps) {
const isVoice = channel?.type === 'voice';
const isDM = channel?.type === 'dm';
const isMobile = useIsMobile();
const navigate = useNavigate();
const location = useLocation();
const { crypto } = usePlatform();
const { resolveStatus } = useOnlineUsers();
const keybinds = useKeybinds();
const pinsButtonRef = useRef<HTMLButtonElement>(null);
const dmHeaderButtonRef = useRef<HTMLButtonElement>(null);
const [pinsAnchor, setPinsAnchor] = useState<DOMRect | null>(null);
const [profileOpen, setProfileOpen] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// ── DM participant lookup ──────────────────────────────────────
// When this channel is a DM we pull the other participant out of
// `api.dms.listDMs` and resolve their profile via getPublicKeys.
const myUserId =
typeof localStorage !== 'undefined'
? localStorage.getItem('userId')
: null;
const dmRows = useQuery(
api.dms.listDMs,
isDM && myUserId ? { userId: myUserId as any } : 'skip',
);
const allUsers = useQuery(
api.auth.getPublicKeys,
isDM ? {} : 'skip',
) ?? [];
const otherParticipant = useMemo(() => {
if (!isDM || !dmRows || !channel?._id) return null;
const row = (dmRows as any[]).find((r) => r.channel_id === channel._id);
if (!row) return null;
const profile = allUsers.find((u) => u.id === row.other_user_id);
const storedStatus =
(profile?.status as string | undefined) ||
(row.other_user_status as string | undefined) ||
'offline';
const liveStatus = resolveStatus(storedStatus, row.other_user_id);
// DM header uses the raw username (not the server display
// name) so people always know who they're actually talking to.
const username =
(profile?.username as string | undefined) ||
(row.other_username as string | undefined) ||
'user';
return {
userId: row.other_user_id as string,
displayName: username,
username,
avatarUrl: profile?.avatarUrl ?? row.other_user_avatar_url ?? null,
status: liveStatus,
};
}, [isDM, dmRows, allUsers, channel?._id, resolveStatus]);
const rotateDMKey = useMutation(api.channelKeys.rotateDMKey);
const handleRotateDMKey = async () => {
if (!channel?._id || !otherParticipant || !myUserId) {
throw new Error("Can't rotate — missing DM context.");
}
const privateKey =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('privateKey')
: null;
if (!privateKey) {
throw new Error('No session key available.');
}
const me = allUsers.find((u) => u.id === myUserId);
const other = allUsers.find((u) => u.id === otherParticipant.userId);
if (!me?.public_identity_key || !other?.public_identity_key) {
throw new Error("One participant's public key is missing.");
}
const newKeyHex = await crypto.randomBytes(32);
const plaintext = JSON.stringify({ [channel._id]: newKeyHex });
const [myBundle, otherBundle] = await Promise.all([
crypto.publicEncrypt(me.public_identity_key, plaintext),
crypto.publicEncrypt(other.public_identity_key, plaintext),
]);
await rotateDMKey({
channelId: channel._id as any,
initiatorUserId: myUserId as any,
entries: [
{ userId: myUserId as any, encryptedKeyBundle: myBundle },
{
userId: otherParticipant.userId as any,
encryptedKeyBundle: otherBundle,
},
],
});
};
const handleOpenProfile = () => {
setProfileOpen(true);
};
// Wire keybinds that affect this header: toggle pins popover,
// toggle member list. The KeybindProvider dispatches
// `brycord:keybind:<id>` events on the window.
useEffect(() => {
const onTogglePins = () => handleTogglePins();
const onToggleMembers = () => {
if (hideMembersButton) return;
handleToggleMembers();
};
window.addEventListener('brycord:keybind:popouts.openPins', onTogglePins);
window.addEventListener(
'brycord:keybind:popouts.toggleMembers',
onToggleMembers,
);
return () => {
window.removeEventListener('brycord:keybind:popouts.openPins', onTogglePins);
window.removeEventListener(
'brycord:keybind:popouts.toggleMembers',
onToggleMembers,
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hideMembersButton]);
const setSearchQuery = (next: string) => {
onSearchChange?.(next);
};
const clearSearchQuery = () => {
if (onSearchClear) onSearchClear();
else onSearchChange?.('');
};
const onInputBlur = () => {
// 200ms delay — long enough for the chip click to register
setTimeout(() => setShowFilters(false), 200);
};
const handleFilterClick = (key: string) => {
setSearchQuery(searchQuery ? `${searchQuery} ${key}` : key);
requestAnimationFrame(() => searchInputRef.current?.focus());
};
/**
* On mobile, the back button strips the trailing `/channelId` segment
* from the current path so we return to the channel list view. For
* `/channels/home/:id` this yields `/channels/home`; for
* `/channels/@me/:id` → `/channels/@me`. Falls back to `/channels/@me`
* if the path doesn't look like a channel URL.
*/
const handleMobileBack = () => {
const path = location.pathname;
const match = path.match(/^(\/channels\/(?:home|@me|[^/]+))(?:\/[^/]+)?$/);
navigate(match ? match[1] : '/channels/@me');
};
const handleTogglePins = () => {
if (pinsAnchor) {
setPinsAnchor(null);
return;
}
const rect = pinsButtonRef.current?.getBoundingClientRect();
if (rect) setPinsAnchor(rect);
};
const handleToggleMembers = () => {
if (isNarrow) return;
if (onToggleMembers) {
onToggleMembers();
} else {
window.dispatchEvent(new CustomEvent('brycord:toggle-members'));
}
};
if (isMobile) {
return (
<div className={styles.container}>
<div className={styles.channelInfoMobile}>
<button
type="button"
className={styles.backButton}
onClick={handleMobileBack}
aria-label="Back to channels"
>
<ArrowLeft size={20} weight="bold" />
</button>
{isDM && otherParticipant ? (
<button
ref={dmHeaderButtonRef}
type="button"
className={styles.channelInfoButton}
onClick={handleOpenProfile}
>
<Avatar
src={otherParticipant.avatarUrl}
size={26}
fallback={otherParticipant.displayName}
status={otherParticipant.status as any}
/>
<span className={styles.name}>
{otherParticipant.displayName}
</span>
<CaretRight size={14} weight="bold" className={styles.caretRight} />
</button>
) : (
<button
type="button"
className={styles.channelInfoButton}
onClick={onOpenChannelDetails}
>
{isVoice ? (
<SpeakerHigh size={22} className={styles.icon} />
) : (
<Hash size={22} weight="bold" className={styles.icon} />
)}
<span className={styles.name}>{channel?.name || 'Channel'}</span>
<CaretRight size={14} weight="bold" className={styles.caretRight} />
</button>
)}
</div>
{otherParticipant && (
<MemberProfileModal
isOpen={profileOpen}
onClose={() => setProfileOpen(false)}
member={{ userId: otherParticipant.userId }}
onRotateKey={handleRotateDMKey}
/>
)}
</div>
);
}
return (
<div className={styles.container}>
{isDM && otherParticipant ? (
<button
ref={dmHeaderButtonRef}
type="button"
className={styles.dmHeaderButton}
onClick={handleOpenProfile}
aria-label={`Open ${otherParticipant.displayName}'s profile`}
>
<Avatar
src={otherParticipant.avatarUrl}
size={28}
fallback={otherParticipant.displayName}
status={otherParticipant.status as any}
/>
<span className={styles.dmHeaderName}>
{otherParticipant.displayName}
</span>
</button>
) : (
<div className={styles.channelInfo}>
{isVoice ? (
<SpeakerHigh size={20} className={styles.icon} />
) : (
<Hash size={20} weight="bold" className={styles.icon} />
)}
<span className={styles.name}>{channel?.name || 'Channel'}</span>
{channel?.topic && (
<>
<div className={styles.divider} />
<span className={styles.topic}>{channel.topic}</span>
</>
)}
</div>
)}
<div className={styles.headerButtons}>
{isDM && (
<>
<Tooltip content="Start Voice Call" placement="bottom">
<button
type="button"
className={styles.headerButton}
aria-label="Start Voice Call"
disabled
title="Voice calls coming soon"
>
<Phone size={20} />
</button>
</Tooltip>
<Tooltip content="Start Video Call" placement="bottom">
<button
type="button"
className={styles.headerButton}
aria-label="Start Video Call"
disabled
title="Video calls coming soon"
>
<VideoCamera size={20} />
</button>
</Tooltip>
</>
)}
<Tooltip
content="Pinned Messages"
shortcut={keybinds.getCombo('popouts.openPins')}
placement="bottom"
>
<button
ref={pinsButtonRef}
type="button"
className={`${styles.headerButton} ${pinsAnchor ? styles.headerButtonActive : ''}`}
aria-label="Pinned"
onClick={handleTogglePins}
>
<PushPin size={20} />
</button>
</Tooltip>
{!hideMembersButton && (
<Tooltip
content={
isNarrow
? 'Window too narrow for member list'
: 'Member List'
}
shortcut={
isNarrow ? undefined : keybinds.getCombo('popouts.toggleMembers')
}
placement="bottom"
>
<button
type="button"
className={`${styles.headerButton} ${
membersVisible && !isNarrow ? styles.headerButtonActive : ''
} ${isNarrow ? styles.headerButtonDisabled : ''}`}
aria-label="Members"
aria-disabled={isNarrow || undefined}
onClick={handleToggleMembers}
>
<Users size={20} />
</button>
</Tooltip>
)}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<MagnifyingGlass
size={16}
weight="regular"
className={styles.searchBarIcon}
/>
<input
ref={searchInputRef}
className={styles.searchBarInput}
placeholder="Search messages"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setShowFilters(true)}
onBlur={onInputBlur}
/>
{searchQuery && (
<button
type="button"
className={styles.searchBarClear}
onClick={clearSearchQuery}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
{showFilters && !searchQuery && (
<div className={styles.filterDropdown}>
<div className={styles.filterSection}>
<div className={styles.filterSectionHeader}>
<span className={styles.filterSectionIcon}>
<Funnel size={12} />
</span>
<span>Search Filters</span>
</div>
{SEARCH_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={styles.filterOption}
onMouseDown={(e) => {
// Use mousedown not click so it fires before the input blur
e.preventDefault();
handleFilterClick(f.key);
}}
>
<span className={styles.filterBadge}>{f.key}</span>
<span className={styles.filterDesc}> {f.desc}</span>
<Plus size={14} className={styles.filterPlus} />
</button>
))}
</div>
</div>
)}
</div>
</div>
{channel?._id && (
<ChannelHeaderPinsPopover
isOpen={pinsAnchor !== null}
anchorRect={pinsAnchor}
channelId={channel._id}
onClose={() => setPinsAnchor(null)}
/>
)}
{otherParticipant && (
isMobile ? (
<MobileMemberProfileSheet
isOpen={profileOpen}
onClose={() => setProfileOpen(false)}
member={{ userId: otherParticipant.userId }}
onRotateKey={handleRotateDMKey}
/>
) : (
<MemberProfileModal
isOpen={profileOpen}
onClose={() => setProfileOpen(false)}
member={{ userId: otherParticipant.userId }}
onRotateKey={handleRotateDMKey}
/>
)
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
/* ── Pinned Messages popover ─────────────────────────────────────────
Portal-rendered dropdown anchored to the ChannelHeader pin icon
button. Shows the channel's pinned messages in reverse
chronological order, same style as Discord / Fluxer. */
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-index-modal, 10000);
pointer-events: none;
}
.popover {
/* Scoped custom properties for this component's sizing
constants, so they live next to the component that uses them
instead of cluttering global.css. The track-size + thumb
formula follow Fluxer's auto-hide scroller pattern: an 8px
track with a 4px visible thumb (4px = 8px track - 2px border
on each side, drawn via background-clip: padding-box). The
scoped --scrollbar-thumb-bg override resets the global
color-mix value to the literal rgba Fluxer uses here. */
--pins-popout-header-height: 68px;
--scroller-track-size: 8px;
--scrollbar-thumb-bg: rgba(121, 122, 124, 0.4);
position: absolute;
pointer-events: auto;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.2),
0 8px 24px -4px rgba(0, 0, 0, 0.45),
0 20px 48px -8px rgba(0, 0, 0, 0.3);
width: 480px;
max-width: calc(100vw - 24px);
max-height: min(calc(100vh - 120px), 720px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
min-height: var(--pins-popout-header-height);
flex-shrink: 0;
box-sizing: border-box;
}
.headerIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-primary);
flex-shrink: 0;
}
.title {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
line-height: 1.25;
margin: 0;
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 0;
/* Firefox — thin scrollbar, transparent thumb by default, track
transparent always. Flips to the real thumb color on hover. */
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.15s ease;
}
.body:hover {
scrollbar-color: var(--scrollbar-thumb-bg) transparent;
}
/* WebKit — auto-hide scrollbar. Track stays transparent; thumb
background is transparent by default so the bar is invisible,
and fades in only on hover. The transparent border + padding-box
background-clip collapses the visible thumb width to
max(2px, track-size - 4px) — 4px visible thumb inside an 8px
track. */
.body::-webkit-scrollbar {
width: var(--scroller-track-size);
height: var(--scroller-track-size);
}
.body::-webkit-scrollbar-track {
background-color: transparent;
}
.body::-webkit-scrollbar-thumb {
background-color: transparent;
border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
min-height: 40px;
transition: background-color 0.15s ease;
}
.body:hover::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-bg);
}
.body::-webkit-scrollbar-corner {
background-color: transparent;
}
/* Hide the up/down arrow buttons some platforms (notably macOS
Chrome with "Show scroll arrows" enabled) render at the ends
of the track. Fluxer and Discord both suppress these for a
cleaner look. */
.body::-webkit-scrollbar-button {
display: none;
width: 0;
height: 0;
}
.loading {
padding: 16px 20px;
text-align: center;
font-size: 0.8125rem;
color: var(--text-primary-muted);
}
/* Fluxer-style empty state — mirrors the ChannelDetailsDrawer pin
empty state so mobile and desktop feel consistent. */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 24px;
text-align: center;
}
.emptyIcon {
color: var(--text-primary-muted);
margin-bottom: 4px;
}
.emptyTitle {
font-size: 1rem;
font-weight: 800;
color: var(--text-primary);
}
.emptyBody {
font-size: 0.8125rem;
color: var(--text-primary-muted);
line-height: 1.4;
max-width: 280px;
}

View File

@@ -0,0 +1,274 @@
/**
* ChannelHeaderPinsPopover — desktop popover anchored to the
* ChannelHeader pin button. Fetches pinned messages from Convex,
* decrypts them using the channel key, and renders a scrollable
* list. Dismisses on outside click or Escape.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { PushPin } from '@phosphor-icons/react';
import { useQuery } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { PinnedMessageRow, ReachedEndNotice, type PinnedMessage } from './PinnedMessageRow';
import { PinConfirmationModal } from './PinConfirmationModal';
import type { AttachmentMetadata } from './EncryptedAttachment';
import styles from './ChannelHeaderPinsPopover.module.css';
interface ChannelHeaderPinsPopoverProps {
isOpen: boolean;
channelId: string;
anchorRect: DOMRect | null;
onClose: () => void;
}
// Ciphertext format matches Messages.tsx: content + 32-hex-char GCM tag.
const TAG_LENGTH = 32;
export function ChannelHeaderPinsPopover({
isOpen,
channelId,
anchorRect,
onClose,
}: ChannelHeaderPinsPopoverProps) {
const { crypto } = usePlatform();
const ref = useRef<HTMLDivElement>(null);
// Outside click + Escape dismiss.
useEffect(() => {
if (!isOpen) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onClose]);
// Position the popover below and right-aligned to the pin button,
// clamped to the viewport.
const positionStyle = useMemo<React.CSSProperties>(() => {
if (!anchorRect) return { top: 0, left: 0 };
const POPOVER_WIDTH = 480;
const POPOVER_MAX_HEIGHT = Math.min(window.innerHeight - 120, 720);
const GAP = 8;
const MARGIN = 12;
const rawLeft = anchorRect.right - POPOVER_WIDTH;
const left = Math.max(
MARGIN,
Math.min(rawLeft, window.innerWidth - POPOVER_WIDTH - MARGIN),
);
const rawTop = anchorRect.bottom + GAP;
const top = Math.max(
MARGIN,
Math.min(rawTop, window.innerHeight - POPOVER_MAX_HEIGHT - MARGIN),
);
return { top, left };
}, [anchorRect]);
// Channel key decryption — mirrors Messages.tsx.
const userId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const privateKeyPem =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('privateKey')
: null;
const allKeys = useQuery(
api.channelKeys.getKeysForUser,
userId && isOpen ? ({ userId: userId as any } as any) : 'skip',
);
// Each encrypted_key_bundle decrypts to a JSON object mapping
// channelId → keyHex. Decrypt once per bundle and merge.
const [channelKey, setChannelKey] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!allKeys || !privateKeyPem) {
setChannelKey(null);
return;
}
(async () => {
const merged: Record<string, string> = {};
for (const item of allKeys) {
try {
const bundleJson = await crypto.privateDecrypt(
privateKeyPem,
(item as any).encrypted_key_bundle,
);
Object.assign(merged, JSON.parse(bundleJson));
} catch (err) {
console.error('Failed to decrypt key bundle:', err);
}
}
if (cancelled) return;
setChannelKey(merged[channelId] ?? null);
})();
return () => {
cancelled = true;
};
}, [allKeys, privateKeyPem, channelId, crypto]);
// Fetch pinned messages.
const pinnedRaw = useQuery(
api.messages.listPinned,
isOpen
? ({
channelId: channelId as any,
userId: (userId as any) ?? undefined,
} as any)
: 'skip',
);
// Decrypt pinned message content.
const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map());
useEffect(() => {
if (!channelKey || !pinnedRaw) return;
let cancelled = false;
(async () => {
const next = new Map(decryptedMap);
let changed = false;
for (const msg of pinnedRaw as any[]) {
const id = msg.id as string;
if (next.has(id)) continue;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
next.set(id, '[Invalid Encrypted Message]');
changed = true;
continue;
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
channelKey,
msg.nonce,
tag,
);
if (cancelled) return;
next.set(id, plaintext);
changed = true;
} catch {
next.set(id, '[Unable to decrypt]');
changed = true;
}
}
if (changed && !cancelled) setDecryptedMap(next);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pinnedRaw, channelKey]);
const pinned: PinnedMessage[] = useMemo(() => {
if (!pinnedRaw) return [];
return (pinnedRaw as any[])
.slice()
.sort((a, b) => {
const at = a.created_at ? new Date(a.created_at).getTime() : 0;
const bt = b.created_at ? new Date(b.created_at).getTime() : 0;
return bt - at;
})
.map((msg) => {
const id = msg.id as string;
const rawContent = decryptedMap.get(id) ?? '';
let text = rawContent;
const attachments: AttachmentMetadata[] = [];
try {
const parsed = JSON.parse(rawContent);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item?.type === 'attachment' && item.url && item.key && item.iv) {
attachments.push(item as AttachmentMetadata);
}
}
text = '';
} else if (parsed.type === 'attachment' && parsed.url && parsed.key && parsed.iv) {
attachments.push(parsed as AttachmentMetadata);
text = '';
} else if (parsed.text !== undefined) {
text = String(parsed.text);
}
}
} catch {
// plain text — leave as-is
}
return {
id,
authorName: msg.displayName || msg.username || 'User',
authorAvatarUrl: msg.avatarUrl ?? null,
content: text,
timestamp: msg.created_at
? new Date(msg.created_at).getTime()
: Date.now(),
attachments,
} as PinnedMessage;
});
}, [pinnedRaw, decryptedMap]);
const handleJumpTo = (messageId: string) => {
window.dispatchEvent(
new CustomEvent('brycord:scroll-to-message', {
detail: { channelId, messageId },
}),
);
onClose();
};
const [unpinTarget, setUnpinTarget] = useState<PinnedMessage | null>(null);
if (!isOpen || !anchorRect) return null;
return createPortal(
<>
<div className={styles.overlay}>
<div ref={ref} className={styles.popover} style={positionStyle}>
<div className={styles.header}>
<span className={styles.headerIcon}>
<PushPin size={20} weight="fill" />
</span>
<h2 className={styles.title}>Pinned Messages</h2>
</div>
<div className={styles.body}>
{pinned.map((msg) => (
<PinnedMessageRow
key={msg.id}
message={msg}
onJumpTo={() => handleJumpTo(msg.id)}
onUnpin={() => setUnpinTarget(msg)}
showHoverActions
canUnpin
/>
))}
{pinnedRaw === undefined && (
<div className={styles.loading}>Loading pinned messages</div>
)}
<ReachedEndNotice />
</div>
</div>
</div>
<PinConfirmationModal
isOpen={!!unpinTarget}
onClose={() => setUnpinTarget(null)}
channelId={channelId}
messageId={unpinTarget?.id ?? null}
message={unpinTarget}
variant="unpin"
/>
</>,
document.body,
);
}

View File

@@ -0,0 +1,192 @@
.body {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-top: 20px;
}
.permissionWarning {
padding: 10px 14px;
background-color: color-mix(in srgb, var(--status-warning, #faa61a) 15%, transparent);
color: var(--status-warning, #faa61a);
border-radius: 8px;
font-size: 0.8125rem;
font-weight: 500;
}
.channelHeader {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.03));
border-radius: 10px;
}
.channelHeaderIcon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.05));
color: var(--text-secondary);
flex-shrink: 0;
}
.channelHeaderText {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.channelHeaderName {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channelHeaderSubtitle {
font-size: 0.75rem;
color: var(--text-tertiary);
margin-top: 2px;
}
/* ── Form fields ─────────────────────────────────────────────────────── */
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin: 0;
padding: 0;
border: 0;
}
.field:disabled {
opacity: 0.6;
}
.label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-tertiary);
margin: 0;
}
.input,
.textarea {
width: 100%;
padding: 10px 12px;
background-color: var(--input-background, var(--background-tertiary));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.08));
border-radius: 8px;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
outline: none;
box-sizing: border-box;
transition: border-color 150ms ease;
}
.textarea {
resize: vertical;
min-height: 88px;
line-height: 1.4;
}
.input::placeholder,
.textarea::placeholder {
color: var(--text-tertiary);
}
.input:focus,
.textarea:focus {
border-color: var(--background-modifier-accent-focus, var(--brand-primary, #5865f2));
}
.fieldFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.fieldHint {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.counter {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--text-tertiary);
}
.counterFull {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--status-danger, #da373c);
}
.statusMessage {
font-size: 0.8125rem;
margin: 0;
line-height: 1.4;
}
.statusSuccess {
color: var(--status-positive, #23a55a);
}
.statusError {
color: var(--status-danger, #da373c);
}
/* ── Danger zone ─────────────────────────────────────────────────────── */
.dangerZone {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
margin-top: 8px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--status-danger, #da373c) 35%, transparent);
background-color: color-mix(in srgb, var(--status-danger, #da373c) 8%, transparent);
}
.dangerHeader {
font-size: 0.875rem;
font-weight: 700;
color: var(--status-danger, #da373c);
}
.dangerDescription {
font-size: 0.8125rem;
line-height: 1.4;
color: var(--text-secondary);
margin: 0;
}
.dangerConfirmRow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.dangerConfirmText {
font-size: 0.8125rem;
color: var(--text-primary);
font-weight: 500;
flex: 1;
min-width: 120px;
}

View File

@@ -0,0 +1,277 @@
/**
* ChannelSettingsModal — opens from the hover gear icon on a channel
* row (via the `brycord:open-channel-settings` event, dispatched by
* GuildNavbar). Lets the user edit the channel name + topic and delete
* the channel. Gated by the `manage_channels` permission.
*/
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Hash, SpeakerHigh, Trash } from '@phosphor-icons/react';
import { Button, Modal } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import styles from './ChannelSettingsModal.module.css';
interface ChannelSettingsModalProps {
isOpen: boolean;
onClose: () => void;
channelId: string | null;
}
const NAME_MAX = 100;
const TOPIC_MAX = 1024;
export function ChannelSettingsModal({
isOpen,
onClose,
channelId,
}: ChannelSettingsModalProps) {
const channel = useQuery(
api.channels.get,
isOpen && channelId ? { id: channelId as Id<'channels'> } : 'skip',
);
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const myPerms = useQuery(
api.roles.getMyPermissions,
myUserId ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
);
const canModify = !!myPerms?.manage_channels;
const renameChannel = useMutation(api.channels.rename);
const updateTopic = useMutation(api.channels.updateTopic);
const removeChannel = useMutation(api.channels.remove);
const [name, setName] = useState('');
const [topic, setTopic] = useState('');
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [status, setStatus] = useState<
{ type: 'success' | 'error'; message: string } | null
>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
useEffect(() => {
if (isOpen && channel) {
setName(channel.name || '');
setTopic(channel.topic || '');
setStatus(null);
setConfirmDelete(false);
}
}, [isOpen, channel?._id, channel?.name, channel?.topic]);
if (!channel) return null;
const trimmedName = name.trim();
const trimmedTopic = topic.trim();
const originalName = (channel.name || '').trim();
const originalTopic = (channel.topic || '').trim();
const isDirty = trimmedName !== originalName || trimmedTopic !== originalTopic;
const nameValid = trimmedName.length > 0 && trimmedName.length <= NAME_MAX;
const topicValid = trimmedTopic.length <= TOPIC_MAX;
const handleSave = async () => {
if (!canModify || !isDirty || !nameValid || !topicValid) return;
setSaving(true);
setStatus(null);
try {
if (trimmedName !== originalName) {
await renameChannel({
id: channel._id as Id<'channels'>,
name: trimmedName,
});
}
if (trimmedTopic !== originalTopic) {
await updateTopic({
id: channel._id as Id<'channels'>,
topic: trimmedTopic,
});
}
setStatus({ type: 'success', message: 'Channel updated.' });
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to update channel.',
});
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!canModify) return;
setDeleting(true);
try {
await removeChannel({ id: channel._id as Id<'channels'> });
onClose();
} catch (err: any) {
setStatus({
type: 'error',
message: err?.message || 'Failed to delete channel.',
});
setDeleting(false);
}
};
const isVoice = channel.type === 'voice';
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
<Modal.Header
title={`Edit ${isVoice ? 'Voice Channel' : 'Channel'}`}
onClose={onClose}
/>
<Modal.Content>
<div className={styles.body}>
{!canModify && (
<div className={styles.permissionWarning}>
You don't have permission to edit this channel.
</div>
)}
<div className={styles.channelHeader}>
<div className={styles.channelHeaderIcon}>
{isVoice ? (
<SpeakerHigh size={18} />
) : (
<Hash size={18} weight="bold" />
)}
</div>
<div className={styles.channelHeaderText}>
<div className={styles.channelHeaderName}>{channel.name}</div>
<div className={styles.channelHeaderSubtitle}>
{isVoice ? 'Voice Channel' : 'Text Channel'}
</div>
</div>
</div>
<fieldset className={styles.field} disabled={!canModify}>
<label className={styles.label} htmlFor="channel-settings-name">
Channel Name
</label>
<input
id="channel-settings-name"
type="text"
className={styles.input}
value={name}
maxLength={NAME_MAX}
onChange={(e) => setName(e.target.value)}
placeholder="channel-name"
/>
<div className={styles.fieldFooter}>
<span className={styles.fieldHint}>
{isVoice
? 'Displayed in the voice channel list.'
: 'Displayed in the channel list and at the top of the chat.'}
</span>
<span
className={
name.length >= NAME_MAX ? styles.counterFull : styles.counter
}
>
{name.length}/{NAME_MAX}
</span>
</div>
</fieldset>
<fieldset className={styles.field} disabled={!canModify}>
<label className={styles.label} htmlFor="channel-settings-topic">
Channel Topic
</label>
<textarea
id="channel-settings-topic"
className={styles.textarea}
value={topic}
maxLength={TOPIC_MAX}
rows={4}
onChange={(e) => setTopic(e.target.value)}
placeholder="Let people know what this channel is about."
/>
<div className={styles.fieldFooter}>
<span className={styles.fieldHint}>
Shown next to the channel name in the header.
</span>
<span
className={
topic.length >= TOPIC_MAX ? styles.counterFull : styles.counter
}
>
{topic.length}/{TOPIC_MAX}
</span>
</div>
</fieldset>
{status && (
<p
className={`${styles.statusMessage} ${
status.type === 'success'
? styles.statusSuccess
: styles.statusError
}`}
>
{status.message}
</p>
)}
{canModify && (
<div className={styles.dangerZone}>
<div className={styles.dangerHeader}>Delete Channel</div>
<p className={styles.dangerDescription}>
Removes this channel for everyone. All messages in it will be
permanently deleted.
</p>
{confirmDelete ? (
<div className={styles.dangerConfirmRow}>
<span className={styles.dangerConfirmText}>Are you sure?</span>
<Button
variant="secondary"
size="sm"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
>
Cancel
</Button>
<Button
variant="danger"
size="sm"
icon={<Trash size={16} />}
loading={deleting}
onClick={handleDelete}
>
Delete Channel
</Button>
</div>
) : (
<Button
variant="danger"
size="sm"
icon={<Trash size={16} />}
onClick={() => setConfirmDelete(true)}
>
Delete Channel
</Button>
)}
</div>
)}
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
disabled={
!canModify || !isDirty || !nameValid || !topicValid || saving
}
loading={saving}
onClick={handleSave}
>
Save Changes
</Button>
</Modal.Footer>
</Modal.Root>
);
}

View File

@@ -0,0 +1,469 @@
/* ── Channel Textarea — matches Fluxer's dense 3-column grid ────────── */
/* Variables */
:root {
--textarea-button-height: var(--user-area-content-height);
--textarea-button-icon-size: 26px;
--textarea-min-height: var(--input-container-min-height);
--textarea-horizontal-padding: var(--chat-horizontal-padding, var(--spacing-4));
--textarea-content-offset: calc((var(--user-area-content-height) - var(--textarea-line-height)) / 2);
--textarea-upload-gap: var(--message-gutter, 16px);
--textarea-side-button-padding: max(0px, calc((var(--message-avatar-size, 40px) - var(--textarea-button-height)) / 2));
}
/* Outer container — sits inside ChannelChatLayout inputArea */
.outer {
outline: none;
padding-left: var(--textarea-horizontal-padding);
padding-right: var(--textarea-horizontal-padding);
box-sizing: border-box;
box-shadow: inset 0 1px 0 var(--user-area-divider-color);
position: relative;
width: 100%;
max-width: 100%;
min-width: 0;
contain: inline-size;
overflow: hidden;
}
/* Reply / Edit bars — stacked above textarea */
.replyBar,
.editBar {
display: grid;
align-items: center;
grid-template-columns: minmax(0, 1fr) auto;
padding-left: 16px;
padding-right: 16px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
min-height: 40px;
border-bottom: 1px solid var(--user-area-divider-color);
}
.replyText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
color: var(--text-primary);
}
.replyText strong {
font-weight: 600;
}
.editLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
color: var(--text-primary);
}
.replyClose,
.editCancel {
cursor: pointer;
flex-shrink: 0;
border: none;
background-color: transparent;
padding: 8px 0 8px 16px;
color: var(--text-primary-muted);
line-height: 0;
transition: color 200ms;
}
.replyClose:hover,
.editCancel:hover {
color: var(--text-primary);
}
.editCancel {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
line-height: 1;
color: var(--text-link);
}
.editCancel:hover {
text-decoration: underline;
color: var(--text-link);
}
/* 3-column dense grid: [plus button] [textarea] [action buttons] */
.mainWrapper {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr) minmax(0, auto);
align-items: flex-start;
position: relative;
min-height: var(--textarea-min-height);
box-sizing: border-box;
padding: var(--user-area-padding-y, 18px) 0;
column-gap: var(--textarea-upload-gap);
min-width: 0;
width: 100%;
max-width: 100%;
}
/* Top divider — drawn via a ::before so it can bleed beyond the
mainWrapper's own box and span the full width of .outer,
including the horizontal padding area. An `inset` box-shadow
on mainWrapper itself would stop at the padding edge and
leave gaps on both sides when a PendingAttachmentRow or a
reply/edit bar sits above it.
`.outer`'s own `overflow: hidden` clips the pseudo to the
outer's edge, so nothing leaks past. */
.mainWrapper::before {
content: "";
position: absolute;
top: 0;
left: calc(var(--textarea-horizontal-padding) * -1);
right: calc(var(--textarea-horizontal-padding) * -1);
height: 1px;
background-color: var(--user-area-divider-color);
pointer-events: none;
}
/* Left column: upload / plus button */
.uploadColumn {
grid-column: 1;
display: flex;
align-items: flex-start;
min-height: var(--user-area-content-height);
padding-top: var(--textarea-side-button-padding);
}
/* Center column: textarea content */
.contentArea {
grid-column: 2;
display: flex;
flex-direction: column;
min-height: var(--user-area-content-height);
min-width: 0;
padding-top: var(--textarea-content-offset);
position: relative;
}
/* Right column: buttons (emoji, gif, send) */
.buttonContainer {
grid-column: 3;
display: flex;
align-items: flex-start;
gap: 10px;
min-height: var(--user-area-content-height);
min-width: 0;
flex-shrink: 1;
}
/* The textarea itself */
.textarea {
display: block;
width: 100%;
min-height: var(--textarea-line-height);
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
background-color: transparent;
color: var(--text-chat, var(--text-primary));
line-height: var(--textarea-line-height);
caret-color: var(--text-chat, var(--text-primary));
padding: 0;
margin: 0;
border: none;
outline: none;
font-family: inherit;
font-size: inherit;
max-height: 50svh;
box-sizing: border-box;
}
.textarea:focus,
.textarea:focus-visible {
outline: none;
box-shadow: none;
}
.placeholder {
position: absolute;
top: var(--textarea-content-offset);
left: 0;
right: 0;
color: var(--text-primary-muted);
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: var(--textarea-line-height);
}
/* Mention pill rendered inside the contenteditable — matches the
received-message mention style so sending looks the same as reading.
contentEditable=false on the element itself so it's atomic. */
.mentionPill {
display: inline;
padding: 0 2px;
border-radius: var(--radius-sm, 4px);
background-color: rgba(88, 101, 242, 0.3);
color: var(--brand-primary, #5865f2);
font-weight: 500;
cursor: default;
user-select: all;
}
/* Textarea buttons (icons for plus, emoji, gif, send) */
.textareaButton {
display: flex;
align-items: center;
justify-content: center;
align-self: flex-start;
min-width: var(--textarea-button-height);
height: var(--textarea-button-height);
padding: 0;
color: var(--text-primary-muted);
transition: color var(--transition-normal);
cursor: pointer;
background: transparent;
border: none;
outline: none;
flex-shrink: 0;
}
.textareaButton:hover:not(:disabled) {
color: var(--text-primary);
}
.textareaButton:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.textareaButtonIcon {
width: var(--textarea-button-icon-size);
height: var(--textarea-button-icon-size);
flex-shrink: 0;
}
/* Send button — brand color when active */
.sendActive {
color: var(--brand-primary);
}
.sendActive:hover {
color: var(--brand-secondary, var(--brand-primary));
}
.emojiPickerPopout {
position: absolute;
bottom: 100%;
right: 8px;
margin-bottom: 8px;
z-index: var(--z-index-popout, 100);
}
/* Mobile send button — hidden on desktop. On mobile it sits outside
the input pill to the right as a round brand-primary button that
commits the message. Desktop still uses Enter-to-send. */
.sendColumn {
display: none;
}
.sendButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
min-width: 36px;
border-radius: 50%;
background-color: var(--brand-primary, #5865f2);
color: #ffffff;
border: none;
padding: 0;
cursor: pointer;
transition: filter 0.15s ease, opacity 0.15s ease;
}
.sendButton:hover:not(:disabled) {
filter: brightness(1.1);
}
.sendButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Body wrapper inside the mobile emoji bottom sheet — stretches the
picker so its mobile layout can fill the entire drawer height. The
picker itself (in mobile mode) uses width/height: 100% and handles
its own internal layout. */
.emojiPickerSheetBody {
display: flex;
flex-direction: column;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
}
.emojiPickerSheetBody > * {
flex: 1 1 auto;
min-height: 0;
}
/* ── Mobile layout ≤768px ────────────────────────────────────────────── */
@media (max-width: 768px) {
.outer {
padding-left: 8px;
padding-right: 8px;
}
/* Drop the 3-column grid to a single row of pill components. */
.mainWrapper {
display: flex;
align-items: center;
column-gap: 8px;
padding: 8px 0;
min-height: 52px;
width: 100%;
}
/* Left: plus becomes a round button outside the pill. */
.uploadColumn {
padding-top: 0;
align-items: center;
min-height: 0;
flex: 0 0 auto;
grid-column: unset;
}
.uploadColumn .textareaButton {
width: 36px;
height: 36px;
min-width: 36px;
border-radius: 50%;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
color: var(--text-primary);
}
.uploadColumn .textareaButtonIcon {
width: 22px;
height: 22px;
}
/* Center + right: visually merge into one rounded pill — expand to
fill all the space between the plus button and the right edge. */
.contentArea {
flex: 1 1 auto;
min-width: 0;
grid-column: unset;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
border-radius: 18px;
padding: 10px 44px 10px 16px;
min-height: 40px;
position: relative;
align-self: stretch;
justify-content: center;
padding-top: 10px;
}
.placeholder {
top: 50%;
left: 14px;
right: 44px;
transform: translateY(-50%);
}
.textarea {
max-height: 140px;
}
/* Right column collapses into the pill: GIF/Image/Sticker hidden,
emoji button absolute-positioned at the pill's right edge.
The offset is measured from .mainWrapper's right edge and needs
to clear both the send button (36px) and the column gap (8px)
before adding the 6px inset inside the pill. */
.buttonContainer {
grid-column: unset;
position: absolute;
right: calc(36px + 8px + 6px);
top: 50%;
transform: translateY(-50%);
min-height: 0;
gap: 0;
background: transparent;
}
/* Hide the GIF / Image / Sticker buttons on mobile — the pill only
needs the emoji toggle. */
.buttonContainer > .textareaButton:nth-child(1),
.buttonContainer > .textareaButton:nth-child(2),
.buttonContainer > .textareaButton:nth-child(3) {
display: none;
}
.buttonContainer > .textareaButton:last-child {
width: 32px;
height: 32px;
min-width: 32px;
}
.buttonContainer > .textareaButton:last-child .textareaButtonIcon {
width: 22px;
height: 22px;
}
/* The parent ChannelView re-nests the textarea inside a flex column;
keep the input sized appropriately on mobile. */
.replyBar,
.editBar {
padding-left: 12px;
padding-right: 12px;
}
/* Show the mobile send button — sits in a fixed-width column next to
the input pill. */
.sendColumn {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
}
/* ── Plus-button popup menu ─────────────────────────────────────────── */
.plusMenu {
min-width: 188px;
padding: 6px 8px;
background-color: var(--background-floating, var(--background-primary));
border-radius: 8px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
display: flex;
flex-direction: column;
}
.plusMenuItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 8px;
border-radius: 4px;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
text-align: left;
transition: background-color 0.1s, color 0.1s;
}
.plusMenuItem:hover {
background-color: var(--brand-primary);
color: #fff;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
.container {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
min-height: 0;
}
.body {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
position: relative;
contain: layout style;
overflow: hidden;
}
.memberListDivider {
position: absolute;
top: 0;
bottom: 0;
right: var(--layout-member-list-width, 240px);
width: 1px;
background: var(--user-area-divider-color);
pointer-events: none;
z-index: 5;
}
/* Hide the members-panel divider alongside the panel on narrow
viewports — the useIsNarrowScreen hook in ChannelView.tsx skips
rendering the panel, so this divider would otherwise be left
hanging over the edge of the chat column. */
@media (max-width: 1024px) {
.memberListDivider {
display: none;
}
}
/* ── Voice channel join prompt ─────────────────────────────── */
.voiceJoinPrompt {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
background-color: var(--background-secondary-lighter, var(--background-primary));
gap: 12px;
}
.voiceJoinIcon {
color: var(--text-tertiary);
margin-bottom: 8px;
}
.voiceJoinTitle {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.voiceJoinDescription {
font-size: 0.9375rem;
color: var(--text-secondary);
margin: 0 0 16px;
text-align: center;
max-width: 360px;
}
.voiceJoinButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 160px;
height: 44px;
padding: 0 24px;
border-radius: var(--radius-md, 8px);
border: none;
background-color: var(--brand-primary);
color: #fff;
font-size: 0.9375rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: filter 150ms;
}
.voiceJoinButton:hover:not(:disabled) {
filter: brightness(1.1);
}
.voiceJoinButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,125 @@
import { useQuery } from 'convex/react';
import { SpeakerHigh } from '@phosphor-icons/react';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../../../../../convex/_generated/api';
import { useVoice } from '../../contexts/VoiceContext';
import { useIsNarrowScreen } from '../../hooks/useIsNarrowScreen';
import { ChannelChatLayout } from './ChannelChatLayout';
import { ChannelDetailsDrawer } from './ChannelDetailsDrawer';
import { ChannelHeader } from './ChannelHeader';
import { MemberListContainer } from '../member/MemberListContainer';
import { SearchPanel } from './SearchPanel';
import { VoiceCallView } from '../voice/VoiceCallView';
import styles from './ChannelView.module.css';
/**
* ChannelView — the main content column for a selected channel. Shows
* either the voice-join prompt, an active voice call, or a text chat
* (header + messages + textarea + members panel).
*/
export function ChannelView() {
const { channelId } = useParams<{ channelId: string }>();
const voice = useVoice();
const [membersVisible, setMembersVisible] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const isNarrow = useIsNarrowScreen();
const showMembers = membersVisible && !isNarrow;
const searchOpen = searchQuery.trim().length > 0;
const channel = useQuery(
api.channels.get,
channelId ? { id: channelId as any } : 'skip',
);
if (!channelId) return null;
const isVoiceChannel = channel?.type === 'voice';
const isDM = channel?.type === 'dm';
const isInThisVoiceCall = voice?.activeChannelId === channelId;
// DMs never show a members panel — there are only two participants
// and they're already represented by the DM header + yourself.
const showMembersPanel = showMembers && !isDM;
const handleJoinVoice = () => {
if (!voice || !channel) return;
const myId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
if (!myId) return;
void voice.connectToVoice?.(channelId, channel.name, myId, false);
};
return (
<div className={styles.container}>
{/* Voice channels we're actively in skip the chat-style header
entirely — the search bar, pins button, members toggle,
and topic string all belong to text channels. A voice call
owns the whole surface via VoiceCallView so there's no
room for that chrome anyway. */}
{(!isVoiceChannel || !isInThisVoiceCall) && (
<ChannelHeader
channel={channel as any}
serverId={undefined}
onOpenChannelDetails={() => setDetailsOpen(true)}
onOpenSearchDrawer={() => {}}
membersVisible={showMembersPanel}
onToggleMembers={() => setMembersVisible((v) => !v)}
hideMembersButton={isDM}
isNarrow={isNarrow}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSearchClear={() => setSearchQuery('')}
/>
)}
<div className={styles.body}>
{isVoiceChannel ? (
isInThisVoiceCall ? (
<VoiceCallView channelId={channelId} />
) : (
<div className={styles.voiceJoinPrompt}>
<SpeakerHigh size={64} weight="fill" className={styles.voiceJoinIcon} />
<h2 className={styles.voiceJoinTitle}>{channel?.name || 'Voice Channel'}</h2>
<p className={styles.voiceJoinDescription}>Click below to join the voice channel.</p>
<button className={styles.voiceJoinButton} onClick={handleJoinVoice}>
Join Voice
</button>
</div>
)
) : (
<>
<ChannelChatLayout channelId={channelId} />
{searchOpen ? (
<>
<div className={styles.memberListDivider} />
<SearchPanel
channelId={channelId}
query={searchQuery}
onClose={() => setSearchQuery('')}
/>
</>
) : (
showMembersPanel && (
<>
<div className={styles.memberListDivider} />
<MemberListContainer
members={[]}
channelId={channelId}
serverId={undefined}
/>
</>
)
)}
</>
)}
</div>
<ChannelDetailsDrawer
isOpen={detailsOpen}
onClose={() => setDetailsOpen(false)}
channelId={channelId}
channelName={channel?.name}
channelType={channel?.type}
/>
</div>
);
}

View File

@@ -0,0 +1,173 @@
.container {
padding: 16px 16px 0;
margin-bottom: 8px;
}
.iconWrapper {
width: 68px;
height: 68px;
border-radius: 50%;
background-color: var(--background-modifier-accent);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
margin-bottom: 8px;
}
.title {
font-size: 2rem;
font-weight: 800;
color: var(--text-primary);
margin: 0 0 8px;
}
.description {
font-size: 0.9375rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.4;
}
/* ── Personal Notes variant ─────────────────────────────────────────
When the channel is the Personal Notes room we render a
centred empty state instead of the left-aligned channel welcome:
a big brand-primary square with the NotePencil icon, the
"Personal Notes" title centred below it, a thin divider, and
the Fluxer-style tagline. Matches the reference screenshot. */
.personalNotesContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 32px;
text-align: center;
}
.personalNotesIconWrapper {
width: 72px;
height: 72px;
border-radius: 50%;
background-color: var(--brand-primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
}
.personalNotesTitle {
font-size: 1.75rem;
font-weight: 800;
color: var(--text-primary);
margin: 0 0 12px;
}
.personalNotesDivider {
width: 40px;
height: 2px;
background-color: var(--background-modifier-accent, rgba(255, 255, 255, 0.1));
border-radius: 1px;
margin: 0 0 12px;
}
.personalNotesDescription {
font-size: 0.9375rem;
color: var(--text-primary-muted, var(--text-secondary));
margin: 0;
max-width: 280px;
line-height: 1.4;
}
/* ── 1:1 DM variant ──────────────────────────────────────────────────
Rendered when the channel is a 1:1 DM. Matches the Fluxer mobile
reference: centred layout, large user avatar, username + handle
line, single-line welcome text, and a "Remove Friend" action
button. */
.dmContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 32px 24px;
text-align: center;
}
.dmAvatar {
margin-bottom: 18px;
}
.dmNameRow {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 10px;
flex-wrap: wrap;
justify-content: center;
}
.dmName {
font-size: 1.75rem;
font-weight: 800;
color: var(--text-primary);
margin: 0;
}
.dmHandle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary-muted, var(--text-secondary));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.dmDescription {
font-size: 0.9375rem;
color: var(--text-primary-muted, var(--text-secondary));
margin: 0 0 18px;
line-height: 1.5;
max-width: 320px;
}
.dmDescription strong {
color: var(--text-primary);
font-weight: 700;
}
.removeFriendButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 18px;
background-color: var(--background-secondary-alt);
border: none;
border-radius: 0.5rem;
color: var(--text-primary);
font: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background-color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.removeFriendButton:hover,
.removeFriendButton:active {
background-color: var(--background-modifier-hover);
}
.removeFriendButton:disabled {
opacity: 0.55;
cursor: default;
}
.dmStatus {
margin-top: 10px;
font-size: 12px;
color: var(--text-primary-muted, var(--text-secondary));
}
.dmStatusError {
color: hsl(0, calc(80% * var(--saturation-factor)), 70%);
}

View File

@@ -0,0 +1,124 @@
/**
* ChannelWelcomeSection — the "start of channel" header rendered above
* the first message in any channel view. Two variants:
*
* - DM (1:1) → centred avatar + display name + "@username" handle +
* "This is the beginning of your direct message history with Name."
* - Text channel → "Welcome to #channel-name!" with the Hash icon
* and a short description.
*
* Voice channels never reach the message list so they never need this.
*/
import { useMemo } from 'react';
import { useQuery } from 'convex/react';
import { Hash } from '@phosphor-icons/react';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { useOnlineUsers } from '../../contexts/PresenceContext';
import styles from './ChannelWelcomeSection.module.css';
interface ChannelWelcomeSectionProps {
channelId: string;
channelName?: string;
channelType?: string;
}
function mapPresence(
status: string | undefined,
): 'online' | 'idle' | 'dnd' | 'offline' {
switch (status) {
case 'online':
return 'online';
case 'idle':
return 'idle';
case 'dnd':
return 'dnd';
default:
return 'offline';
}
}
export function ChannelWelcomeSection({
channelId,
channelName,
channelType,
}: ChannelWelcomeSectionProps) {
if (channelType === 'dm') {
return <DmWelcome channelId={channelId} channelName={channelName} />;
}
const name = channelName || 'channel';
return (
<div className={styles.container}>
<div className={styles.iconWrapper}>
<Hash size={42} weight="bold" />
</div>
<h1 className={styles.title}>Welcome to #{name}!</h1>
<p className={styles.description}>
This is the start of the #{name} channel. All messages are
end-to-end encrypted.
</p>
</div>
);
}
interface DmWelcomeProps {
channelId: string;
channelName?: string;
}
function DmWelcome({ channelId, channelName }: DmWelcomeProps) {
const { resolveStatus } = useOnlineUsers();
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const dmRows = useQuery(
api.dms.listDMs,
myUserId ? { userId: myUserId as any } : 'skip',
);
const allUsers = useQuery(api.auth.getPublicKeys) ?? [];
const other = useMemo(() => {
if (!dmRows) return null;
const row = (dmRows as any[]).find((r) => r.channel_id === channelId);
if (!row) return null;
const profile = allUsers.find((u) => u.id === row.other_user_id);
const username = profile?.username || row.other_username || '';
return {
userId: row.other_user_id as string,
displayName: username || channelName || 'this user',
username,
avatarUrl: profile?.avatarUrl ?? row.other_user_avatar_url ?? null,
status:
(profile?.status as string | undefined) ||
(row.other_user_status as string | undefined) ||
'offline',
};
}, [dmRows, allUsers, channelId, channelName]);
const presence = mapPresence(
other ? resolveStatus(other.status, other.userId) : 'offline',
);
const displayName = other?.displayName ?? channelName ?? 'this user';
return (
<div className={styles.dmContainer}>
<div className={styles.dmAvatar}>
<Avatar
src={other?.avatarUrl ?? null}
fallback={displayName}
size={80}
status={presence}
/>
</div>
<div className={styles.dmNameRow}>
<h1 className={styles.dmName}>{displayName}</h1>
</div>
<p className={styles.dmDescription}>
This is the beginning of your direct message history with{' '}
<strong>{displayName}</strong>.
</p>
</div>
);
}

View File

@@ -0,0 +1,154 @@
.body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0.25rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-primary-muted, var(--text-muted));
}
.input,
.textarea {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 0.375rem;
background-color: var(--background-primary);
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
outline: none;
transition: border-color 0.12s;
}
.input:focus,
.textarea:focus {
border-color: var(--brand-primary, #4641d9);
}
.answerList {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.answerRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.answerRow .input {
flex: 1;
}
.removeButton {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--background-modifier-accent);
border-radius: 0.375rem;
background-color: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.removeButton:hover:not(:disabled) {
color: var(--status-danger);
border-color: var(--status-danger);
}
.removeButton:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.addButton {
align-self: flex-start;
margin-top: 0.125rem;
padding: 0.375rem 0.75rem;
border: 1px dashed var(--background-modifier-accent);
border-radius: 0.375rem;
background-color: transparent;
color: var(--text-tertiary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.addButton:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--brand-primary, #4641d9);
}
.addButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toggles {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.toggleRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.toggleLabel {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.toggleName {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.toggleHint {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.toggleInput {
width: 1.125rem;
height: 1.125rem;
accent-color: var(--brand-primary, #4641d9);
cursor: pointer;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
.error {
color: var(--status-danger);
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,237 @@
/**
* CreatePollModal — compose a new MSC3381 poll.
*
* Lives next to ChannelTextarea; the composer's poll button opens
* it for the active channel. Keeps its own local form state and
* hands the final payload off to `MessageActionCreators.sendPollStart`
* on submit. Closes itself on success, surfaces errors inline.
*/
import { useEffect, useMemo, useState } from 'react';
import { useMutation } from 'convex/react';
import { Plus, X } from '@phosphor-icons/react';
import { Button, Modal } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import styles from './CreatePollModal.module.css';
interface PollAnswer {
id: string;
text: string;
}
interface CreatePollModalProps {
isOpen: boolean;
onClose: () => void;
channelId: string;
}
const MIN_ANSWERS = 2;
const MAX_ANSWERS = 20;
const MAX_QUESTION_LEN = 340;
const MAX_ANSWER_LEN = 340;
/** Short random string for answer ids. Matches Element's format. */
function makeAnswerId(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let out = '';
for (let i = 0; i < 16; i++) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out;
}
export function CreatePollModal({ isOpen, onClose, channelId }: CreatePollModalProps) {
const createPoll = useMutation(api.polls.create);
const [question, setQuestion] = useState('');
// Each draft answer carries a stable id even before submit so
// React doesn't reshuffle focus between rows when the list
// mutates. The ids flow straight into the outgoing poll.start.
const [answers, setAnswers] = useState<PollAnswer[]>(() => [
{ id: makeAnswerId(), text: '' },
{ id: makeAnswerId(), text: '' },
]);
const [allowMultiple, setAllowMultiple] = useState(false);
const [disclosed, setDisclosed] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset the form when the modal opens so previous attempts don't
// linger. Running on `isOpen === true` only avoids wiping the
// form while the user's submit is in flight.
useEffect(() => {
if (!isOpen) return;
setQuestion('');
setAnswers([
{ id: makeAnswerId(), text: '' },
{ id: makeAnswerId(), text: '' },
]);
setAllowMultiple(false);
setDisclosed(true);
setSubmitting(false);
setError(null);
}, [isOpen]);
const trimmedQuestion = question.trim();
const validAnswers = useMemo(
() => answers.map((a) => ({ ...a, text: a.text.trim() })).filter((a) => a.text.length > 0),
[answers],
);
const canSubmit =
trimmedQuestion.length > 0 &&
validAnswers.length >= MIN_ANSWERS &&
!submitting;
const setAnswerText = (id: string, text: string) => {
setAnswers((prev) =>
prev.map((a) => (a.id === id ? { ...a, text: text.slice(0, MAX_ANSWER_LEN) } : a)),
);
};
const addAnswer = () => {
setAnswers((prev) =>
prev.length >= MAX_ANSWERS ? prev : [...prev, { id: makeAnswerId(), text: '' }],
);
};
const removeAnswer = (id: string) => {
setAnswers((prev) =>
prev.length <= MIN_ANSWERS ? prev : prev.filter((a) => a.id !== id),
);
};
const handleSubmit = async () => {
if (!canSubmit) return;
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
if (!myUserId) {
setError('You must be signed in to create a poll.');
return;
}
setSubmitting(true);
setError(null);
try {
await createPoll({
channelId: channelId as Id<'channels'>,
createdBy: myUserId as Id<'userProfiles'>,
question: trimmedQuestion,
options: validAnswers.map((a) => ({ id: a.id, text: a.text })),
allowMultiple,
disclosed,
});
onClose();
} catch (err: any) {
setError(err?.message || 'Failed to create poll.');
} finally {
setSubmitting(false);
}
};
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="small">
<Modal.Header title="Create Poll" onClose={onClose} />
<Modal.Content>
<div className={styles.body}>
<div className={styles.field}>
<label className={styles.label} htmlFor="poll-question">
Question
</label>
<input
id="poll-question"
className={styles.input}
value={question}
placeholder="What should we have for lunch?"
maxLength={MAX_QUESTION_LEN}
onChange={(e) => setQuestion(e.target.value)}
disabled={submitting}
/>
</div>
<div className={styles.field}>
<div className={styles.label}>Answers</div>
<div className={styles.answerList}>
{answers.map((answer, i) => (
<div key={answer.id} className={styles.answerRow}>
<input
className={styles.input}
value={answer.text}
placeholder={`Answer ${i + 1}`}
onChange={(e) => setAnswerText(answer.id, e.target.value)}
disabled={submitting}
/>
<button
type="button"
className={styles.removeButton}
onClick={() => removeAnswer(answer.id)}
disabled={submitting || answers.length <= MIN_ANSWERS}
aria-label={`Remove answer ${i + 1}`}
>
<X size={14} weight="bold" />
</button>
</div>
))}
</div>
<button
type="button"
className={styles.addButton}
onClick={addAnswer}
disabled={submitting || answers.length >= MAX_ANSWERS}
>
<Plus size={14} weight="bold" style={{ marginRight: 4, verticalAlign: 'middle' }} />
Add answer
</button>
</div>
<div className={styles.toggles}>
<label className={styles.toggleRow}>
<span className={styles.toggleLabel}>
<span className={styles.toggleName}>Allow multiple answers</span>
<span className={styles.toggleHint}>
Voters can select more than one option.
</span>
</span>
<input
type="checkbox"
className={styles.toggleInput}
checked={allowMultiple}
onChange={(e) => setAllowMultiple(e.target.checked)}
disabled={submitting}
/>
</label>
<label className={styles.toggleRow}>
<span className={styles.toggleLabel}>
<span className={styles.toggleName}>Show results in real time</span>
<span className={styles.toggleHint}>
If off, vote counts stay hidden until you end the poll.
</span>
</span>
<input
type="checkbox"
className={styles.toggleInput}
checked={disclosed}
onChange={(e) => setDisclosed(e.target.checked)}
disabled={submitting}
/>
</label>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<Button variant="secondary" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
loading={submitting}
>
Create
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -0,0 +1,25 @@
/**
* CustomEmojiImage — renders a custom emoji image given an `mxc://`
* URL (or a raw http URL), resolving through the authenticated media
* endpoint via `useMxcUrl`. While the blob is being fetched the img
* has no src, so the browser draws nothing; once resolved the image
* pops in. Cached across the tab lifetime so repeat renders are
* instant.
*
* Forwards all other img props (className, alt, title, draggable,
* width/height, etc.), so callers can style the element normally.
*/
import { useMxcUrl } from '@app/hooks/useMxcUrl';
interface CustomEmojiImageProps
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src'> {
/** Either an mxc:// URL (will be resolved via auth) or a direct http URL. */
mxc: string;
}
export function CustomEmojiImage({ mxc, alt = '', ...rest }: CustomEmojiImageProps) {
const src = useMxcUrl(mxc);
// Don't set src until it resolves — otherwise the browser eagerly
// fetches an empty string and logs an error.
return <img src={src || undefined} alt={alt} {...rest} />;
}

View File

@@ -0,0 +1,180 @@
/* ── Edit Attachment modal body ──────────────────────────────
Chrome (backdrop, centring, header X) comes from Modal.Root.
This module styles the form controls inside Modal.Content. */
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.labelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-primary-muted, #a0a3a8);
}
.counter {
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-tertiary, #a0a3a8);
font-variant-numeric: tabular-nums;
}
.input {
height: 40px;
padding: 0 12px;
background-color: var(--form-surface-background, #1e2024);
border: 1px solid var(--background-modifier-accent);
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.12s;
}
.input:focus,
.input:focus-visible {
outline: none;
border-color: var(--brand-primary);
}
.textarea {
min-height: 80px;
padding: 10px 12px;
background-color: var(--form-surface-background, #1e2024);
border: 1px solid var(--background-modifier-accent);
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
resize: vertical;
outline: none;
transition: border-color 0.12s;
}
.textarea:focus,
.textarea:focus-visible {
outline: none;
border-color: var(--brand-primary);
}
.textarea::placeholder {
color: var(--text-tertiary);
}
/* ── Spoiler toggle row ───────────────────────────────────────
Fluxer-style iOS switch. Left label + right track-and-thumb. */
.spoilerRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.spoilerLabel {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
}
.toggle {
position: relative;
width: 36px;
height: 20px;
padding: 0;
background-color: var(--background-modifier-accent);
border: none;
border-radius: 999px;
cursor: pointer;
transition: background-color 0.18s ease;
flex-shrink: 0;
}
.toggleOn {
background-color: var(--brand-primary);
}
.toggleThumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: transform 0.18s ease;
}
.toggleOn .toggleThumb {
transform: translateX(16px);
}
/* ── Footer actions ───────────────────────────────────────── */
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.secondaryButton {
height: 40px;
padding: 0 18px;
background: var(--background-secondary-alt);
border: none;
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.12s;
}
.secondaryButton:hover {
background-color: var(--background-modifier-hover);
}
.primaryButton {
height: 40px;
padding: 0 20px;
background-color: var(--brand-primary);
border: none;
border-radius: 6px;
color: var(--text-on-brand-primary, #fff);
font: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.12s;
}
.primaryButton:hover {
filter: brightness(1.08);
}
.primaryButton:active {
filter: brightness(0.92);
}

View File

@@ -0,0 +1,144 @@
/**
* EditAttachmentModal — opened from a PendingAttachmentCard's
* "Edit" hover button. Lets the user rename the outgoing file,
* add an alt-text description, and toggle the Mark-as-Spoiler
* flag before sending.
*
* The Alt Text Description field is hidden for audio attachments
* per the user's spec — audio doesn't carry a visual payload so
* the accessibility angle doesn't apply.
*
* Saving writes the edits back to PendingAttachmentStore via
* `updateAttachment`, which re-renders the affected card
* reactively without any prop wiring.
*/
import { useEffect, useState } from 'react';
import { Modal } from '@brycord/ui';
import PendingAttachmentStore, {
type PendingAttachment,
} from '@app/stores/PendingAttachmentStore';
import styles from './EditAttachmentModal.module.css';
const ALT_TEXT_MAX = 4096;
interface EditAttachmentModalProps {
isOpen: boolean;
onClose: () => void;
channelId: string;
attachment: PendingAttachment | null;
}
export function EditAttachmentModal({
isOpen,
onClose,
channelId,
attachment,
}: EditAttachmentModalProps) {
const [filename, setFilename] = useState('');
const [altText, setAltText] = useState('');
const [isSpoiler, setIsSpoiler] = useState(false);
// Hydrate the form every time the modal opens on a (possibly
// different) attachment. Without this re-sync, the user's last
// edit on a previous card would leak into the next one.
useEffect(() => {
if (!isOpen || !attachment) return;
setFilename(attachment.filename);
setAltText(attachment.altText);
setIsSpoiler(attachment.isSpoiler);
}, [isOpen, attachment]);
if (!attachment) return null;
const hideAltText = attachment.kind === 'audio';
const handleSave = () => {
PendingAttachmentStore.updateAttachment(channelId, attachment.id, {
filename: filename.trim() || attachment.file.name,
altText: altText.trim(),
isSpoiler,
});
onClose();
};
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="small">
<Modal.Header title="Edit Attachment" onClose={onClose} />
<Modal.Content>
<div className={styles.body}>
<div className={styles.field}>
<label className={styles.label} htmlFor="edit-att-filename">
Filename
</label>
<input
id="edit-att-filename"
type="text"
className={styles.input}
value={filename}
onChange={(e) => setFilename(e.target.value)}
autoFocus
/>
</div>
{!hideAltText && (
<div className={styles.field}>
<div className={styles.labelRow}>
<label className={styles.label} htmlFor="edit-att-alt">
Alt Text Description
</label>
<span className={styles.counter}>
{altText.length}/{ALT_TEXT_MAX}
</span>
</div>
<textarea
id="edit-att-alt"
className={styles.textarea}
placeholder="Describe this media for screen readers"
value={altText}
onChange={(e) =>
setAltText(e.target.value.slice(0, ALT_TEXT_MAX))
}
rows={3}
/>
</div>
)}
<div className={styles.spoilerRow}>
<label className={styles.spoilerLabel} htmlFor="edit-att-spoiler">
Mark as Spoiler
</label>
<button
id="edit-att-spoiler"
type="button"
className={`${styles.toggle} ${
isSpoiler ? styles.toggleOn : ''
}`}
onClick={() => setIsSpoiler((v) => !v)}
aria-pressed={isSpoiler}
aria-label="Mark as spoiler"
>
<span className={styles.toggleThumb} />
</button>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.secondaryButton}
onClick={onClose}
>
Cancel
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleSave}
>
Save
</button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -0,0 +1,581 @@
/* ── Desktop picker shell ─────────────────────────────────────────
Fluxer-style expression picker. Top stripe of tabs (GIFs / Media
/ Stickers / Emojis), search bar below, main row with a vertical
category sidebar on the left and a collapsible emoji grid on the
right, hover inspector pinned at the bottom. */
.picker {
width: 480px;
height: 460px;
display: flex;
flex-direction: column;
background-color: var(--background-tertiary);
border: 1px solid var(--background-modifier-accent);
border-radius: 10px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* Mobile variant: fill the parent (a BottomSheet body), no fixed
dimensions, no rounding/shadow — the sheet owns those. */
.pickerMobile {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
border: none;
border-radius: 0;
box-shadow: none;
background-color: transparent;
}
/* ── Top tab bar (GIFs / Media / Stickers / Emojis) ───────────── */
.tabBar {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 12px 0;
flex-shrink: 0;
}
.tabButton {
padding: 6px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-primary-muted, #a0a3a8);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.tabButton:hover {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.tabButtonActive {
color: var(--text-primary);
background-color: var(--background-modifier-selected, var(--background-modifier-hover));
}
/* ── Search row ───────────────────────────────────────────────── */
.searchRow {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--background-modifier-hover);
}
/* On the Media tab the divider moves down one level to the filter
chip row (which sits between the search bar and the grid), so
the search row itself drops its bottom border to avoid a double
line. */
.searchRowFlush {
border-bottom: none;
}
.searchBar {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--background-modifier-accent);
background-color: color-mix(in srgb, var(--form-surface-background) 85%, black);
}
.searchIcon {
flex-shrink: 0;
color: var(--text-primary-muted, #a0a3a8);
}
.searchInput {
flex: 1;
height: 100%;
background: transparent;
border: none;
padding: 0;
font-size: 0.875rem;
color: var(--text-primary);
outline: none;
font-family: inherit;
}
/* Kill the browser's default focus ring — the search bar's own
border already signals the interactive state, a blue outline
on top of it reads as a bug. */
.searchInput:focus,
.searchInput:focus-visible {
outline: none;
box-shadow: none;
border: none;
}
.searchInput::placeholder {
color: var(--text-tertiary);
}
.searchInput:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.searchClear {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
padding: 2px;
}
.searchClear:hover {
color: var(--text-primary);
}
/* ── Main area (sidebar + grid) ───────────────────────────────── */
.main {
flex: 1;
min-height: 0;
display: flex;
align-items: stretch;
}
/* Vertical category sidebar — narrow column of icon buttons on
the left edge. The active category pill is a rounded square
matching Fluxer. */
.sideBar {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px;
width: 48px;
flex-shrink: 0;
overflow-y: auto;
scrollbar-width: none;
background-color: var(--background-primary);
box-shadow: inset -1px 0 0 var(--background-modifier-accent);
}
.sideBar::-webkit-scrollbar {
display: none;
}
.sideTab {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary-muted, #a0a3a8);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.12s, color 0.12s;
}
.sideTab:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.sideTabActive {
background-color: var(--background-modifier-selected, var(--background-modifier-hover));
color: var(--text-primary);
}
/* ── Scrollable emoji grid ────────────────────────────────────── */
.grid {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 12px 12px;
-webkit-overflow-scrolling: touch;
}
.pickerMobile .grid {
padding: 4px 12px;
}
/* ── Collapsible section (desktop only) ───────────────────────── */
.section {
margin-top: 4px;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 4px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
font-weight: 700;
text-align: left;
}
.sectionHeaderLabel {
flex-shrink: 0;
}
.sectionHeaderCaret {
color: var(--text-primary-muted, #a0a3a8);
transition: transform 0.15s ease;
}
/* Caret points DOWN when expanded (default), rotated to RIGHT when
the section is collapsed. Matches the Fluxer reference where
expanded sections show a `v` and collapsed sections show `>`. */
.sectionHeaderCaretCollapsed {
transform: rotate(-90deg);
}
/* Mobile sticky headers (legacy flat layout) reuse a subset of
the desktop header styles; they don't need the caret. */
.categoryHeader {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-tertiary);
letter-spacing: 0.02em;
padding: 8px 4px 4px;
position: sticky;
top: 0;
background-color: var(--background-secondary-lighter, var(--background-primary));
z-index: 1;
}
.emojiGrid {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 0;
}
.pickerMobile .emojiGrid {
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
}
.emojiButton {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
font-size: 1.75rem;
line-height: 1;
border: none;
background: none;
border-radius: 6px;
cursor: pointer;
padding: 0;
transition: background-color 0.1s;
}
.emojiButton:hover {
background-color: var(--background-modifier-hover);
}
.noResults {
text-align: center;
color: var(--text-tertiary);
font-size: 0.875rem;
padding: 2rem;
}
/* Placeholder rendered when the user clicks an unimplemented
top-level tab (GIFs / Media / Stickers). */
.comingSoon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 2rem;
text-align: center;
color: var(--text-primary-muted, #a0a3a8);
font-size: 0.9375rem;
}
/* ── Mobile-only bottom category bar ──────────────────────────── */
.categoryBar {
display: none;
}
.pickerMobile .categoryBar {
display: flex;
border-top: 1px solid var(--background-modifier-accent);
padding: 8px 12px;
gap: 4px;
justify-content: space-between;
overflow-x: auto;
flex-shrink: 0;
}
.pickerMobile .categoryTab {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 6px;
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
flex-shrink: 0;
transition: color 0.15s, background-color 0.15s;
}
.pickerMobile .categoryTab:hover {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}
.pickerMobile .categoryTabActive {
color: var(--text-primary);
background-color: var(--background-modifier-selected);
}
/* ── Media tab ────────────────────────────────────────────────
Body content rendered when the user selects the top-level
"Media" tab. Saved-item grid + hover-delete X. The filter
chip row is rendered OUTSIDE this container (at the EmojiPicker
top level, directly under the search row), so `.mediaTab` only
owns the grid and its empty state. */
.mediaTab {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 0;
}
/* Filter chip row — sits between the search row and the main
content when `activeTab === 'media'`. Rendered at the picker
top level so it stays pinned while the grid scrolls. */
.filterChips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 8px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--background-modifier-hover);
}
/* Mobile: the sheet already owns its own horizontal padding and the
bottom sheet surface doesn't need a secondary divider under the
chips, so drop both so the chip row sits flush inside the sheet. */
@media (max-width: 768px) {
.filterChips {
padding: 0;
border-bottom: none;
}
}
.filterChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: transparent;
border: none;
border-radius: var(--radius-md, 0.375rem);
color: var(--text-primary-muted, #a0a3a8);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.25rem;
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.filterChip:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.filterChipActive {
background-color: var(--background-modifier-selected);
color: var(--text-primary);
}
.filterChipActive:hover {
background-color: var(--background-modifier-selected);
color: var(--text-primary);
}
/* Media tab background override — the emoji tab uses
`--background-tertiary` (inherited from `.picker`), but the
Media tab reads as a lighter surface more like the rest of
the app. Applied to the main row wrapper only when the user
is on the Media tab. */
.mainMedia {
background-color: var(--background-primary);
}
.mainMedia .grid {
background-color: var(--background-primary);
}
.mediaGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding: 0 4px 4px;
}
.mediaCard {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
cursor: pointer;
transition: border-color 0.12s, transform 0.12s;
}
.mediaCard:hover {
border-color: var(--brand-primary, #4641d9);
}
.mediaCardImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Small play badge overlay for video tiles in the Media tab.
Sits in the bottom-right corner of the tile so the thumbnail
reads as a video at a glance without mistaking it for a
still image. Only rendered when the card's kind is 'video'. */
.mediaCardPlayBadge {
position: absolute;
bottom: 6px;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
pointer-events: none;
/* Nudge the play triangle 1px right — same geometric-
centroid fix the AttachmentVideo play badge uses. */
padding-left: 2px;
box-sizing: border-box;
}
.mediaCardPlaceholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-muted, #a0a3a8);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mediaCardRemove {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background-color: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 50%;
color: #fff;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0.12s ease, background-color 0.12s;
}
.mediaCard:hover .mediaCardRemove,
.mediaCard:focus-within .mediaCardRemove {
opacity: 1;
visibility: visible;
}
.mediaCardRemove:hover {
background-color: var(--status-danger, #ed4245);
}
/* ── Inspector bar ────────────────────────────────────────────── */
.inspector {
display: flex;
align-items: center;
gap: 10px;
height: 44px;
padding: 0 14px;
border-top: 1px solid var(--background-modifier-accent);
background-color: var(--background-primary);
flex-shrink: 0;
}
.inspectorEmoji {
display: inline-flex;
align-items: center;
font-size: 1.5rem;
}
.inspectorName {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inspectorHint {
font-size: 0.8125rem;
color: var(--text-tertiary);
}
/* ── Light theme overrides ────────────────────────────────────
Promote the picker chrome rows (tab bar, search row, media-tab
filter chips) from the inherited --background-tertiary up to
--background-primary in light theme so the picker reads as a
brighter surface than the chat body instead of a dimmer gray
one. Dark theme keeps the original inherited look. */
[data-theme='light'] .tabBar,
[data-theme='light'] .searchRow,
[data-theme='light'] .filterChips {
background-color: var(--background-primary);
}
/* The inner rounded search input itself sits on top of the
now-white search row, so the dark-theme color-mix surface
reads as a muddy tan against it. Switch it to the hover
modifier shade so it stays distinct from the row without
looking off-palette. */
[data-theme='light'] .searchBar {
background-color: var(--background-modifier-hover);
}

View File

@@ -0,0 +1,736 @@
import {
Bicycle,
BowlFood,
CaretDown,
Clock,
Flag,
GameController,
Gif,
Heart,
ImageSquare,
Leaf,
Magnet,
MagnifyingGlass,
Smiley,
SmileyWink,
Sticker,
X,
} from '@phosphor-icons/react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useQuery } from 'convex/react';
import emojiData from '@app/data/emojis.json';
import { api } from '../../../../../convex/_generated/api';
import { GifPicker } from './GifPicker';
import { TwemojiImg } from './TwemojiImg';
import styles from './EmojiPicker.module.css';
export type ExpressionTab = 'gifs' | 'media' | 'stickers' | 'emojis';
export type EmojiPickerValue =
| {
kind: 'unicode';
surrogates: string;
name: string;
}
| {
kind: 'custom';
shortcode: string;
url: string;
}
| {
// Picked from the GIFs tab — composer should send this as
// a plain text URL so the existing link-embed renderer
// shows it inline.
kind: 'gif';
url: string;
};
interface CustomEmojiListEntry {
_id: string;
name: string;
src: string;
}
interface EmojiPickerProps {
onSelect: (value: EmojiPickerValue) => void;
onClose?: () => void;
mobile?: boolean;
initialTab?: ExpressionTab;
onTabChange?: (tab: ExpressionTab) => void;
}
interface EmojiEntry {
names: string[];
surrogates: string;
}
type EmojiData = Record<string, EmojiEntry[]>;
const EXPRESSION_TABS: Array<{ key: ExpressionTab; label: string }> = [
{ key: 'gifs', label: 'GIFs' },
{ key: 'media', label: 'Media' },
{ key: 'stickers', label: 'Stickers' },
{ key: 'emojis', label: 'Emojis' },
];
interface CategoryDef {
key: string;
label: string;
icon: ReactNode;
}
const CATEGORIES: CategoryDef[] = [
{ key: 'people', label: 'Smileys & People', icon: <Smiley size={20} /> },
{ key: 'nature', label: 'Animals & Nature', icon: <Leaf size={20} /> },
{ key: 'food', label: 'Food & Drink', icon: <BowlFood size={20} /> },
{ key: 'activity', label: 'Activities', icon: <GameController size={20} /> },
{ key: 'travel', label: 'Travel & Places', icon: <Bicycle size={20} /> },
{ key: 'objects', label: 'Objects', icon: <Magnet size={20} /> },
{ key: 'symbols', label: 'Symbols', icon: <Heart size={20} /> },
{ key: 'flags', label: 'Flags', icon: <Flag size={20} /> },
];
const RECENTS_STORAGE_KEY = 'discord_clone_recent_emojis_v1';
const MAX_RECENTS = 24;
function loadRecents(): EmojiPickerValue[] {
if (typeof localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(RECENTS_STORAGE_KEY);
return raw ? (JSON.parse(raw) as EmojiPickerValue[]) : [];
} catch {
return [];
}
}
function saveRecents(list: EmojiPickerValue[]) {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(RECENTS_STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENTS)));
} catch {}
}
export function EmojiPicker({
onSelect,
onClose,
mobile = false,
initialTab = 'emojis',
onTabChange,
}: EmojiPickerProps) {
const [activeTab, setActiveTabState] = useState<ExpressionTab>(initialTab);
const setActiveTab = (tab: ExpressionTab) => {
setActiveTabState(tab);
onTabChange?.(tab);
};
// Allow the parent to imperatively switch tabs by changing
// `initialTab` while the picker is mounted — used by the composer
// when the user clicks a different expression-button without
// closing the picker first.
useEffect(() => {
setActiveTabState(initialTab);
}, [initialTab]);
const [search, setSearch] = useState('');
const [activeCategory, setActiveCategory] = useState<string>('people');
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [hovered, setHovered] = useState<EmojiPickerValue | null>(null);
const [recents, setRecents] = useState<EmojiPickerValue[]>(() => loadRecents());
// Saved-media library — only fetched when the Media tab is open
// to keep the picker cheap during normal emoji use.
const myUserId =
typeof localStorage !== 'undefined'
? localStorage.getItem('userId')
: null;
const savedMedia =
useQuery(
api.savedMedia.list,
myUserId && activeTab === 'media'
? { userId: myUserId as any }
: 'skip',
) ?? [];
const handleRepostSaved = (item: any) => {
// ChannelTextarea listens for this event and re-uses the
// stored attachment metadata so the same file can be
// re-posted into the active channel without re-uploading.
window.dispatchEvent(
new CustomEvent('brycord:repost-saved-media', {
detail: {
url: item.url,
filename: item.filename,
mimeType: item.mimeType,
width: item.width,
height: item.height,
size: item.size,
encryptionKey: item.encryptionKey,
encryptionIv: item.encryptionIv,
},
}),
);
onClose?.();
};
const gridRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
const data = emojiData as unknown as EmojiData;
useEffect(() => {
if (!mobile && activeTab === 'emojis') {
searchRef.current?.focus();
}
}, [activeTab, mobile]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return null;
const results: EmojiEntry[] = [];
for (const cat of CATEGORIES) {
for (const entry of data[cat.key] ?? []) {
if (entry.names.some((n) => n.toLowerCase().includes(q))) {
results.push(entry);
}
}
}
return results;
}, [search, data]);
const customEmojis = (useQuery(api.customEmojis.list, {}) ?? []) as CustomEmojiListEntry[];
const recentKey = (v: EmojiPickerValue): string =>
v.kind === 'unicode' ? `u:${v.surrogates}` : `c:${v.shortcode}`;
const addRecent = (value: EmojiPickerValue) => {
setRecents((prev) => {
const key = recentKey(value);
const next = [value, ...prev.filter((r) => recentKey(r) !== key)].slice(0, MAX_RECENTS);
saveRecents(next);
return next;
});
};
const handleSelect = (entry: EmojiEntry) => {
const value: EmojiPickerValue = {
kind: 'unicode',
surrogates: entry.surrogates,
name: entry.names[0] ?? '',
};
addRecent(value);
onSelect(value);
};
const handleSelectRecent = (value: EmojiPickerValue) => {
addRecent(value);
onSelect(value);
};
const handleCategoryJump = (key: string) => {
setActiveCategory(key);
const el = categoryRefs.current[key];
if (el && gridRef.current) {
gridRef.current.scrollTo({ top: el.offsetTop - 8, behavior: 'smooth' });
}
};
const handleScroll = () => {
const grid = gridRef.current;
if (!grid) return;
const scrollTop = grid.scrollTop;
let best: string = activeCategory;
let bestDelta = Number.POSITIVE_INFINITY;
for (const key of Object.keys(categoryRefs.current)) {
const el = categoryRefs.current[key];
if (!el) continue;
const delta = Math.abs(el.offsetTop - scrollTop - 8);
if (delta < bestDelta) {
bestDelta = delta;
best = key;
}
}
if (best !== activeCategory) setActiveCategory(best);
};
const toggleCollapsed = (key: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const renderSectionHeader = (key: string, label: string) => {
const isCollapsed = collapsed.has(key);
return (
<button
type="button"
className={styles.sectionHeader}
onClick={() => toggleCollapsed(key)}
>
<span className={styles.sectionHeaderLabel}>{label}</span>
<CaretDown
size={14}
weight="bold"
className={`${styles.sectionHeaderCaret} ${
isCollapsed ? styles.sectionHeaderCaretCollapsed : ''
}`}
/>
</button>
);
};
// The index is included in the key because `emojis.json` contains
// the same surrogate in multiple categories (e.g. 🌆 lives under
// both "travel" and "nature" in some builds of the source data),
// which makes React complain about duplicate keys. Pair the
// surrogate with its map index so keys stay unique even when the
// underlying data isn't.
const renderUnicodeButton = (entry: EmojiEntry, idx: number) => (
<button
key={`${entry.surrogates}-${idx}`}
type="button"
className={styles.emojiButton}
onClick={() => handleSelect(entry)}
onMouseEnter={() =>
setHovered({
kind: 'unicode',
surrogates: entry.surrogates,
name: entry.names[0] ?? '',
})
}
onMouseLeave={() => setHovered(null)}
title={`:${entry.names[0] ?? ''}:`}
>
<TwemojiImg emoji={entry.surrogates} size={28} />
</button>
);
const handleSelectCustom = (entry: CustomEmojiListEntry) => {
const value: EmojiPickerValue = {
kind: 'custom',
shortcode: entry.name,
url: entry.src,
};
addRecent(value);
onSelect(value);
};
const renderCustomButton = (entry: CustomEmojiListEntry) => (
<button
key={entry._id}
type="button"
className={styles.emojiButton}
onClick={() => handleSelectCustom(entry)}
onMouseEnter={() =>
setHovered({ kind: 'custom', shortcode: entry.name, url: entry.src })
}
onMouseLeave={() => setHovered(null)}
title={`:${entry.name}:`}
>
<img
src={entry.src}
alt={entry.name}
draggable={false}
style={{ width: 32, height: 32, objectFit: 'contain' }}
/>
</button>
);
const renderRecentButton = (r: EmojiPickerValue, i: number) => {
if (r.kind === 'custom') {
return (
<button
key={`r-${i}-c-${r.shortcode}`}
type="button"
className={styles.emojiButton}
onClick={() => handleSelectRecent(r)}
onMouseEnter={() => setHovered(r)}
onMouseLeave={() => setHovered(null)}
title={`:${r.shortcode}:`}
>
<img
src={r.url}
alt={r.shortcode}
draggable={false}
style={{ width: 32, height: 32, objectFit: 'contain' }}
/>
</button>
);
}
return (
<button
key={`r-${i}-u-${r.surrogates}`}
type="button"
className={styles.emojiButton}
onClick={() => handleSelectRecent(r)}
onMouseEnter={() => setHovered(r)}
onMouseLeave={() => setHovered(null)}
>
<TwemojiImg emoji={r.surrogates} size={28} />
</button>
);
};
const renderSidebarTab = (key: string, label: string, icon: ReactNode) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
className={`${styles.sideTab} ${isActive ? styles.sideTabActive : ''}`}
onClick={() => handleCategoryJump(key)}
title={label}
aria-label={label}
>
{icon}
</button>
);
};
// Mobile layout - flat sticky headers + bottom category bar
if (mobile) {
return (
<div className={`${styles.picker} ${styles.pickerMobile}`}>
<div className={styles.searchBar}>
<MagnifyingGlass size={16} className={styles.searchIcon} />
<input
ref={searchRef}
className={styles.searchInput}
placeholder="Search emoji"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button type="button" className={styles.searchClear} onClick={() => setSearch('')}>
<X size={14} />
</button>
)}
</div>
<div className={styles.grid} ref={gridRef} onScroll={handleScroll}>
{filtered ? (
filtered.length === 0 ? (
<div className={styles.noResults}>No emoji found</div>
) : (
<>
<div className={styles.categoryHeader}>Search Results</div>
<div className={styles.emojiGrid}>
{filtered.map(renderUnicodeButton)}
</div>
</>
)
) : (
<>
{customEmojis.length > 0 && (
<div
ref={(el) => {
categoryRefs.current['custom'] = el;
}}
>
<div className={styles.categoryHeader}>Server Emojis</div>
<div className={styles.emojiGrid}>
{customEmojis.map(renderCustomButton)}
</div>
</div>
)}
{recents.length > 0 && (
<div
ref={(el) => {
categoryRefs.current['recent'] = el;
}}
>
<div className={styles.categoryHeader}>Recently Used</div>
<div className={styles.emojiGrid}>
{recents.map((r, i) => renderRecentButton(r, i))}
</div>
</div>
)}
{CATEGORIES.map((cat) => (
<div
key={cat.key}
ref={(el) => {
categoryRefs.current[cat.key] = el;
}}
>
<div className={styles.categoryHeader}>{cat.label}</div>
<div className={styles.emojiGrid}>
{(data[cat.key] ?? []).map(renderUnicodeButton)}
</div>
</div>
))}
</>
)}
</div>
<div className={styles.categoryBar}>
{customEmojis.length > 0 && (
<button
type="button"
className={`${styles.categoryTab} ${activeCategory === 'custom' ? styles.categoryTabActive : ''}`}
onClick={() => handleCategoryJump('custom')}
title="Server Emojis"
>
<SmileyWink size={20} />
</button>
)}
{recents.length > 0 && (
<button
type="button"
className={`${styles.categoryTab} ${activeCategory === 'recent' ? styles.categoryTabActive : ''}`}
onClick={() => handleCategoryJump('recent')}
title="Recently Used"
>
<Clock size={20} />
</button>
)}
{CATEGORIES.map((cat) => (
<button
key={cat.key}
type="button"
className={`${styles.categoryTab} ${activeCategory === cat.key ? styles.categoryTabActive : ''}`}
onClick={() => handleCategoryJump(cat.key)}
title={cat.label}
>
{cat.icon}
</button>
))}
</div>
{onClose && (
<button type="button" className={styles.searchClear} onClick={onClose}>
<X size={18} />
</button>
)}
</div>
);
}
// Desktop layout - top tab bar + sidebar + grid + inspector
return (
<div className={styles.picker}>
<div className={styles.tabBar}>
{EXPRESSION_TABS.map((tab) => {
const isActive = activeTab === tab.key;
return (
<button
key={tab.key}
type="button"
className={`${styles.tabButton} ${isActive ? styles.tabButtonActive : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
);
})}
</div>
{activeTab !== 'gifs' && (
<div className={styles.searchRow}>
<div className={styles.searchBar}>
<MagnifyingGlass size={16} className={styles.searchIcon} />
<input
ref={searchRef}
className={styles.searchInput}
placeholder={activeTab === 'emojis' ? 'Search emoji' : 'Coming soon'}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={activeTab !== 'emojis'}
/>
{search && (
<button type="button" className={styles.searchClear} onClick={() => setSearch('')}>
<X size={14} />
</button>
)}
</div>
</div>
)}
<div className={styles.main}>
{activeTab === 'emojis' && (
<div className={styles.sideBar}>
{customEmojis.length > 0 &&
renderSidebarTab('custom', 'Server Emojis', <SmileyWink size={20} />)}
{recents.length > 0 &&
renderSidebarTab('recent', 'Recently Used', <Clock size={20} />)}
{CATEGORIES.map((cat) => renderSidebarTab(cat.key, cat.label, cat.icon))}
</div>
)}
<div className={styles.grid} ref={gridRef} onScroll={handleScroll}>
{activeTab === 'gifs' ? (
<GifPicker
onSelectGif={(url) => {
onSelect({ kind: 'gif', url });
onClose?.();
}}
/>
) : activeTab === 'media' ? (
savedMedia.length === 0 ? (
<div className={styles.comingSoon}>
Nothing saved yet. Star an attachment to bookmark it
here for quick re-sharing.
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(96px, 1fr))',
gap: 8,
padding: 8,
}}
>
{savedMedia.map((item: any) => {
const isImage = item.kind === 'image';
const isVideo = item.kind === 'video';
return (
<button
key={item._id}
type="button"
title={item.filename}
onClick={() => handleRepostSaved(item)}
style={{
aspectRatio: '1 / 1',
borderRadius: 6,
overflow: 'hidden',
border: '1px solid var(--background-modifier-accent)',
background: 'var(--background-tertiary)',
cursor: 'pointer',
padding: 0,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-tertiary)',
}}
>
{isImage ? (
// Server-stored URL is publicly fetchable but
// the bytes are encrypted — show a generic
// thumbnail label since we'd need the per-file
// key to decrypt for a real preview.
<ImageSquare size={32} weight="fill" />
) : isVideo ? (
<Gif size={32} weight="fill" />
) : (
<Sticker size={32} weight="fill" />
)}
<span
style={{
position: 'absolute',
bottom: 4,
left: 4,
right: 4,
fontSize: 9,
color: 'var(--text-secondary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'center',
}}
>
{item.filename}
</span>
</button>
);
})}
</div>
)
) : activeTab !== 'emojis' ? (
<div className={styles.comingSoon}>
{EXPRESSION_TABS.find((t) => t.key === activeTab)?.label} are coming soon.
</div>
) : filtered ? (
filtered.length === 0 ? (
<div className={styles.noResults}>No emoji found</div>
) : (
<div className={styles.emojiGrid}>{filtered.map(renderUnicodeButton)}</div>
)
) : (
<>
{customEmojis.length > 0 && (
<div
className={styles.section}
ref={(el) => {
categoryRefs.current['custom'] = el;
}}
>
{renderSectionHeader('custom', 'Server Emojis')}
{!collapsed.has('custom') && (
<div className={styles.emojiGrid}>
{customEmojis.map(renderCustomButton)}
</div>
)}
</div>
)}
{recents.length > 0 && (
<div
className={styles.section}
ref={(el) => {
categoryRefs.current['recent'] = el;
}}
>
{renderSectionHeader('recent', 'Recently Used')}
{!collapsed.has('recent') && (
<div className={styles.emojiGrid}>
{recents.map((r, i) => renderRecentButton(r, i))}
</div>
)}
</div>
)}
{CATEGORIES.map((cat) => (
<div
key={cat.key}
className={styles.section}
ref={(el) => {
categoryRefs.current[cat.key] = el;
}}
>
{renderSectionHeader(cat.key, cat.label)}
{!collapsed.has(cat.key) && (
<div className={styles.emojiGrid}>
{(data[cat.key] ?? []).map(renderUnicodeButton)}
</div>
)}
</div>
))}
</>
)}
</div>
</div>
{activeTab === 'emojis' && (
<div className={styles.inspector}>
{hovered ? (
hovered.kind === 'custom' ? (
<>
<span className={styles.inspectorEmoji}>
<img
src={hovered.url}
alt={hovered.shortcode}
style={{ width: 32, height: 32, objectFit: 'contain' }}
/>
</span>
<span className={styles.inspectorName}>:{hovered.shortcode}:</span>
</>
) : (
<>
<span className={styles.inspectorEmoji}>
<TwemojiImg emoji={hovered.surrogates} size={32} />
</span>
<span className={styles.inspectorName}>:{hovered.name}:</span>
</>
)
) : (
<span className={styles.inspectorName}>Pick your emoji</span>
)}
</div>
)}
</div>
);
}
// Suppress unused icon warnings — they're kept imported for parity with
// the original's GIFs/Media/Stickers tabs.
void Gif;
void ImageSquare;
void Sticker;
export default EmojiPicker;

View File

@@ -0,0 +1,249 @@
import { useEffect, useState } from 'react';
import { usePlatform } from '../../platform';
import { AttachmentAudio } from './AttachmentAudio';
import { AttachmentVideo } from './AttachmentVideo';
export interface AttachmentMetadata {
type: 'attachment';
url: string;
filename: string;
mimeType: string;
size: number;
/** Hex-encoded AES-256 key. The file bytes are encrypted with this
* key before upload, so we need it to decrypt back to a blob URL. */
key: string;
iv: string;
width?: number;
height?: number;
}
const TAG_HEX_LEN = 32;
// Shared blob-URL cache so re-mounts don't refetch + redecrypt the
// same file. Keyed by the remote storage URL.
const attachmentCache = new Map<string, string>();
function fromHexString(hex: string): Uint8Array {
const matches = hex.match(/.{1,2}/g) ?? [];
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
}
function toHexString(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
/**
* Fetch an encrypted attachment from storage, decrypt it with the
* per-file AES key from the message metadata, and return a blob: URL
* that can be fed to <img>, <video>, <audio>, or <a download>.
*/
export function useDecryptedAttachmentUrl(metadata: AttachmentMetadata): {
url: string | null;
loading: boolean;
error: string | null;
} {
const { crypto } = usePlatform();
const [state, setState] = useState<{
url: string | null;
loading: boolean;
error: string | null;
}>(() => {
const cached = attachmentCache.get(metadata.url);
return { url: cached ?? null, loading: !cached, error: null };
});
useEffect(() => {
const cached = attachmentCache.get(metadata.url);
if (cached) {
setState({ url: cached, loading: false, error: null });
return;
}
let cancelled = false;
setState({ url: null, loading: true, error: null });
(async () => {
try {
const res = await fetch(metadata.url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer());
const hex = toHexString(buf);
if (hex.length < TAG_HEX_LEN) throw new Error('Invalid file data');
const contentHex = hex.slice(0, -TAG_HEX_LEN);
const tagHex = hex.slice(-TAG_HEX_LEN);
const plaintextBuf = await crypto.decryptData(
contentHex,
metadata.key,
metadata.iv,
tagHex,
{ encoding: 'buffer' },
);
const bytes =
plaintextBuf instanceof Uint8Array
? plaintextBuf
: new Uint8Array(plaintextBuf as ArrayBuffer);
const blob = new Blob([bytes], { type: metadata.mimeType });
const objectUrl = URL.createObjectURL(blob);
attachmentCache.set(metadata.url, objectUrl);
if (!cancelled) setState({ url: objectUrl, loading: false, error: null });
} catch (err: any) {
console.error('Attachment decrypt error:', err);
if (!cancelled)
setState({ url: null, loading: false, error: err?.message ?? 'decrypt failed' });
}
})();
return () => {
cancelled = true;
};
}, [metadata.url, metadata.key, metadata.iv, metadata.mimeType]);
return state;
}
interface AttachmentProps {
metadata: AttachmentMetadata;
onImageClick?: (url: string, metadata: AttachmentMetadata) => void;
className?: string;
}
export function EncryptedAttachment({ metadata, onImageClick, className }: AttachmentProps) {
const { url, loading, error } = useDecryptedAttachmentUrl(metadata);
const kind = metadata.mimeType.split('/')[0];
if (error) {
return <div style={{ color: 'var(--status-danger)', fontSize: 13 }}>{error}</div>;
}
if (kind === 'image') {
// Reserve the exact final layout box up-front so the loaded
// image lands in the same slot the placeholder occupied — no
// post-load height shift, no scroll jump. When the metadata
// carries both width + height we compute the box from them;
// otherwise we fall back to a ratio-aware aspect-ratio so the
// browser still reserves a sensible chunk of space.
const hasDims = !!metadata.width && !!metadata.height;
const maxW = metadata.width ? Math.min(metadata.width, 400) : 300;
const renderedH =
hasDims
? Math.round(maxW * (metadata.height! / metadata.width!))
: undefined;
const sharedBoxStyle: React.CSSProperties = {
width: maxW,
...(renderedH !== undefined ? { height: renderedH } : {}),
...(hasDims
? { aspectRatio: `${metadata.width} / ${metadata.height}` }
: { aspectRatio: '4 / 3' }),
maxHeight: '50vh',
borderRadius: 'var(--radius-lg)',
};
if (loading || !url) {
return (
<div
style={{
...sharedBoxStyle,
backgroundColor: 'var(--background-tertiary)',
}}
/>
);
}
return (
<img
src={url}
alt={metadata.filename}
className={className}
// `loading="eager"` so the image decodes synchronously
// with the placeholder it's replacing — keeps the
// "scroll to bottom on initial load" anchor accurate
// instead of jumping after a deferred lazy decode.
loading="eager"
decoding="async"
style={{
...sharedBoxStyle,
objectFit: 'cover',
cursor: 'pointer',
}}
onLoad={() => {
// Tell the Messages scroller that an attachment
// finished decoding so it can re-pin to bottom if
// the user is still anchored there. Belt-and-
// suspenders — the parent ResizeObserver should
// also catch this, but a same-size box with
// `objectFit: cover` may not trigger one.
window.dispatchEvent(
new CustomEvent('brycord:attachment-loaded'),
);
}}
onClick={() => onImageClick?.(url, metadata)}
/>
);
}
if (kind === 'video') {
if (loading || !url) {
return (
<div
style={{
width: 400,
height: 225,
backgroundColor: 'var(--background-tertiary)',
borderRadius: 'var(--radius-lg)',
}}
/>
);
}
return (
<AttachmentVideo
src={url}
filename={metadata.filename}
width={metadata.width}
height={metadata.height}
attachment={metadata}
/>
);
}
if (kind === 'audio') {
if (loading || !url) {
return (
<div
style={{
width: 360,
height: 96,
backgroundColor: 'var(--background-tertiary)',
borderRadius: 'var(--radius-lg)',
}}
/>
);
}
return (
<AttachmentAudio
src={url}
filename={metadata.filename}
attachment={metadata}
/>
);
}
// Generic file
if (loading || !url) {
return (
<div style={{ padding: 10, color: 'var(--text-tertiary)' }}>
{metadata.filename} (decrypting)
</div>
);
}
return (
<a
href={url}
download={metadata.filename}
className={className}
target="_blank"
rel="noopener noreferrer"
>
{metadata.filename}
</a>
);
}

View File

@@ -0,0 +1,45 @@
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 20px 24px 8px;
text-align: center;
}
.iconRow {
display: flex;
justify-content: center;
}
.iconBadge {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background-color: rgba(234, 80, 80, 0.15);
color: var(--status-danger, #da373c);
}
.body {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary, #a0a3a8);
max-width: 360px;
}
.body strong {
color: var(--text-primary, #fff);
font-weight: 600;
}
.hint {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-primary-muted, #a0a3a8);
max-width: 360px;
}

View File

@@ -0,0 +1,71 @@
/**
* FileSizeLimitModal — shown when a user drops (or picks) a file that
* exceeds the homeserver's advertised maximum upload size. Clicking
* Cancel closes the modal; there's no "upload anyway" path because
* the homeserver will just reject the POST with a 413 regardless.
*
* The numbers we show come from `MediaManager.getMaxUploadSize`
* (which calls `GET /_matrix/media/v3/config` on the user's own
* homeserver). This is a per-homeserver-federation policy — different
* homeservers have different limits — so the copy reflects that.
*/
import { Modal, Button } from '@brycord/ui';
import { WarningCircle } from '@phosphor-icons/react';
import styles from './FileSizeLimitModal.module.css';
interface FileSizeLimitModalProps {
isOpen: boolean;
onClose: () => void;
/** The file(s) that tripped the limit — for display only. */
fileName: string;
/** Actual file size in bytes. */
actualBytes: number;
/** Homeserver-advertised maximum in bytes. */
limitBytes: number;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes)) return 'unlimited';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function FileSizeLimitModal({
isOpen,
onClose,
fileName,
actualBytes,
limitBytes,
}: FileSizeLimitModalProps) {
if (!isOpen) return null;
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="small">
<Modal.Header title="File size limit exceeded" onClose={onClose} />
<Modal.Content className={styles.content}>
<div className={styles.iconRow}>
<div className={styles.iconBadge}>
<WarningCircle size={28} weight="fill" />
</div>
</div>
<p className={styles.body}>
The file <strong>{fileName}</strong> is{' '}
<strong>{formatBytes(actualBytes)}</strong>, which exceeds the
maximum upload size allowed by this homeserver of{' '}
<strong>{formatBytes(limitBytes)}</strong>.
</p>
<p className={styles.hint}>
Try a smaller file, compress the file, or upload it to another
service and share the link instead.
</p>
</Modal.Content>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Cancel
</Button>
</Modal.Footer>
</Modal.Root>
);
}

View File

@@ -0,0 +1,105 @@
/* Fills its parent completely — used as a full-window wrapper in
GuildsLayout so a drop anywhere in the Brycord viewport triggers
the overlay. The `display: contents`-style layout contract is
preserved by `height: 100%` + `width: 100%` so the inner tree
(which expects a block filling its parent) still lays out the
same as before. */
.root {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0;
}
.overlay {
position: absolute;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(3px);
pointer-events: none; /* let the drop event fall through to .root */
animation: fadeIn 120ms ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 32px 40px;
background-color: var(--background-floating, #1a1b1e);
border: 2px dashed var(--brand-primary, #5865f2);
border-radius: 12px;
max-width: 420px;
text-align: center;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.6);
/* cap re-enable so the card itself doesn't block the drop event
(though the overlay is pointer-events:none anyway). */
pointer-events: none;
}
.iconBadge {
display: flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 50%;
background-color: rgba(88, 101, 242, 0.15);
color: var(--brand-primary, #5865f2);
margin-bottom: 4px;
}
.title {
font-size: 18px;
font-weight: 700;
color: var(--text-primary, #fff);
}
.subtitle {
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary, #a0a3a8);
max-width: 320px;
}
.hint {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 6px 12px;
background-color: rgba(0, 0, 0, 0.4);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #fff);
}
.hintKey {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.15);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
font-weight: 600;
}

View File

@@ -0,0 +1,128 @@
import { File as FileIcon } from '@phosphor-icons/react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import styles from './FileUploadDropZone.module.css';
export type DropMode = 'compose' | 'instant';
interface FileUploadDropZoneProps {
channelName: string;
onDropFiles: (files: File[], mode: DropMode) => void;
disabled?: boolean;
children: ReactNode;
}
/**
* Full-area file drop target. Wrap the whole chat region with this so
* a drop anywhere in the viewport — sidebar, chat body, members — is
* captured. The overlay covers the full root and shows a centered
* "Upload to #channel" card.
*
* Drag counter guards nested dragenter/dragleave events; the overlay
* uses `pointer-events: none` so the drop itself falls through to
* the root handler.
*/
export function FileUploadDropZone({
channelName,
onDropFiles,
disabled = false,
children,
}: FileUploadDropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);
const hasFiles = (e: React.DragEvent): boolean => {
const items = e.dataTransfer?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
if (items[i].kind === 'file') return true;
}
}
const types = e.dataTransfer?.types;
return !!types && Array.from(types).includes('Files');
};
const handleDragEnter = (e: React.DragEvent) => {
if (disabled || !hasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
dragCounter.current += 1;
if (dragCounter.current === 1) setIsDragging(true);
};
const handleDragOver = (e: React.DragEvent) => {
if (disabled || !hasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
};
const handleDragLeave = (e: React.DragEvent) => {
if (disabled || !hasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
dragCounter.current -= 1;
if (dragCounter.current <= 0) {
dragCounter.current = 0;
setIsDragging(false);
}
};
const handleDrop = (e: React.DragEvent) => {
if (disabled) return;
if (!hasFiles(e)) return;
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setIsDragging(false);
const files: File[] = [];
if (e.dataTransfer?.files) {
for (let i = 0; i < e.dataTransfer.files.length; i++) {
files.push(e.dataTransfer.files[i]);
}
}
if (files.length === 0) return;
const mode: DropMode = e.shiftKey ? 'instant' : 'compose';
onDropFiles(files, mode);
};
// Window blur can strand the overlay if the user drags out of the
// viewport without a matching dragleave. Reset defensively.
useEffect(() => {
const handleWindowBlur = () => {
dragCounter.current = 0;
setIsDragging(false);
};
window.addEventListener('blur', handleWindowBlur);
return () => window.removeEventListener('blur', handleWindowBlur);
}, []);
return (
<div
className={styles.root}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{children}
{isDragging && (
<div className={styles.overlay} aria-hidden="true">
<div className={styles.card}>
<div className={styles.iconBadge}>
<FileIcon size={32} weight="duotone" />
</div>
<div className={styles.title}>Upload to #{channelName}</div>
<div className={styles.subtitle}>
You can add comments before uploading. Hold shift to upload directly.
</div>
<div className={styles.hint}>
<span className={styles.hintKey}></span>
<span>Hold for instant upload</span>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 20px 4px;
}
.destination {
font-size: 13px;
color: var(--text-secondary, #a0a3a8);
}
.destination strong {
color: var(--text-primary, #fff);
font-weight: 600;
}
.previewGrid {
display: flex;
flex-wrap: wrap;
gap: 10px;
max-height: 320px;
overflow-y: auto;
}
.preview {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
width: 140px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
border-radius: 8px;
}
.removeButton {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background-color: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
z-index: 1;
transition: background-color 0.1s;
}
.removeButton:hover {
background-color: var(--status-danger, #da373c);
}
.previewImage {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.3);
}
.previewIcon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100px;
background-color: rgba(88, 101, 242, 0.12);
color: var(--brand-primary, #5865f2);
border-radius: 6px;
}
.previewMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.previewName {
font-size: 12px;
font-weight: 500;
color: var(--text-primary, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.previewSize {
font-size: 11px;
color: var(--text-primary-muted, #a0a3a8);
}
.captionLabel {
display: flex;
flex-direction: column;
gap: 6px;
}
.captionLabelText {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-primary-muted, #a0a3a8);
}
.captionInput {
width: 100%;
padding: 10px 12px;
background-color: var(--background-tertiary, rgba(0, 0, 0, 0.3));
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-primary, #fff);
font-size: 14px;
font-family: inherit;
outline: none;
box-sizing: border-box;
transition: border-color 0.1s;
}
.captionInput:focus {
border-color: var(--brand-primary, #5865f2);
}
.errorBanner {
padding: 10px 12px;
background-color: rgba(234, 80, 80, 0.15);
border: 1px solid rgba(234, 80, 80, 0.45);
border-radius: 6px;
color: var(--text-primary, #fff);
font-size: 13px;
line-height: 1.45;
word-break: break-word;
}

View File

@@ -0,0 +1,194 @@
/**
* FileUploadModal — compose UI that opens after dropping file(s)
* onto the chat area (unless Shift was held, which triggers an
* instant upload instead). Shows a preview of each file, a single
* shared caption input, and a Send button.
*
* Behaviour:
* - Image files (mime starts with `image/`): show as thumbnails.
* - Other files: show filename + extension badge + size.
* - Caption is sent attached to the FIRST file only (matches
* Discord where a single comment lives above a group of files).
* - Enter in the caption input sends; Shift+Enter inserts a newline.
* - Cancel discards everything and closes.
*/
import { useEffect, useMemo, useState } from 'react';
import { File as FileIcon, X, PaperPlaneTilt } from '@phosphor-icons/react';
import { Modal, Button } from '@brycord/ui';
import styles from './FileUploadModal.module.css';
interface FileUploadModalProps {
isOpen: boolean;
onClose: () => void;
channelName: string;
files: File[];
onSend: (caption: string) => Promise<void> | void;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FilePreview({ file, onRemove }: { file: File; onRemove?: () => void }) {
// Create a local object URL for image previews. Revoke on unmount
// so we don't leak. For non-image files we skip the URL entirely.
const objectUrl = useMemo(() => {
if (file.type.startsWith('image/')) return URL.createObjectURL(file);
return null;
}, [file]);
useEffect(() => {
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [objectUrl]);
return (
<div className={styles.preview}>
{onRemove && (
<button
type="button"
className={styles.removeButton}
onClick={onRemove}
aria-label={`Remove ${file.name}`}
>
<X size={14} weight="bold" />
</button>
)}
{objectUrl ? (
<img src={objectUrl} alt={file.name} className={styles.previewImage} />
) : (
<div className={styles.previewIcon}>
<FileIcon size={40} weight="duotone" />
</div>
)}
<div className={styles.previewMeta}>
<div className={styles.previewName} title={file.name}>
{file.name}
</div>
<div className={styles.previewSize}>{formatSize(file.size)}</div>
</div>
</div>
);
}
export function FileUploadModal({
isOpen,
onClose,
channelName,
files,
onSend,
}: FileUploadModalProps) {
const [caption, setCaption] = useState('');
const [sending, setSending] = useState(false);
const [workingFiles, setWorkingFiles] = useState<File[]>(files);
const [error, setError] = useState<string | null>(null);
// Reset local state whenever the modal opens with a fresh file list.
useEffect(() => {
if (isOpen) {
setWorkingFiles(files);
setCaption('');
setSending(false);
setError(null);
}
}, [isOpen, files]);
const handleRemove = (index: number) => {
setWorkingFiles((prev) => {
const next = prev.filter((_, i) => i !== index);
// If the user removed the last file, close the modal.
if (next.length === 0) onClose();
return next;
});
};
const handleSend = async () => {
if (workingFiles.length === 0 || sending) return;
setSending(true);
setError(null);
try {
await onSend(caption);
onClose();
} catch (err: any) {
console.error('Failed to upload file(s):', err);
// Surface the error inline so the user knows why their
// upload failed. Common causes: 413 (too large — shouldn't
// happen anymore since the drop-zone pre-checks), 520/502
// (homeserver's media CDN is misbehaving), auth token
// expired, network drop. The modal stays open so they can
// hit Upload again to retry, or Cancel to bail out.
const code = err?.httpStatus || err?.status;
const matrixCode = err?.errcode || err?.data?.errcode;
const detail = err?.data?.error || err?.message;
let message = 'Upload failed';
if (code) message += ` (HTTP ${code})`;
if (matrixCode) message += `${matrixCode}`;
if (detail) message += `: ${detail}`;
setError(message);
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
};
if (!isOpen) return null;
const title =
workingFiles.length === 1
? `Upload ${workingFiles[0].name}`
: `Upload ${workingFiles.length} files`;
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
<Modal.Header title={title} onClose={onClose} />
<Modal.Content className={styles.content}>
<div className={styles.destination}>
Sending to <strong>#{channelName}</strong>
</div>
<div className={styles.previewGrid}>
{workingFiles.map((file, i) => (
<FilePreview
key={`${file.name}-${i}`}
file={file}
onRemove={workingFiles.length > 1 ? () => handleRemove(i) : undefined}
/>
))}
</div>
<label className={styles.captionLabel}>
<span className={styles.captionLabelText}>Add a comment</span>
<input
type="text"
className={styles.captionInput}
placeholder="Optional comment…"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
</label>
{error && <div className={styles.errorBanner}>{error}</div>}
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={sending}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSend}
disabled={workingFiles.length === 0 || sending}
icon={<PaperPlaneTilt size={16} weight="fill" />}
>
{sending ? 'Uploading…' : 'Upload'}
</Button>
</Modal.Footer>
</Modal.Root>
);
}

View File

@@ -0,0 +1,202 @@
/* ── GifPicker ─────────────────────────────────────────────────
Klipy-backed GIF browser embedded in the EmojiPicker's GIFs tab.
Search bar at the top, optional featured tile row + grid below. */
.root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 10px;
padding: 10px 12px;
box-sizing: border-box;
}
.searchBar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--background-tertiary);
border: 1px solid var(--background-modifier-accent);
border-radius: 8px;
flex-shrink: 0;
}
.searchIcon {
color: var(--text-tertiary);
flex-shrink: 0;
}
.searchInput {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font: inherit;
font-size: 14px;
min-width: 0;
}
.featuredRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
flex-shrink: 0;
}
.featuredTile {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
padding: 14px;
height: 96px;
border-radius: 8px;
border: none;
cursor: pointer;
color: #ffffff;
overflow: hidden;
transition: transform 0.12s ease;
}
.featuredTile:hover {
transform: translateY(-1px);
}
.featuredFavorites {
background: linear-gradient(135deg, #4641d9 0%, #7b5ce7 100%);
}
.featuredTrending {
background: linear-gradient(135deg, #f04f88 0%, #ff7c5c 100%);
}
.featuredIcon {
position: absolute;
top: 12px;
right: 12px;
opacity: 0.85;
}
.featuredLabel {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.01em;
}
.subHeaderRow {
display: flex;
align-items: center;
gap: 12px;
padding: 0 4px;
flex-shrink: 0;
}
.backLink {
background: none;
border: none;
color: var(--brand-primary, #5865f2);
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
}
.subHeaderTitle {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.02em;
text-transform: uppercase;
}
.grid {
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;
}
.grid::-webkit-scrollbar {
display: none;
}
.tile {
position: relative;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
background: var(--background-tertiary);
aspect-ratio: 16 / 9;
width: 100%;
min-width: 0;
min-height: 0;
transition: transform 0.12s ease;
}
.tile:hover {
transform: scale(1.02);
}
.tileImage {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.favoriteButton {
position: absolute;
top: 6px;
right: 6px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.55);
color: #ffffff;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s ease, background-color 0.12s ease;
}
.tile:hover .favoriteButton {
opacity: 1;
}
.favoriteButton:hover {
background: rgba(0, 0, 0, 0.75);
}
.favoriteButtonActive {
opacity: 1;
color: var(--brand-primary, #5865f2);
}
.statusMessage,
.statusMessageError {
padding: 24px 12px;
text-align: center;
color: var(--text-tertiary);
font-size: 13px;
}
.statusMessageError {
color: var(--status-danger, #ed4245);
}

View File

@@ -0,0 +1,260 @@
/**
* GifPicker — Klipy-backed GIF browser embedded in the EmojiPicker's
* "GIFs" tab. Layout matches the new UI screenshot:
*
* ┌────────────────────────────────────┐
* │ [ 🔍 Search Tenor / Klipy ] │
* │ ┌──────────┐ ┌──────────────────┐ │
* │ │ ★ │ │ Trending GIFs │ │
* │ │ Favorites│ │ │ │
* │ └──────────┘ └──────────────────┘ │
* │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
* │ │ G │ │ G │ │ G │ │ G │ │
* │ └────┘ └────┘ └────┘ └────┘ │
* └────────────────────────────────────┘
*
* Click on any tile fires `onSelectGif(url)` — the parent picker
* forwards that to the composer, which sends the GIF URL as a
* plain text message. The auto-link parser in MessageContent +
* the LinkEmbed/DirectMediaEmbed renderer take it from there.
*/
import { useEffect, useMemo, useState } from 'react';
import { useAction } from 'convex/react';
import { MagnifyingGlass, Star, TrendUp } from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import styles from './GifPicker.module.css';
interface GifResult {
id: string;
title: string;
url: string;
previewUrl: string;
width?: number;
height?: number;
}
interface GifPickerProps {
onSelectGif: (url: string) => void;
}
type Tab = 'home' | 'favorites' | 'trending';
const FAVORITES_KEY = 'gifPicker.favorites';
function loadFavorites(): GifResult[] {
try {
const raw = localStorage.getItem(FAVORITES_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveFavorites(items: GifResult[]) {
try {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items.slice(0, 100)));
} catch {
/* ignore quota */
}
}
export function GifPicker({ onSelectGif }: GifPickerProps) {
const [search, setSearch] = useState('');
const [tab, setTab] = useState<Tab>('home');
const [trending, setTrending] = useState<GifResult[]>([]);
const [searchResults, setSearchResults] = useState<GifResult[]>([]);
const [favorites, setFavorites] = useState<GifResult[]>(() =>
loadFavorites(),
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const searchAction = useAction(api.gifs.search);
const trendingAction = useAction(api.gifs.trending);
// 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.
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const res: any = await trendingAction({ limit: 24 });
if (cancelled) return;
setTrending(res?.results ?? []);
} catch (err: any) {
if (cancelled) return;
setError(err?.message ?? 'Failed to load GIFs.');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [trendingAction]);
// Debounced search — fires 350ms after the last keystroke so we
// don't hammer the upstream API on every character.
useEffect(() => {
const q = search.trim();
if (!q) {
setSearchResults([]);
return;
}
const t = window.setTimeout(async () => {
setLoading(true);
setError(null);
try {
const res: any = await searchAction({ q, limit: 24 });
setSearchResults(res?.results ?? []);
} catch (err: any) {
setError(err?.message ?? 'Search failed.');
} finally {
setLoading(false);
}
}, 350);
return () => window.clearTimeout(t);
}, [search, searchAction]);
const handlePick = (gif: GifResult) => {
onSelectGif(gif.url);
};
const toggleFavorite = (gif: GifResult, e: React.MouseEvent) => {
e.stopPropagation();
setFavorites((prev) => {
const exists = prev.some((g) => g.id === gif.id);
const next = exists
? prev.filter((g) => g.id !== gif.id)
: [gif, ...prev];
saveFavorites(next);
return next;
});
};
const isFavorited = (gif: GifResult) =>
favorites.some((g) => g.id === gif.id);
// 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.
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;
}, [isSearching, searchResults, tab, favorites, trending]);
const showFeaturedRow = !isSearching && tab === 'home';
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>
{showFeaturedRow && (
<div className={styles.featuredRow}>
<button
type="button"
className={`${styles.featuredTile} ${styles.featuredFavorites}`}
onClick={() => setTab('favorites')}
>
<Star size={28} weight="fill" className={styles.featuredIcon} />
<span className={styles.featuredLabel}>Favorites</span>
</button>
<button
type="button"
className={`${styles.featuredTile} ${styles.featuredTrending}`}
onClick={() => setTab('trending')}
>
<TrendUp size={28} weight="fill" className={styles.featuredIcon} />
<span className={styles.featuredLabel}>Trending GIFs</span>
</button>
</div>
)}
{tab !== 'home' && !isSearching && (
<div className={styles.subHeaderRow}>
<button
type="button"
className={styles.backLink}
onClick={() => setTab('home')}
>
Back
</button>
<span className={styles.subHeaderTitle}>
{tab === 'favorites' ? 'Favorites' : 'Trending GIFs'}
</span>
</div>
)}
{loading && displayList.length === 0 ? (
<div className={styles.statusMessage}>Loading GIFs</div>
) : error ? (
<div className={styles.statusMessageError}>{error}</div>
) : displayList.length === 0 ? (
<div className={styles.statusMessage}>
{isSearching
? 'No GIFs match your search.'
: tab === 'favorites'
? 'No favorites yet. Click the star on a GIF to save it here.'
: 'No GIFs to show.'}
</div>
) : (
<div className={styles.grid}>
{displayList.map((gif) => {
const fav = isFavorited(gif);
return (
<button
key={gif.id}
type="button"
className={styles.tile}
onClick={() => handlePick(gif)}
title={gif.title || 'GIF'}
>
<img
src={gif.previewUrl || gif.url}
alt={gif.title || 'GIF'}
className={styles.tileImage}
loading="lazy"
draggable={false}
/>
<button
type="button"
className={`${styles.favoriteButton} ${
fav ? styles.favoriteButtonActive : ''
}`}
onClick={(e) => toggleFavorite(gif, e)}
aria-label={fav ? 'Unfavorite GIF' : 'Favorite GIF'}
title={fav ? 'Unfavorite' : 'Favorite'}
>
<Star size={14} weight={fav ? 'fill' : 'regular'} />
</button>
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,238 @@
/* ── Backdrop ─────────────────────────────────────────────────
Full-viewport dimmer behind the image. When the image is
zoomed the backdrop allows scroll so the user can pan through
overflow content. */
.backdrop {
position: fixed;
inset: 0;
z-index: 16000;
display: flex;
/* `safe center` keeps the image centred when it fits the
viewport but stops centering once the content overflows —
without `safe`, an oversized child gets clipped at the top /
left and the user can't scroll back into the hidden region. */
align-items: safe center;
justify-content: safe center;
background-color: rgba(0, 0, 0, 0.85);
padding: 80px 48px 48px;
box-sizing: border-box;
overflow: auto;
/* Desktop click-outside-to-close affordance — mobile drops
this via the media query below since tapping empty space
there is more likely to be accidental. */
cursor: zoom-out;
}
/* Mobile: drop the backdrop padding so the image can use the full
width of the screen. The mobile header buttons are position: fixed
so they still float over the image instead of occupying layout. */
@media (max-width: 768px) {
.backdrop {
padding: 0;
cursor: default;
}
}
/* Image — fits the viewport by default. Toggling `.imageZoomed`
drops the max constraints so the natural size kicks in; the
backdrop becomes scrollable to pan through the overflow. */
.image {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
cursor: zoom-in;
user-select: none;
-webkit-user-drag: none;
transition: transform 0.2s ease;
}
.imageZoomed {
max-width: none;
max-height: none;
/* Force the image to its natural pixel dimensions so the
backdrop's overflow:auto actually has something to scroll. */
width: auto;
height: auto;
min-width: max-content;
min-height: max-content;
cursor: zoom-out;
}
/* ── Mobile header row ───────────────────────────────────────
Bare minimum on mobile: an X button pinned top-left and a
three-dot menu button pinned top-right. Everything else lives
in the MobileImageActionsSheet the menu button opens.
Rendered instead of the desktop `.header` on touch viewports. */
.mobileHeader {
position: fixed;
top: 14px;
left: 14px;
right: 14px;
display: flex;
align-items: center;
justify-content: space-between;
pointer-events: none;
z-index: 1;
}
.mobileHeaderButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-primary, #fff);
cursor: pointer;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.12s;
}
.mobileHeaderButton:active {
background-color: rgba(255, 255, 255, 0.12);
}
/* ── Desktop header row ──────────────────────────────────────
Sits above the image, pinned to the top of the viewport. Holds
three independent cards: file info (left), action controls
(right), close button (far right). */
.header {
position: fixed;
top: 16px;
left: 24px;
right: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 40px;
pointer-events: none;
z-index: 1;
}
/* File info card — filename + dimensions. Spec from Fluxer:
`--background-textarea` surface with a 1px accent border and
a soft drop shadow. Constrained max-width so it never shoves
the action cards off-screen on narrow viewports. */
.fileInfoCard {
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.125rem;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-lg, 10px);
background-color: var(--background-textarea, rgba(24, 25, 28, 0.85));
border: 1px solid var(--background-modifier-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: auto;
min-width: 0;
max-width: calc(100% - 260px);
height: 100%;
box-sizing: border-box;
}
.filename {
font-size: 13px;
font-weight: 700;
color: var(--text-primary, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.dimensions {
font-size: 11px;
color: var(--text-primary-muted, rgba(255, 255, 255, 0.6));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
line-height: 1.2;
}
/* Right-side cluster: action card + close card, separated so the
close button can sit visually apart like the Fluxer reference. */
.headerRight {
display: flex;
align-items: stretch;
gap: 8px;
height: 100%;
pointer-events: none;
}
/* Action card — zoom / favorite / save / open. Same surface
treatment as the file info card, tighter padding and gap. */
.actionCard {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem 0.375rem;
border-radius: var(--radius-lg, 10px);
background-color: var(--background-textarea, rgba(24, 25, 28, 0.85));
border: 1px solid var(--background-modifier-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: auto;
height: 100%;
box-sizing: border-box;
}
/* Close card — its own standalone pill. Square-ish so the X
button reads as a self-contained control. */
.closeCard {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-lg, 10px);
background-color: var(--background-textarea, rgba(24, 25, 28, 0.85));
border: 1px solid var(--background-modifier-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: auto;
height: 100%;
box-sizing: border-box;
}
.actionButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-primary, #fff);
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.actionButton:hover:not(:disabled) {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.1));
}
.actionButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Favorited state — brand-primary fill + white icon so the
star reads as "on" even when the button isn't hovered.
Matches the spec'd `--brand-primary` / `--text-on-brand-primary`
tokens. */
.actionButtonActive {
background-color: var(--brand-primary);
color: var(--text-on-brand-primary);
}
.actionButtonActive:hover:not(:disabled) {
background-color: var(--brand-primary);
color: var(--text-on-brand-primary);
filter: brightness(1.08);
}

View File

@@ -0,0 +1,298 @@
/**
* ImageLightbox — full-screen image viewer opened by clicking an
* image attachment in the chat. Shows a file info card (filename +
* dimensions) on the left, an action card (zoom toggle, download,
* open-external) on the right, and a standalone close card. Clicking
* the image toggles between fit-to-viewport and native-size modes —
* when zoomed, the backdrop scrolls so the user can pan through the
* overflow.
*
* Layout mirrors the Brycord/Fluxer reference 1:1, with Matrix-
* specific bits (useMxcUrl, SavedMediaStore, MobileImageActionsSheet)
* stripped out. The `src` prop is a blob URL already decrypted by
* EncryptedAttachment — no async resolution happens here.
*
* Keyboard: Escape closes, `+` / `-` toggle zoom, `0` resets.
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useMutation, useQuery } from 'convex/react';
import {
Download,
ArrowSquareOut,
Star,
X,
MagnifyingGlassPlus,
MagnifyingGlassMinus,
} from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import type { AttachmentMetadata } from './EncryptedAttachment';
import styles from './ImageLightbox.module.css';
interface ImageLightboxProps {
isOpen: boolean;
onClose: () => void;
/** Blob URL of the decrypted image bytes. */
src: string;
/** Filename to show in the info card and to use for the download
* attribute. Falls back to `alt` then to a generic "image". */
filename?: string;
/** Legacy alias for `filename` kept so existing MessageGroup callers
* that only pass `alt` still render a label. */
alt?: string;
/** Pixel dimensions used by the info card's `WxH` readout. */
width?: number;
height?: number;
/** Byte size of the attachment — rendered alongside the dimensions
* when present. */
size?: number;
mimeType?: string;
/**
* Original encrypted attachment metadata. When present, the
* lightbox shows a star button so the user can save the image to
* their personal media library — the saved entry stores the same
* url + per-file key + iv so it can be re-posted later without
* re-uploading the bytes.
*/
attachment?: AttachmentMetadata;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function ImageLightbox({
isOpen,
onClose,
src,
filename,
alt,
width,
height,
size,
attachment,
}: ImageLightboxProps) {
const [zoomed, setZoomed] = useState(false);
const label = filename ?? alt ?? 'image';
// Saved-media wiring — fetches the user's library once so we can
// flip the star button between "save" and "unsave" states. The
// list query is cheap (per-user, indexed) and stays cached.
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const savedList = useQuery(
api.savedMedia.list,
myUserId && isOpen ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
);
const isSaved = !!(
attachment &&
savedList?.some((m) => m.url === attachment.url)
);
const saveMutation = useMutation(api.savedMedia.save);
const removeMutation = useMutation(api.savedMedia.remove);
const handleToggleSaved = async () => {
if (!attachment || !myUserId) return;
try {
if (isSaved) {
await removeMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
});
} else {
const kind = attachment.mimeType.split('/')[0]; // image | video | audio
await saveMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
kind,
filename: attachment.filename,
mimeType: attachment.mimeType,
width: attachment.width,
height: attachment.height,
size: attachment.size,
encryptionKey: attachment.key,
encryptionIv: attachment.iv,
});
}
} catch (err) {
console.warn('Failed to toggle saved media:', err);
}
};
// Close on Escape, toggle zoom via keyboard shortcuts. Also lock
// body scroll while the lightbox is open so the background chat
// doesn't jitter when the backdrop consumes the viewport.
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === '+' || e.key === '=') setZoomed(true);
else if (e.key === '-' || e.key === '_') setZoomed(false);
else if (e.key === '0') setZoomed(false);
};
document.addEventListener('keydown', handleKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = prevOverflow;
};
}, [isOpen, onClose]);
// Reset transient state whenever the modal closes so a previously
// zoomed session doesn't bleed into the next attachment clicked.
useEffect(() => {
if (!isOpen) setZoomed(false);
}, [isOpen]);
const dimensionsLabel = width && height ? `${width}×${height}` : '';
const sizeLabel = size ? formatBytes(size) : '';
const metaLabel = [dimensionsLabel, sizeLabel].filter(Boolean).join(' · ');
const handleDownload = () => {
if (!src) return;
// Trigger a download via a temporary <a download>. Blob URLs
// honour the `download` attribute in all modern browsers.
const a = document.createElement('a');
a.href = src;
a.download = label;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const handleOpenExternal = () => {
if (!src) return;
window.open(src, '_blank', 'noopener,noreferrer');
};
const handleToggleZoom = () => setZoomed((z) => !z);
// Wrap every action button click so it doesn't bubble up to the
// backdrop (which would close the lightbox).
const stop =
(fn: () => void) =>
(e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
className={styles.backdrop}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
>
{/* Desktop header — file info card on the left, action
card + close card on the right. Each sits in its
own `--background-textarea` pill. */}
<div className={styles.header} onClick={(e) => e.stopPropagation()}>
<div className={styles.fileInfoCard}>
<div className={styles.filename} title={label}>
{label}
</div>
{metaLabel && <div className={styles.dimensions}>{metaLabel}</div>}
</div>
<div className={styles.headerRight}>
<div className={styles.actionCard}>
<button
type="button"
className={styles.actionButton}
onClick={stop(handleToggleZoom)}
aria-label={zoomed ? 'Zoom out' : 'Zoom in'}
title={zoomed ? 'Zoom out' : 'Zoom in'}
>
{zoomed ? (
<MagnifyingGlassMinus size={18} weight="regular" />
) : (
<MagnifyingGlassPlus size={18} weight="regular" />
)}
</button>
{attachment && (
<button
type="button"
className={styles.actionButton}
onClick={stop(() => void handleToggleSaved())}
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
title={isSaved ? 'Unfavorite' : 'Favorite'}
style={
isSaved
? { color: 'var(--brand-primary, #5865f2)' }
: undefined
}
>
<Star
size={18}
weight={isSaved ? 'fill' : 'regular'}
/>
</button>
)}
<button
type="button"
className={styles.actionButton}
onClick={stop(handleDownload)}
aria-label="Download"
title="Download"
disabled={!src}
>
<Download size={18} weight="regular" />
</button>
<button
type="button"
className={styles.actionButton}
onClick={stop(handleOpenExternal)}
aria-label="Open in new tab"
title="Open in new tab"
disabled={!src}
>
<ArrowSquareOut size={18} weight="regular" />
</button>
</div>
<div className={styles.closeCard}>
<button
type="button"
className={styles.actionButton}
onClick={stop(onClose)}
aria-label="Close"
title="Close (Esc)"
>
<X size={20} weight="bold" />
</button>
</div>
</div>
</div>
{/* Centered image. Clicking it toggles zoom — event is
stopped so the click doesn't propagate to the
backdrop's close handler. */}
<motion.img
key={src || 'loading'}
src={src || undefined}
alt={label}
className={`${styles.image} ${zoomed ? styles.imageZoomed : ''}`}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
onClick={stop(handleToggleZoom)}
draggable={false}
/>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}

View File

@@ -0,0 +1,98 @@
/* ── Fluxer-style inline editor ────────────────────────────────
Lives in-place of the normal message body when the user edits.
Rounded contenteditable box with the current text, an emoji
button pinned top-right, and a keyboard-hint footer underneath
telling the user how to save or cancel. */
.wrapper {
display: flex;
flex-direction: column;
gap: 0.3125rem;
margin-top: 0.125rem;
max-width: 100%;
}
.box {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
}
.editor {
flex: 1;
min-width: 0;
min-height: 1.25rem;
max-height: 320px;
overflow-y: auto;
font-size: 0.9375rem;
line-height: 1.375rem;
color: var(--text-primary);
outline: none;
word-wrap: break-word;
white-space: pre-wrap;
}
.editor:empty::before {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
}
.emojiButton {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: none;
border-radius: 50%;
background-color: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.emojiButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.hint {
display: flex;
align-items: center;
gap: 0.25rem;
padding-left: 0.25rem;
font-size: 0.75rem;
color: var(--text-tertiary);
}
.hint strong {
color: var(--brand-primary, #4641d9);
font-weight: 600;
cursor: pointer;
}
.hint strong:hover {
text-decoration: underline;
}
.hintSeparator {
display: inline-block;
width: 3px;
height: 3px;
border-radius: 50%;
background-color: var(--text-tertiary);
margin: 0 0.125rem;
}
.error {
font-size: 0.75rem;
color: var(--status-danger);
padding-left: 0.25rem;
}

View File

@@ -0,0 +1,147 @@
/**
* InlineMessageEditor — fluxer-style inline edit UI for a message.
*
* Renders a rounded contenteditable box in place of the normal
* message body. Keyboard model matches fluxer: Enter saves, Escape
* cancels, Shift+Enter inserts a newline. A secondary "escape to
* cancel • enter to save" hint sits under the box and doubles as
* clickable shortcuts (the words `cancel` and `save` are buttons).
*
* The editor is a contenteditable `div` rather than a textarea so
* future work can drop mention pills / custom emoji into the same
* edit path (matching the composer's behaviour). For v1 we just
* serialise `innerText` back to plain text on save.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Smiley } from '@phosphor-icons/react';
import styles from './InlineMessageEditor.module.css';
export interface InlineMessageEditorProps {
/** Current body we're editing — pre-filled into the box. For
* attachments this is the existing caption (MSC2530 `body` is
* the caption when `filename` is set). */
initialContent: string;
/** Called with the new text when the user commits. Empty
* strings are allowed (clearing a caption, or blanking a text
* message to effectively delete it — the caller decides). */
onSave: (newContent: string) => Promise<void> | void;
/** Called when the user escapes or clicks "cancel". Parent
* should clear its `editingEventId` state. */
onCancel: () => void;
/** Placeholder shown when the box is empty. Callers typically
* pass a type-specific hint ("Add a caption…" for attachments,
* "Message" for text). */
placeholder?: string;
}
export function InlineMessageEditor({
initialContent,
onSave,
onCancel,
placeholder = 'Message',
}: InlineMessageEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Seed the editor once on mount with the existing content, then
// drop the caret at the end so the user can keep typing. A ref
// rather than React-controlled content so React doesn't fight
// the contenteditable on every keystroke.
useEffect(() => {
const el = editorRef.current;
if (!el) return;
el.textContent = initialContent;
el.focus();
// Move caret to end of content.
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(range);
}
// Intentionally depend on nothing so this only runs once —
// we don't want to clobber the user's typing if `initialContent`
// ever changes identity during an edit session.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSave = useCallback(async () => {
if (saving) return;
const next = editorRef.current?.innerText ?? '';
// Trim trailing whitespace but preserve interior newlines so
// multi-line edits survive round-tripping.
const trimmed = next.replace(/\s+$/g, '');
setError(null);
setSaving(true);
try {
await onSave(trimmed);
} catch (err: any) {
setError(err?.message || 'Failed to save edit.');
setSaving(false);
return;
}
setSaving(false);
}, [onSave, saving]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSave();
return;
}
// Shift+Enter falls through so the contenteditable inserts a
// line break normally.
},
[handleSave, onCancel],
);
return (
<div className={styles.wrapper}>
<div className={styles.box}>
<div
ref={editorRef}
className={styles.editor}
contentEditable
suppressContentEditableWarning
data-placeholder={placeholder}
role="textbox"
aria-multiline="true"
onKeyDown={handleKeyDown}
/>
<button
type="button"
className={styles.emojiButton}
aria-label="Insert emoji"
// Emoji picker integration for the inline editor
// is a follow-up — for v1 the button is decorative
// so the surface visually matches fluxer. Wiring it
// would reuse the EmojiPicker popover pattern from
// ChannelTextarea.
onClick={(e) => e.preventDefault()}
>
<Smiley size={18} weight="fill" />
</button>
</div>
{error ? (
<div className={styles.error}>{error}</div>
) : (
<div className={styles.hint}>
<span>escape to</span>
<strong onClick={onCancel}>cancel</strong>
<span className={styles.hintSeparator} />
<span>enter to</span>
<strong onClick={() => void handleSave()}>save</strong>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,203 @@
/* ── Link Embed — matches Fluxer's embed card ────────────────────────── */
.embed {
position: relative;
display: inline-grid;
inline-size: fit-content;
max-inline-size: 100%;
max-width: 432px;
box-sizing: border-box;
border-radius: 8px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-accent);
border-left: 4px solid var(--brand-primary);
margin-top: 4px;
}
.grid {
overflow: hidden;
padding: 12px 12px 14px 12px;
display: grid;
grid-template-columns: auto;
grid-template-rows: auto;
}
.embedContent {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.embedContent > *:first-child {
margin-top: 4px;
}
/* Provider (e.g. "YouTube", "GitHub") */
.provider {
font-size: 0.75rem;
line-height: 1rem;
font-weight: 500;
color: var(--text-tertiary);
}
/* Author row */
.author {
display: flex;
align-items: center;
min-width: 0;
}
.authorIcon {
flex-shrink: 0;
margin-right: 8px;
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 50%;
}
.authorName {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Title */
.title {
font-size: 1rem;
font-weight: 600;
display: inline-block;
color: var(--text-link);
text-decoration: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
text-decoration: underline;
}
/* Description */
.description {
font-size: 0.875rem;
line-height: 1.125rem;
white-space: pre-line;
color: var(--text-primary);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
/* ── Media (image / video thumbnail) ─────────────────────────────────── */
.mediaContainer {
position: relative;
border-radius: 4px;
overflow: hidden;
margin-top: 4px;
contain: paint;
cursor: pointer;
}
.mediaImage {
display: block;
max-width: 100%;
max-height: 300px;
width: 100%;
border-radius: 4px;
object-fit: cover;
}
/* Dark overlay for video thumbnails */
.mediaOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.2s ease;
text-decoration: none;
}
.mediaContainer:hover .mediaOverlay {
opacity: 1;
}
.mediaControls {
display: flex;
align-items: center;
gap: 0.75rem;
}
.playButton,
.openButton {
display: flex;
height: 3.5rem;
width: 3.5rem;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(0, 0, 0, 0.75);
cursor: pointer;
transition: background 200ms ease;
color: #fff;
border: none;
padding: 0;
}
.playButton:hover,
.openButton:hover {
background: rgba(0, 0, 0, 0.85);
}
.openButton {
height: 2.75rem;
width: 2.75rem;
}
.directVideoWrapper {
position: relative;
display: inline-block;
}
.directVideo {
max-width: 400px;
max-height: 300px;
border-radius: 8px;
display: block;
}
.directPlayOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.35);
border: none;
border-radius: 8px;
cursor: pointer;
color: #fff;
transition: background 0.15s;
}
.directPlayOverlay:hover {
background: rgba(0, 0, 0, 0.5);
}
.directImage {
max-width: 400px;
max-height: 300px;
border-radius: 8px;
display: block;
object-fit: contain;
}

View File

@@ -0,0 +1,265 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowSquareOut, Play } from '@phosphor-icons/react';
import { useAction } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import styles from './LinkEmbed.module.css';
interface UrlPreview {
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
}
const VIDEO_HOSTS = [
'youtube.com',
'youtu.be',
'www.youtube.com',
'vimeo.com',
'www.vimeo.com',
'twitch.tv',
'www.twitch.tv',
'dailymotion.com',
'www.dailymotion.com',
];
const DIRECT_VIDEO_EXTS = ['.mp4', '.webm', '.ogg', '.mov'];
const DIRECT_IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
function isDirectMedia(url: string): 'video' | 'image' | null {
try {
const pathname = new URL(url).pathname.toLowerCase();
if (DIRECT_VIDEO_EXTS.some((ext) => pathname.endsWith(ext))) return 'video';
if (DIRECT_IMAGE_EXTS.some((ext) => pathname.endsWith(ext))) return 'image';
} catch {}
return null;
}
function isVideoUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname;
return VIDEO_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h));
} catch {
return false;
}
}
// Module-scope cache so we don't refetch the same URL every re-render.
// `null` means "we tried and there's no preview" — we cache that too to
// avoid hammering the fetcher for URLs that will never resolve.
const previewCache = new Map<string, UrlPreview | null>();
function normaliseMetadata(raw: any): UrlPreview | null {
if (!raw || typeof raw !== 'object') return null;
// Accept a few possible shapes: the Electron preload IPC returns
// `{ title, description, image, siteName, url }`; matrix-js-sdk style
// returns `{ 'og:title', 'og:description', 'og:image', 'og:site_name' }`.
const title =
raw.title ?? raw['og:title'] ?? raw.ogTitle ?? undefined;
const description =
raw.description ?? raw['og:description'] ?? raw.ogDescription ?? undefined;
const imageUrl =
raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined;
const siteName =
raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined;
if (!title && !description && !imageUrl) return null;
return { title, description, imageUrl, siteName };
}
function useUrlPreview(url: string): UrlPreview | null {
const platform = usePlatform();
const fetchPreviewAction = useAction(api.links.fetchPreview);
const [preview, setPreview] = useState<UrlPreview | null>(
previewCache.get(url) ?? null,
);
useEffect(() => {
if (previewCache.has(url)) {
setPreview(previewCache.get(url) ?? null);
return;
}
let cancelled = false;
(async () => {
try {
// Prefer the platform-native fetcher when available (Electron
// ships one via IPC — no CORS, no round-trip through the
// backend). On web, `fetchMetadata` returns null due to CORS,
// so we fall through to the Convex Node action which performs
// the fetch server-side.
let result: UrlPreview | null = null;
const fetcher = platform?.links?.fetchMetadata;
if (typeof fetcher === 'function') {
try {
const raw = await fetcher(url);
result = normaliseMetadata(raw);
} catch {
result = null;
}
}
if (!result) {
try {
const raw = await fetchPreviewAction({ url });
if (!cancelled) result = normaliseMetadata(raw);
} catch {
result = null;
}
}
if (cancelled) return;
previewCache.set(url, result);
setPreview(result);
} catch {
if (!cancelled) previewCache.set(url, null);
}
})();
return () => {
cancelled = true;
};
}, [url, platform, fetchPreviewAction]);
return preview;
}
function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image' }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
if (type === 'video') {
const handlePlay = () => {
if (!videoRef.current) return;
videoRef.current.controls = true;
void videoRef.current.play();
setPlaying(true);
};
return (
<div className={styles.embed}>
<div className={styles.directVideoWrapper}>
<video
ref={videoRef}
className={styles.directVideo}
src={url}
preload="metadata"
onPause={() => {
if (videoRef.current && videoRef.current.ended) {
videoRef.current.controls = false;
setPlaying(false);
}
}}
onEnded={() => {
if (videoRef.current) {
videoRef.current.controls = false;
setPlaying(false);
}
}}
/>
{!playing && (
<button
type="button"
className={styles.directPlayOverlay}
onClick={handlePlay}
>
<Play size={36} weight="fill" />
</button>
)}
</div>
</div>
);
}
return (
<div className={styles.embed}>
<a href={url} target="_blank" rel="noopener noreferrer">
<img
className={styles.directImage}
src={url}
alt=""
loading="lazy"
/>
</a>
</div>
);
}
interface LinkEmbedProps {
url: string;
}
export function LinkEmbed({ url }: LinkEmbedProps) {
const directType = isDirectMedia(url);
if (directType) {
return <DirectMediaEmbed url={url} type={directType} />;
}
const preview = useUrlPreview(url);
// If the platform has no metadata fetcher (or it returned null / threw),
// degrade gracefully to nothing — the raw link is already rendered in
// the message body by MessageContent.
if (!preview) return null;
const isVideo = isVideoUrl(url);
const hasImage = !!preview.imageUrl;
return (
<div className={styles.embed}>
<div className={styles.grid}>
<div className={styles.embedContent}>
{preview.siteName && (
<div className={styles.provider}>{preview.siteName}</div>
)}
{preview.title && (
<a
className={styles.title}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{preview.title}
</a>
)}
{preview.description && (
<div className={styles.description}>{preview.description}</div>
)}
{hasImage && (
<div className={styles.mediaContainer}>
<img
className={styles.mediaImage}
src={preview.imageUrl}
alt={preview.title || ''}
loading="lazy"
/>
{isVideo && (
<a
className={styles.mediaOverlay}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<div className={styles.mediaControls}>
<button type="button" className={styles.playButton}>
<Play size={28} weight="fill" />
</button>
<button type="button" className={styles.openButton}>
<ArrowSquareOut size={22} />
</button>
</div>
</a>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default LinkEmbed;

View File

@@ -0,0 +1,104 @@
/* Mention autocomplete popup — matches Fluxer's Autocomplete look. The
popup floats above the chat input, slightly rounded, with a compact
section header and individual rows for each mentionable user. */
.container {
overflow: hidden;
border-radius: 8px;
background-color: var(--background-primary, #111214);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
max-height: 420px;
}
.header {
flex: 0 0 auto;
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-primary-muted, #a0a3a8);
user-select: none;
}
.scroller {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 8px 8px;
overflow-y: auto;
min-height: 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border: none;
background-color: transparent;
border-radius: 6px;
text-align: left;
cursor: pointer;
color: var(--text-primary, #fff);
font: inherit;
min-height: 32px;
}
.rowActive,
.row:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
}
.icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.everyoneIcon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--brand-primary, #5865f2);
color: #fff;
}
.nameWrapper {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
}
.name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
font-size: 14px;
color: var(--text-primary, #fff);
line-height: 1.25;
}
.description {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-weight: 400;
color: var(--text-primary-muted, #a0a3a8);
font-size: 12px;
line-height: 1.33;
max-width: 50%;
}

View File

@@ -0,0 +1,257 @@
/**
* Mention autocomplete popup — opens above the chat input when the user
* types `@`. Renders the server members for the current channel, with
* arrow-key navigation and Enter/click to insert.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from 'convex/react';
import { Avatar } from '@discord-clone/ui';
import { Megaphone } from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import styles from './MentionAutocomplete.module.css';
export type MentionItem =
| {
kind: 'user';
userId: string;
displayName: string;
username: string;
avatar?: string;
}
| {
kind: 'everyone';
};
export interface MentionAutocompleteHandle {
/** Move the keyboard selection. Returns true if the popup consumed the key. */
moveSelection: (delta: number) => boolean;
/** Commit the currently highlighted item. Returns true if something was selected. */
commit: () => boolean;
}
interface Props {
channelId: string;
query: string;
anchorEl: HTMLElement | null;
onSelect: (item: MentionItem) => void;
onClose: () => void;
}
const MAX_RESULTS = 10;
/**
* Rank a candidate against the query. Lower score = better.
* -2: startsWith match (best)
* -1: word-boundary match
* 0: substring match anywhere
* null: no match
*/
function scoreMatch(candidate: string, query: string): number | null {
if (!query) return 0;
const c = candidate.toLowerCase();
const q = query.toLowerCase();
if (c.startsWith(q)) return -2;
const words = c.split(/[\s_\-.:/]+/);
for (const w of words) {
if (w !== c && w.startsWith(q)) return -1;
}
return c.includes(q) ? 0 : null;
}
interface ConvexMember {
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
}
export const MentionAutocomplete = forwardRef<MentionAutocompleteHandle, Props>(
function MentionAutocomplete({ channelId, query, anchorEl, onSelect, onClose }, ref) {
const [selected, setSelected] = useState(0);
const [pos, setPos] = useState<{ left: number; bottom: number; width: number } | null>(
null,
);
const containerRef = useRef<HTMLDivElement>(null);
const membersRaw = useQuery(
api.members.getChannelMembers,
channelId ? { channelId: channelId as any } : 'skip',
) as ConvexMember[] | undefined;
const items = useMemo<MentionItem[]>(() => {
const result: MentionItem[] = [];
if (scoreMatch('everyone', query) !== null) {
result.push({ kind: 'everyone' });
}
if (membersRaw) {
type Scored = { item: MentionItem; score: number; name: string };
const scored: Scored[] = [];
for (const m of membersRaw) {
const name = m.displayName || m.username;
const s1 = scoreMatch(name, query);
const s2 = scoreMatch(m.username, query);
const best =
s1 === null && s2 === null
? null
: Math.min(s1 ?? Infinity, s2 ?? Infinity);
if (best === null) continue;
scored.push({
item: {
kind: 'user',
userId: m.id,
displayName: name,
username: m.username,
avatar: m.avatarUrl ?? undefined,
},
score: best,
name,
});
}
scored.sort((a, b) => {
if (a.score !== b.score) return a.score - b.score;
return a.name.localeCompare(b.name);
});
result.push(...scored.slice(0, MAX_RESULTS).map((s) => s.item));
}
return result;
}, [membersRaw, query]);
// Clamp selection when the list changes.
useEffect(() => {
setSelected((prev) => {
if (items.length === 0) return 0;
if (prev >= items.length) return items.length - 1;
return prev;
});
}, [items]);
// Close if the filter yielded nothing — but only after the query
// has had a chance to resolve. While the query is still loading
// (membersRaw === undefined) we keep the popup open so the user
// doesn't see it flicker.
useEffect(() => {
if (membersRaw !== undefined && items.length === 0) {
onClose();
}
}, [membersRaw, items.length, onClose]);
// Position above the anchor (the textarea).
useLayoutEffect(() => {
if (!anchorEl) return;
const update = () => {
const rect = anchorEl.getBoundingClientRect();
setPos({
left: rect.left,
bottom: window.innerHeight - rect.top + 8,
width: Math.max(280, Math.min(rect.width, 520)),
});
};
update();
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [anchorEl]);
// Keep the highlighted row in view.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const el = container.querySelector<HTMLElement>(`[data-index="${selected}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [selected]);
useImperativeHandle(
ref,
() => ({
moveSelection: (delta) => {
if (items.length === 0) return false;
setSelected((prev) => {
const next = (prev + delta + items.length) % items.length;
return next;
});
return true;
},
commit: () => {
const item = items[selected];
if (!item) return false;
onSelect(item);
return true;
},
}),
[items, selected, onSelect],
);
if (!pos || items.length === 0) return null;
return createPortal(
<div
ref={containerRef}
className={styles.container}
style={{
position: 'fixed',
left: pos.left,
bottom: pos.bottom,
width: pos.width,
zIndex: 15000,
}}
// Prevent the contenteditable from losing focus when users
// click into the popup.
onMouseDown={(e) => e.preventDefault()}
>
<div className={styles.header}>MEMBERS</div>
<div className={styles.scroller}>
{items.map((item, index) => {
const isActive = index === selected;
return (
<button
key={item.kind === 'everyone' ? 'everyone' : item.userId}
type="button"
data-index={index}
className={`${styles.row} ${isActive ? styles.rowActive : ''}`}
onMouseEnter={() => setSelected(index)}
onClick={() => onSelect(item)}
>
<div className={styles.icon}>
{item.kind === 'everyone' ? (
<div className={styles.everyoneIcon}>
<Megaphone size={16} weight="fill" />
</div>
) : (
<Avatar
src={item.avatar}
fallback={item.displayName}
size={24}
/>
)}
</div>
<div className={styles.nameWrapper}>
<div className={styles.name}>
{item.kind === 'everyone' ? '@everyone' : item.displayName}
</div>
</div>
<div className={styles.description}>
{item.kind === 'everyone'
? 'Notify everyone in this channel'
: item.username}
</div>
</button>
);
})}
</div>
</div>,
document.body,
);
},
);

View File

@@ -0,0 +1,112 @@
/* Message action bar - appears on hover */
.container {
position: absolute;
top: -16px;
right: 16px;
display: none;
align-items: center;
padding: 2px;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 8px;
z-index: 1;
}
/* Show on hover from parent message */
:global(.messageHoverable):hover > .container,
:global(.actionBarForceVisible) > .container {
display: flex;
}
/* Mobile: never show the desktop hover action bar. Mobile users get
the long-press bottom sheet (MobileMessageActionsSheet) instead —
the hover bar would be both useless (no hover) and visually noisy
when a tap accidentally triggers the :hover state on iOS. */
@media (max-width: 768px) {
:global(.messageHoverable):hover > .container,
:global(.actionBarForceVisible) > .container {
display: none;
}
}
.button {
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 4px;
border-radius: 6px;
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
transition: background-color 0.1s, color 0.1s;
}
.button:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.quickEmoji {
width: 20px;
height: 20px;
display: block;
}
.divider {
width: 1px;
height: 20px;
margin: 0 2px;
background-color: var(--background-modifier-accent);
}
/* ── More dropdown menu ───────────────────────────────────────── */
.moreMenu {
min-width: 188px;
padding: 6px 8px;
background-color: var(--background-floating, var(--background-primary));
border-radius: 8px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.24);
}
.menuItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border-radius: 4px;
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
text-align: left;
transition: background-color 0.1s, color 0.1s;
}
.menuItem:hover {
background-color: var(--brand-primary);
color: #fff;
}
.menuItemDanger {
color: var(--status-danger);
}
.menuItemDanger:hover {
background-color: var(--status-danger);
color: #fff;
}
.menuDivider {
height: 1px;
margin: 4px 8px;
background-color: var(--background-modifier-accent);
}

View File

@@ -0,0 +1,326 @@
import {
ArrowBendUpLeft,
ArrowBendUpRight,
Copy,
DotsThree,
Link,
PencilSimple,
PushPin,
Smiley,
Trash,
} from '@phosphor-icons/react';
import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent } from 'react';
import { createPortal } from 'react-dom';
import { getTwemojiUrl } from '../../utils/twemoji';
import styles from './MessageActionBar.module.css';
// Default quick-reaction emojis shown as the first three icons on the
// hover bar. Matches the new UI's set.
const QUICK_EMOJIS: Array<{ emoji: string; name: string }> = [
{ emoji: '😄', name: 'smile' },
{ emoji: '👍', name: 'thumbsup' },
{ emoji: '👌', name: 'ok_hand' },
];
interface MessageActionBarProps {
isOwnMessage: boolean;
onReply?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onReact?: (e?: MouseEvent<HTMLButtonElement>) => void;
onQuickReact?: (emoji: string) => void;
onPin?: () => void;
onCopyText?: () => void;
onCopyLink?: () => void;
onForward?: () => void;
/**
* External trigger for opening the More dropdown (e.g. right-click
* on a message). When set, the dropdown opens at `{ x, y }` and the
* hover action bar stays visible via the `.actionBarForceVisible`
* class on the parent. Pass null to close.
*/
externalMenuAt?: { x: number; y: number } | null;
onExternalMenuClose?: () => void;
/**
* Fires when the local More menu opens / closes so the parent
* (MessageGroup) can keep the hover bar pinned to visible while
* the dropdown is on screen. Without this the bar disappears as
* soon as the pointer leaves the message row.
*/
onMenuOpenChange?: (isOpen: boolean) => void;
}
/**
* Hover quick-action bar + right-click context menu.
*
* Appears via `:global(.messageHoverable):hover > .container` — must be
* rendered as a direct child of an element with the `messageHoverable`
* class.
*
* The "More" button opens a portal-rendered dropdown with additional
* actions (edit/delete/pin/copy/copy link).
*/
export function MessageActionBar({
isOwnMessage,
onReply,
onEdit,
onDelete,
onReact,
onQuickReact,
onPin,
onCopyText,
onCopyLink,
onForward,
externalMenuAt,
onExternalMenuClose,
onMenuOpenChange,
}: MessageActionBarProps) {
const moreButtonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
// Adjusted position after we measure the menu's actual size and
// clamp it to the viewport. Falls back to the requested coords
// while the measurement is in flight (one frame at most).
const [adjustedPos, setAdjustedPos] = useState<
{ top: number; left: number } | null
>(null);
// Merge external right-click menu state with local more-button state.
const effectiveMenuAt = externalMenuAt ?? menuAt;
// Notify the parent whenever the menu open state flips, so the
// message row can pin the hover bar visible while the dropdown is
// up. Triggered for both local and external menus.
useEffect(() => {
onMenuOpenChange?.(!!effectiveMenuAt);
}, [effectiveMenuAt, onMenuOpenChange]);
useEffect(() => {
if (!effectiveMenuAt) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeMenu();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [effectiveMenuAt]);
// Clamp the menu inside the viewport. When the requested top/left
// would push the menu off-screen, flip vertically (open upwards)
// or shift horizontally so it stays fully visible.
useLayoutEffect(() => {
if (!effectiveMenuAt) {
setAdjustedPos(null);
return;
}
const measure = () => {
const el = menuRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const margin = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = effectiveMenuAt.y;
let left = effectiveMenuAt.x - 180;
// Vertical: flip upwards if the menu would overflow the
// bottom of the viewport. Use the menu's measured height
// so the flip lines its bottom up with the requested y.
if (top + rect.height + margin > vh) {
top = Math.max(margin, effectiveMenuAt.y - rect.height);
}
top = Math.max(margin, Math.min(top, vh - rect.height - margin));
// Horizontal: shift left if it would overflow the right
// edge, then clamp at the left margin.
if (left + rect.width + margin > vw) {
left = vw - rect.width - margin;
}
left = Math.max(margin, left);
setAdjustedPos({ top, left });
};
// One frame to let the menu mount, one fallback in case
// requestAnimationFrame fires before layout settles.
const raf = requestAnimationFrame(measure);
return () => cancelAnimationFrame(raf);
}, [effectiveMenuAt]);
const closeMenu = () => {
setMenuAt(null);
onExternalMenuClose?.();
};
const openMoreMenu = () => {
const rect = moreButtonRef.current?.getBoundingClientRect();
if (!rect) return;
setMenuAt({ x: rect.right, y: rect.bottom + 4 });
};
const runAndClose = (handler?: () => void) => () => {
handler?.();
closeMenu();
};
return (
<div className={styles.container}>
{onQuickReact &&
QUICK_EMOJIS.map((qe) => (
<button
key={qe.name}
type="button"
className={styles.button}
onClick={() => onQuickReact(qe.emoji)}
aria-label={`React with :${qe.name}:`}
title={`:${qe.name}:`}
>
<img
src={getTwemojiUrl(qe.emoji)}
alt={qe.emoji}
className={styles.quickEmoji}
draggable={false}
/>
</button>
))}
<button
type="button"
className={styles.button}
onClick={(e) => onReact?.(e)}
aria-label="Add reaction"
title="Add Reaction"
>
<Smiley size={20} />
</button>
<button
type="button"
className={styles.button}
onClick={onReply}
aria-label="Reply"
title="Reply"
>
<ArrowBendUpLeft size={20} />
</button>
{isOwnMessage && onEdit && (
<button
type="button"
className={styles.button}
onClick={onEdit}
aria-label="Edit"
title="Edit"
>
<PencilSimple size={20} />
</button>
)}
<button
type="button"
ref={moreButtonRef}
className={styles.button}
onClick={openMoreMenu}
aria-label="More"
title="More"
>
<DotsThree size={20} weight="bold" />
</button>
{effectiveMenuAt &&
createPortal(
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 14999 }}
onClick={closeMenu}
onContextMenu={(e) => {
e.preventDefault();
closeMenu();
}}
/>
<div
ref={menuRef}
className={styles.moreMenu}
style={{
position: 'fixed',
top: adjustedPos?.top ?? effectiveMenuAt.y,
left: adjustedPos?.left ?? effectiveMenuAt.x - 180,
// Hide the menu for the first frame while we
// measure it; revealing it after the clamp
// avoids a flash at the wrong position.
visibility: adjustedPos ? 'visible' : 'hidden',
zIndex: 15000,
}}
onClick={(e) => e.stopPropagation()}
>
{onReply && (
<button
type="button"
className={styles.menuItem}
onClick={runAndClose(onReply)}
>
<ArrowBendUpLeft size={16} />
<span>Reply</span>
</button>
)}
{onForward && (
<button
type="button"
className={styles.menuItem}
onClick={runAndClose(onForward)}
>
<ArrowBendUpRight size={16} />
<span>Forward</span>
</button>
)}
{onPin && (
<button
type="button"
className={styles.menuItem}
onClick={runAndClose(onPin)}
>
<PushPin size={16} />
<span>Pin Message</span>
</button>
)}
{onCopyText && (
<button
type="button"
className={styles.menuItem}
onClick={runAndClose(onCopyText)}
>
<Copy size={16} />
<span>Copy Text</span>
</button>
)}
{onCopyLink && (
<button
type="button"
className={styles.menuItem}
onClick={runAndClose(onCopyLink)}
>
<Link size={16} />
<span>Copy Link</span>
</button>
)}
{isOwnMessage && onEdit && (
<button
type="button"
className={styles.menuItem}
onClick={runAndClose(onEdit)}
>
<PencilSimple size={16} />
<span>Edit Message</span>
</button>
)}
{isOwnMessage && onDelete && (
<>
<div className={styles.menuDivider} />
<button
type="button"
className={`${styles.menuItem} ${styles.menuItemDanger}`}
onClick={runAndClose(onDelete)}
>
<Trash size={16} />
<span>Delete Message</span>
</button>
</>
)}
</div>
</>,
document.body,
)}
</div>
);
}

View File

@@ -0,0 +1,163 @@
.content {
word-wrap: break-word;
white-space: pre-wrap;
}
.emoji {
width: 1.375em;
height: 1.375em;
vertical-align: -0.3em;
display: inline;
object-fit: contain;
}
.emojiOnly .emoji {
width: 3rem;
height: 3rem;
vertical-align: -0.5em;
}
/* MSC2545 custom emoji — inline image from a server's image pack.
Sized to the line-height so it sits cleanly next to text. Animated
GIF / WebP play automatically because they're just <img> elements. */
.customEmoji {
display: inline-block;
height: 1.4em;
width: auto;
max-width: 3em;
vertical-align: -0.35em;
object-fit: contain;
}
.emojiOnly .customEmoji {
height: 3rem;
max-width: 4rem;
vertical-align: -0.5em;
}
/* Inline formatting */
.bold {
font-weight: 700;
}
.italic {
font-style: italic;
}
.strikethrough {
text-decoration: line-through;
}
.inlineCode {
padding: 0.1em 0.3em;
margin: 0 0.1em;
border-radius: var(--radius-sm);
background-color: var(--background-secondary);
font-family: var(--font-mono);
font-size: 0.85em;
line-height: 1.125rem;
color: var(--text-primary);
white-space: pre-wrap;
}
/* Code blocks */
.codeBlock {
margin: 4px 0;
border-radius: var(--radius-lg);
background-color: var(--background-secondary);
border: 1px solid var(--background-tertiary);
overflow: hidden;
white-space: pre;
}
.codeBlockHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background-color: var(--background-tertiary);
font-size: var(--font-size-xs);
color: var(--text-secondary);
font-family: var(--font-sans);
font-weight: 600;
text-transform: lowercase;
}
.codeBlockBody {
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 0.875rem;
line-height: 1.125rem;
color: var(--text-primary);
overflow-x: auto;
white-space: pre;
}
/* Block quotes */
.blockquote {
display: flex;
margin: 2px 0;
}
.blockquoteBorder {
flex-shrink: 0;
width: 4px;
border-radius: var(--radius-full);
background-color: var(--interactive-muted);
margin-right: 12px;
}
.blockquoteContent {
color: var(--text-primary);
white-space: pre-wrap;
}
/* Links */
.link {
color: var(--text-link);
text-decoration: none;
cursor: pointer;
}
.link:hover {
text-decoration: underline;
}
/* Spoilers */
.spoiler {
background-color: var(--background-accent);
border-radius: var(--radius-sm);
padding: 0 2px;
cursor: pointer;
transition: background-color 0.1s ease;
}
.spoilerHidden {
background-color: var(--background-accent);
color: transparent;
}
.spoilerHidden:hover {
background-color: var(--background-modifier-accent);
}
.spoilerRevealed {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
/* Mentions */
.mention {
padding: 0 2px;
border-radius: var(--radius-sm);
background-color: rgba(88, 101, 242, 0.3);
color: var(--brand-primary);
font-weight: 500;
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
}
.mention:hover {
background-color: var(--brand-primary);
color: #fff;
}

View File

@@ -0,0 +1,213 @@
import type { ReactNode } from 'react';
import { getTwemojiUrl } from '../../utils/twemoji';
import styles from './MessageContent.module.css';
interface MentionMember {
displayName: string;
username: string;
userId: string;
}
export interface CustomEmojiEntry {
name: string;
url: string;
}
interface MessageContentProps {
content: string;
members?: MentionMember[];
customEmojis?: CustomEmojiEntry[];
}
const EMOJI_REGEX =
/(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)(?:\u200D(?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F))*|\p{Regional_Indicator}{2}/gu;
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, '\\$&');
}
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,
members: MentionMember[],
customEmojiMap: Map<string, string>,
): 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;
best = {
type: 'url',
index: urlMatch.index,
length: matchText.length,
text: matchText,
url: matchText,
};
}
// Emoji — next match at or after `from`.
EMOJI_REGEX.lastIndex = from;
const emojiMatch = EMOJI_REGEX.exec(text);
if (emojiMatch && (!best || emojiMatch.index < best.index)) {
best = {
type: 'emoji',
index: emojiMatch.index,
length: emojiMatch[0].length,
text: emojiMatch[0],
};
}
// 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;
while ((m = CUSTOM_EMOJI_REGEX.exec(text)) !== null) {
const name = m[1].toLowerCase();
const url = customEmojiMap.get(name);
if (url) {
if (!best || m.index < best.index) {
best = {
type: 'customEmoji',
index: m.index,
length: m[0].length,
text: name,
url,
};
}
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' };
}
// @{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),
);
for (const m of sorted) {
const name = m.displayName || m.username;
if (!name) continue;
const needle = `@${name}`;
const idx = text.indexOf(needle, from);
if (idx !== -1 && (!best || idx < best.index)) {
best = { type: 'mention', index: idx, length: needle.length, text: needle };
}
}
// Generic @word fallback — single run of word chars after an @.
const genericRe = /@[\w]+/g;
genericRe.lastIndex = from;
const gm = genericRe.exec(text);
if (gm && (!best || gm.index < best.index)) {
best = { type: 'mention', index: gm.index, length: gm[0].length, text: gm[0] };
}
return best;
}
function renderContent(
text: string,
members: MentionMember[],
customEmojiMap: Map<string, string>,
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);
if (!tok) {
parts.push(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor)}</span>);
break;
}
if (tok.index > cursor) {
parts.push(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor, tok.index)}</span>);
}
if (tok.type === 'emoji') {
parts.push(
<img
key={`${keyPrefix}e${tok.index}`}
src={getTwemojiUrl(tok.text)}
alt={tok.text}
className={styles.emoji}
draggable={false}
/>,
);
} else if (tok.type === 'customEmoji') {
parts.push(
<img
key={`${keyPrefix}c${tok.index}`}
src={tok.url}
alt={`:${tok.text}:`}
title={`:${tok.text}:`}
className={`${styles.customEmoji} ${styles.emoji}`}
draggable={false}
/>,
);
} else if (tok.type === 'url') {
parts.push(
<a
key={`${keyPrefix}u${tok.index}`}
className={styles.link}
href={tok.url}
target="_blank"
rel="noopener noreferrer"
>
{tok.text}
</a>,
);
} else {
parts.push(
<span key={`${keyPrefix}m${tok.index}`} className={styles.mention}>
{tok.text}
</span>,
);
}
cursor = tok.index + tok.length;
}
if (parts.length === 0) {
parts.push(<span key={`${keyPrefix}t0`}>{text}</span>);
}
// Silence unused escapeRegex in case linter complains.
void escapeRegex;
return parts;
}
export function MessageContent({
content,
members = [],
customEmojis = [],
}: 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')}</>;
}

View File

@@ -0,0 +1,374 @@
/* Message group layout */
.group {
padding: 0;
margin-top: 1.0625rem;
position: relative;
user-select: text;
-webkit-user-select: text;
word-break: break-word;
}
.contentColumn {
min-width: 0;
display: flex;
flex-direction: column;
}
/* Avatar positioned in the gutter of the first message */
.avatarSlot {
position: absolute;
left: 16px;
cursor: pointer;
}
/* --- Reply context (above message) --- */
.replyContext {
display: flex;
align-items: center;
gap: 4px;
position: relative;
margin-bottom: 4px;
margin-top: 2px;
font-size: 0.875rem;
line-height: 1.125rem;
cursor: pointer;
min-width: 0;
overflow: visible;
}
.replyContext:hover .replyText {
color: var(--text-primary);
}
/* L-shaped spine: vertical arm goes down to above the avatar, horizontal arm goes right to reply content */
.replySpine {
position: absolute;
left: -36px;
top: 50%;
bottom: 0;
width: 33px;
border-left: 2px solid var(--text-muted);
border-top: 2px solid var(--text-muted);
border-top-left-radius: 6px;
box-sizing: border-box;
pointer-events: none;
}
.replyAuthor {
font-weight: 600;
font-size: 0.8125rem;
color: var(--text-primary);
white-space: nowrap;
flex-shrink: 0;
}
.replyText {
color: var(--text-muted);
font-size: 0.8125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.replyContentMissing {
color: var(--text-muted);
font-size: 0.8125rem;
font-style: italic;
padding-left: 4px;
}
/* --- Header --- */
.header {
display: flex;
align-items: baseline;
gap: 8px;
min-height: 1.375rem;
line-height: 1.375rem;
}
.username {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
cursor: pointer;
}
.username:hover {
text-decoration: underline;
}
.timestamp {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 400;
}
/* --- Individual message content --- */
.messageContent {
position: relative;
padding-left: 72px;
padding-right: 48px;
}
.messageContent:hover {
background-color: var(--background-modifier-hover);
}
/* Mentioned-row highlight — copies Fluxer's exact golden tint. A 2px
::before bar sits on the inline-start edge; the row gets a low-alpha
gold background, nudged slightly on hover. Fires when the current
user is in `m.mentions.user_ids` or when `m.mentions.room` is set. */
.messageMentioned::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 0;
width: 2px;
background-color: rgb(234 197 50);
pointer-events: none;
}
.messageMentioned {
background-color: rgb(234 197 50 / 0.1);
}
.messageMentioned:hover {
background-color: rgb(234 197 50 / 0.14);
}
/* First message includes the avatar + header row */
.firstMessage {
padding-top: 2px;
}
/* Compact timestamp: sits in the avatar gutter, visible on hover */
.compactTimestamp {
position: absolute;
left: 0;
width: 72px;
text-align: center;
top: 0;
font-size: 0.6875rem;
line-height: 1.375rem;
color: var(--text-muted);
opacity: 0;
pointer-events: none;
}
.messageContent:hover .compactTimestamp {
opacity: 1;
}
/* --- Message text --- */
.text {
font-size: 1rem;
line-height: var(--message-line-height, 1.375rem);
color: var(--text-primary);
word-wrap: break-word;
white-space: pre-wrap;
}
/* "(edited)" tag shown inline next to edited messages. Matches
the fluxer treatment — small, muted, one-space offset. */
.editedTag {
margin-left: 0.25rem;
font-size: 0.625rem;
color: var(--text-tertiary);
user-select: none;
vertical-align: baseline;
}
/* --- Encrypted placeholder --- */
.encryptedPlaceholder {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
line-height: var(--message-line-height, 1.375rem);
color: var(--text-muted);
font-style: italic;
}
/* Tombstone for a redacted message — rendered by MessageGroup
when `message.isDeleted` is true. Mirrors the encrypted
placeholder's visual language (muted italic + leading icon)
so the two states feel like siblings. */
.deletedPlaceholder {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
line-height: var(--message-line-height, 1.375rem);
color: var(--text-muted);
font-style: italic;
}
/* --- Attachments --- */
.attachments {
margin-top: 4px;
}
.imageAttachment {
border-radius: var(--radius-lg);
cursor: pointer;
display: block;
/* Belt-and-braces: the inline `maxWidth` style caps the image
dimensionally (so we don't upscale tiny images), but on narrow
viewports a 400px image would still overflow the parent's
horizontal padding and bleed into the screen edge. `max-width: 100%`
keeps it inside the message's content box regardless of the
absolute pixel cap. `height: auto` preserves aspect ratio when
width is the constraint. */
max-width: 100%;
height: auto;
}
/* ── Spoiler attachments ─────────────────────────────────────
Wrapper lets the "SPOILER" cover button overlay the image
via absolute positioning without reflowing the message.
The image itself gets a blur filter while covered so even
partial glimpses don't give away the content. */
.spoilerableWrap {
position: relative;
display: inline-block;
max-width: 100%;
}
.imageAttachmentBlurred {
filter: blur(44px);
transform: scale(1);
/* Prevent blurred edges from bleeding outside the rounded
corners of the wrapper. */
clip-path: inset(0 round var(--radius-lg));
}
.spoilerCover {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.45);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition: background-color 0.12s;
padding: 0;
}
.spoilerCover:hover {
background-color: rgba(0, 0, 0, 0.6);
}
.spoilerCoverLabel {
padding: 8px 16px;
background-color: rgba(0, 0, 0, 0.75);
border-radius: 999px;
color: #fff;
font-size: 0.8125rem;
font-weight: 800;
letter-spacing: 0.08em;
pointer-events: none;
}
.fileAttachment {
display: inline-flex;
align-items: center;
padding: 8px 12px;
background-color: var(--background-secondary);
border-radius: var(--radius-lg);
color: var(--text-link);
font-size: 0.875rem;
text-decoration: none;
margin-top: 4px;
}
.fileAttachment:hover {
text-decoration: underline;
}
/* --- Reactions --- */
.reactions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.reactionChip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-lg);
background-color: var(--background-secondary);
border: 1px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary);
transition: background-color 0.15s;
}
.reactionChip:hover {
background-color: var(--background-modifier-hover);
}
.reactionMe {
border-color: var(--brand-primary);
background-color: rgba(88, 101, 242, 0.15);
}
.reactionCount {
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: 500;
}
/* Custom MSC2545 emoji reaction — rendered as an inline image in the
chip. Matched to the Twemoji size used for unicode reactions so
both styles of chip look the same height. Animated GIF / WebP
emojis play automatically because it's a normal <img>. */
.reactionCustomEmoji {
width: 16px;
height: 16px;
object-fit: contain;
display: inline-block;
vertical-align: middle;
}
/* ── Hover gutter timestamp ──────────────────────────────────────
Follow-up messages in a sender group don't have an avatar / name
header — the timestamp lives in the left gutter (where the avatar
would be) and only fades in on hover, matching the new UI. */
.gutterTimestamp {
position: absolute;
top: 4px;
left: 16px;
width: 56px;
font-size: 11px;
color: var(--text-tertiary);
text-align: right;
padding-right: 8px;
box-sizing: border-box;
opacity: 0;
transition: opacity 0.1s ease;
pointer-events: none;
user-select: none;
font-variant-numeric: tabular-nums;
}
.messageContent:hover .gutterTimestamp,
.messageContent.actionBarForceVisible .gutterTimestamp,
:global(.messageHoverable):hover .gutterTimestamp {
opacity: 1;
}

View File

@@ -0,0 +1,735 @@
import { useMutation, useQuery } from 'convex/react';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { useIsMobile } from '../../hooks/useIsMobile';
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
import { ImageLightbox } from './ImageLightbox';
import { LinkEmbed } from './LinkEmbed';
import type { DecryptedMessage } from './Messages';
import { MessageActionBar } from './MessageActionBar';
import { MessageContent } from './MessageContent';
import { MobileMessageActionsSheet } from './MobileMessageActionsSheet';
import {
MemberProfilePopout,
type MemberProfilePopoutMember,
} from '../member/MemberProfilePopout';
import { PinConfirmationModal } from './PinConfirmationModal';
import { ReactionsModal } from './ReactionsModal';
import { Tooltip } from '@discord-clone/ui';
import { reactionKeyToName } from '../../utils/emojiLookup';
import type { PinnedMessage } from './PinnedMessageRow';
import { TwemojiImg } from './TwemojiImg';
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
import styles from './MessageGroup.module.css';
interface MessageGroupProps {
messages: DecryptedMessage[];
channelId: string;
onReply?: (eventId: string, username: string) => void;
}
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
function extractUrls(text: string): string[] {
const matches = text.match(URL_REGEX) ?? [];
// Strip trailing punctuation that's almost never part of the URL but
// commonly butts up against one in prose ("see https://foo.com.").
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
return Array.from(new Set(cleaned));
}
function formatTime(ts: number): string {
const date = new Date(ts);
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function formatFullTime(ts: number): string {
const date = new Date(ts);
return date.toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps) {
const first = messages[0];
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const channelMembers = useQuery(api.members.getChannelMembers, {
channelId: channelId as any,
});
const mentionMembers = Array.isArray(channelMembers)
? channelMembers.map((m: any) => ({
displayName: m.displayName || m.username || '',
username: m.username || '',
userId: m.id,
}))
: [];
const removeMessage = useMutation(api.messages.remove);
const addReaction = useMutation(api.reactions.add);
const removeReaction = useMutation(api.reactions.remove);
// Custom emoji catalog — powers `:shortcode:` rendering inside
// message bodies and custom-emoji reaction chips. One query per
// group keeps the subscription count reasonable.
const customEmojiDocs = (useQuery(api.customEmojis.list, {}) ?? []) as Array<{
_id: string;
name: string;
src: string;
}>;
const customEmojiList = customEmojiDocs.map((e) => ({ name: e.name, url: e.src }));
const customEmojiByName = new Map<string, string>();
for (const e of customEmojiDocs) customEmojiByName.set(e.name.toLowerCase(), e.src);
// Reaction picker state — a single picker shared across the group.
// Records which message was clicked so the chosen emoji is bound
// to the right target.
const [reactPicker, setReactPicker] = useState<{
messageId: string;
pos: { top: number; left: number };
} | 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.
const [lightboxItem, setLightboxItem] = useState<{
src: string;
attachment: AttachmentMetadata;
} | null>(null);
// Right-click context menu state. When set, the MessageActionBar for
// the target message opens its More dropdown at the click coordinates
// and the parent row gets `actionBarForceVisible` so the hover bar
// stays pinned regardless of cursor position.
const [contextMenu, setContextMenu] = useState<{
messageId: string;
x: number;
y: number;
} | null>(null);
// Tracks which message currently has its local More dropdown open
// so we can pin that row's hover bar visible even when the pointer
// leaves the row. Flips back to null when the dropdown closes.
const [localMenuOpenFor, setLocalMenuOpenFor] = useState<string | null>(
null,
);
const [mobileSheetForMsg, setMobileSheetForMsg] =
useState<DecryptedMessage | null>(null);
// Author profile popout — opens when the user clicks the avatar
// or username in the message header, same component MemberListContainer
// uses so profile cards look identical regardless of entry point.
const [authorPopout, setAuthorPopout] = useState<{
anchorRect: DOMRect;
member: MemberProfilePopoutMember;
} | null>(null);
// Full reactions breakdown modal — opens when the user clicks a
// reaction chip's tooltip or the chip itself. Tracks the target
// message id so the modal can read its latest reaction rows.
const [reactionsModalMsgId, setReactionsModalMsgId] = useState<
string | null
>(null);
const longPressTimerRef = useRef<number | null>(null);
const isMobile = useIsMobile();
const openAuthorPopout = (msg: DecryptedMessage, rect: DOMRect) => {
setAuthorPopout({
anchorRect: rect,
member: {
userId: msg.senderId,
displayName: msg.authorName,
avatarUrl: msg.authorAvatarUrl,
},
});
};
const startLongPress = (msg: DecryptedMessage) => {
if (!isMobile) return;
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
}
longPressTimerRef.current = window.setTimeout(() => {
setMobileSheetForMsg(msg);
}, 450);
};
const cancelLongPress = () => {
if (longPressTimerRef.current !== null) {
window.clearTimeout(longPressTimerRef.current);
longPressTimerRef.current = null;
}
};
const handleCopyText = (content: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
void navigator.clipboard.writeText(content);
}
};
const handleCopyLink = (messageId: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const url = `${window.location.origin}${window.location.pathname}#msg-${messageId}`;
void navigator.clipboard.writeText(url);
}
};
// Pin / unpin both flow through the PinConfirmationModal so the
// user gets the same confirmation step the new UI uses. The
// variant flips based on whether the target message is already
// pinned.
const [pinTarget, setPinTarget] = useState<{
message: PinnedMessage;
variant: 'pin' | 'unpin';
} | null>(null);
const handlePin = (messageId: string) => {
const msg = messages.find((m) => m.id === messageId);
if (!msg) return;
setPinTarget({
message: {
id: msg.id,
authorName: msg.authorName,
authorAvatarUrl: msg.authorAvatarUrl,
content: msg.content,
timestamp: msg.timestamp,
attachments: msg.attachments,
},
variant: msg.pinned ? 'unpin' : 'pin',
});
};
const openReactPicker = (messageId: string, anchorEl: Element | null) => {
const rect = anchorEl?.getBoundingClientRect();
if (!rect) return;
setReactPicker({
messageId,
pos: {
top: rect.bottom + 8,
left: Math.max(8, rect.right - 480),
},
});
};
const handlePickReaction = async (value: EmojiPickerValue) => {
if (!reactPicker || !myUserId) return;
try {
// For unicode we store the raw surrogates as the reaction key.
// For custom server emojis we store the shortcode — the render
// path below rehydrates it via `customEmojiByName` and falls
// back through `resolveReactionKeyToUnicode` for legacy /
// unknown keys.
const emojiKey =
value.kind === 'custom' ? value.shortcode : value.surrogates;
await addReaction({
messageId: reactPicker.messageId as any,
userId: myUserId as any,
emoji: emojiKey,
});
} catch (err) {
console.error('Failed to add reaction:', err);
}
setReactPicker(null);
};
const handleDelete = async (messageId: string) => {
if (!myUserId) return;
try {
await removeMessage({ id: messageId as any, userId: myUserId as any });
} catch (err) {
console.error('Failed to delete message:', err);
}
};
const handleToggleReaction = async (messageId: string, emoji: string, me: boolean) => {
if (!myUserId) return;
try {
if (me) {
await removeReaction({
messageId: messageId as any,
userId: myUserId as any,
emoji,
});
} else {
await addReaction({
messageId: messageId as any,
userId: myUserId as any,
emoji,
});
}
} catch (err) {
console.error('Failed to toggle reaction:', err);
}
};
return (
<div className={styles.group}>
<div className={styles.contentColumn}>
{messages.map((msg, i) => {
const isFirst = i === 0;
const isOwn = msg.senderId === myUserId;
const menuOpenForThis = contextMenu?.messageId === msg.id;
return (
<div
key={msg.id}
data-message-id={msg.id}
className={`${styles.messageContent} ${isFirst ? styles.firstMessage : ''} messageHoverable ${menuOpenForThis || localMenuOpenFor === msg.id ? 'actionBarForceVisible' : ''}`}
onContextMenu={(e) => {
if (isMobile) return;
e.preventDefault();
setContextMenu({ messageId: msg.id, x: e.clientX, y: e.clientY });
}}
onTouchStart={() => startLongPress(msg)}
onTouchEnd={cancelLongPress}
onTouchMove={cancelLongPress}
onTouchCancel={cancelLongPress}
>
<MessageActionBar
isOwnMessage={isOwn}
onMenuOpenChange={(open) =>
setLocalMenuOpenFor(open ? msg.id : null)
}
onReply={() => onReply?.(msg.id, first.authorName)}
onDelete={() => handleDelete(msg.id)}
onReact={(e) => openReactPicker(msg.id, e?.currentTarget ?? null)}
onQuickReact={async (emoji) => {
if (!myUserId) return;
try {
await addReaction({
messageId: msg.id as any,
userId: myUserId as any,
emoji,
});
} catch (err) {
console.error('Failed to add quick reaction:', err);
}
}}
onPin={() => handlePin(msg.id)}
onCopyText={() => handleCopyText(msg.content)}
onCopyLink={() => handleCopyLink(msg.id)}
externalMenuAt={
menuOpenForThis ? { x: contextMenu.x, y: contextMenu.y } : null
}
onExternalMenuClose={() => setContextMenu(null)}
/>
{isFirst && msg.replyToId && (
<div
className={styles.replyContext}
role="button"
tabIndex={0}
onClick={() => {
if (!msg.replyToId) return;
window.dispatchEvent(
new CustomEvent('brycord:scroll-to-message', {
detail: {
channelId,
messageId: msg.replyToId,
},
}),
);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!msg.replyToId) return;
window.dispatchEvent(
new CustomEvent('brycord:scroll-to-message', {
detail: {
channelId,
messageId: msg.replyToId,
},
}),
);
}
}}
style={{ cursor: 'pointer' }}
>
<div className={styles.replySpine} />
<span className={styles.replyAuthor}>
{msg.replyToAuthorName ?? 'Unknown'}
</span>
<span className={styles.replyText}>
{msg.replyToContent ?? <em>(missing context)</em>}
</span>
</div>
)}
{isFirst && (
<>
<div
className={styles.avatarSlot}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}
}}
style={{ cursor: 'pointer' }}
>
<Avatar
src={first.authorAvatarUrl}
size={40}
fallback={first.authorName}
/>
</div>
<div className={styles.header}>
<span
className={styles.username}
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openAuthorPopout(
first,
(e.currentTarget as HTMLElement).getBoundingClientRect(),
);
}
}}
style={{
cursor: 'pointer',
color: first.authorRoleColor ?? undefined,
}}
>
{first.authorName}
</span>
<span
className={styles.timestamp}
title={formatFullTime(first.timestamp)}
>
{formatTime(first.timestamp)}
</span>
</div>
</>
)}
{!isFirst && (
<span
className={styles.gutterTimestamp}
title={formatFullTime(msg.timestamp)}
>
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})}
</span>
)}
{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)
.slice(0, 3)
.map((url, idx) => (
<LinkEmbed key={`embed-${idx}-${url}`} url={url} />
))}
{msg.attachments.length > 0 && (
<div className={styles.attachments}>
{msg.attachments.map((att, j) => {
const kind = att.mimeType.split('/')[0];
const cls =
kind === 'image' || kind === 'video'
? styles.imageAttachment
: styles.fileAttachment;
return (
<EncryptedAttachment
key={`${msg.id}-${j}`}
metadata={att}
className={cls}
onImageClick={(url, attachment) => {
setLightboxItem({ src: url, attachment });
}}
/>
);
})}
</div>
)}
{msg.reactions.length > 0 && (
<div className={styles.reactions}>
{msg.reactions.map((r) => {
// Custom emoji wins over unicode lookup. The reaction
// key is a plain shortcode, so check the custom map
// first; if no match, fall through to the existing
// shortcode→unicode path.
const customUrl = /^[a-zA-Z0-9_]+$/.test(r.emoji)
? customEmojiByName.get(r.emoji.toLowerCase())
: undefined;
// Tooltip body: emoji glyph + ":name: reacted by
// A, B, C, and N others" + hint line. Names come
// straight from the enriched payload so we don't
// refetch on hover. Marked interactive so the user
// can move onto the panel and click it to open the
// full reactions modal.
const emojiLabel = `:${reactionKeyToName(r.emoji)}:`;
const displayNames = r.users
.slice(0, 3)
.map((u) => u.displayName || u.username);
const extras = Math.max(0, r.count - displayNames.length);
const namesText =
displayNames.length === 0
? ''
: extras === 0
? displayNames.length === 1
? displayNames[0]
: displayNames.length === 2
? `${displayNames[0]} and ${displayNames[1]}`
: `${displayNames.slice(0, -1).join(', ')}, and ${displayNames[displayNames.length - 1]}`
: `${displayNames.join(', ')}, and ${extras} ${extras === 1 ? 'other' : 'others'}`;
const tooltipContent = (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
textAlign: 'center',
maxWidth: 240,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{customUrl ? (
<img
src={customUrl}
alt={emojiLabel}
draggable={false}
style={{
width: 32,
height: 32,
objectFit: 'contain',
}}
/>
) : (
<TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)}
size={32}
/>
)}
</div>
<span style={{ fontWeight: 600 }}>
{emojiLabel} reacted by
</span>
<span style={{ fontWeight: 500 }}>{namesText}</span>
<span
style={{
fontSize: '0.6875rem',
color: 'var(--text-tertiary)',
marginTop: 2,
}}
>
Click to view all reactions
</span>
</div>
);
return (
<Tooltip
key={r.emoji}
content={tooltipContent}
placement="top"
delay={200}
interactive
onContentClick={() => setReactionsModalMsgId(msg.id)}
>
<button
type="button"
className={`${styles.reactionChip} ${r.me ? styles.reactionMe : ''}`}
onClick={() => setReactionsModalMsgId(msg.id)}
>
{customUrl ? (
<img
src={customUrl}
alt={`:${r.emoji}:`}
title={`:${r.emoji}:`}
draggable={false}
style={{
width: 16,
height: 16,
objectFit: 'contain',
verticalAlign: 'middle',
}}
/>
) : (
<TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)}
size={16}
/>
)}
<span className={styles.reactionCount}>{r.count}</span>
</button>
</Tooltip>
);
})}
</div>
)}
</div>
);
})}
</div>
{reactPicker &&
createPortal(
<div
style={{
position: 'fixed',
top: reactPicker.pos.top,
left: reactPicker.pos.left,
zIndex: 15000,
}}
onClick={(e) => e.stopPropagation()}
>
<EmojiPicker
onSelect={handlePickReaction}
onClose={() => setReactPicker(null)}
/>
</div>,
document.body,
)}
<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}
attachment={lightboxItem?.attachment}
onClose={() => setLightboxItem(null)}
/>
<PinConfirmationModal
isOpen={!!pinTarget}
onClose={() => setPinTarget(null)}
channelId={channelId}
messageId={pinTarget?.message.id ?? null}
message={pinTarget?.message ?? null}
variant={pinTarget?.variant ?? 'pin'}
/>
{authorPopout && (
<MemberProfilePopout
anchorRect={authorPopout.anchorRect}
member={authorPopout.member}
onClose={() => setAuthorPopout(null)}
/>
)}
{(() => {
// Look up the target message by id from the current group
// so the modal always renders against the latest reaction
// rows (the group array is re-created on every parent
// re-render by Messages.tsx's memoized decrypt step).
const targetMsg = reactionsModalMsgId
? messages.find((m) => m.id === reactionsModalMsgId) ?? null
: null;
return (
<ReactionsModal
isOpen={!!targetMsg}
onClose={() => setReactionsModalMsgId(null)}
reactions={targetMsg?.reactions ?? []}
myUserId={myUserId}
customEmojiByName={customEmojiByName}
onRemoveOwnReaction={async (emoji) => {
if (!targetMsg || !myUserId) return;
try {
await removeReaction({
messageId: targetMsg.id as any,
userId: myUserId as any,
emoji,
});
} catch (err) {
console.error('Failed to remove reaction:', err);
}
}}
/>
);
})()}
{mobileSheetForMsg && (
<MobileMessageActionsSheet
isOpen={!!mobileSheetForMsg}
onClose={() => setMobileSheetForMsg(null)}
isOwnMessage={mobileSheetForMsg.senderId === myUserId}
canDelete={mobileSheetForMsg.senderId === myUserId}
hasContent={!!mobileSheetForMsg.content}
onQuickReact={async (emoji) => {
if (!myUserId) return;
try {
await addReaction({
messageId: mobileSheetForMsg.id as any,
userId: myUserId as any,
emoji,
});
} catch (err) {
console.error('Failed to add quick reaction:', err);
}
}}
onAddReaction={() => {
// No anchor on mobile — just open a centered picker
// by passing `null` as the anchor.
setReactPicker({
messageId: mobileSheetForMsg.id,
anchor: null as any,
});
}}
onReply={() => {
onReply?.(mobileSheetForMsg.id, first.authorName);
}}
onForward={() => {
/* forward not implemented yet */
}}
onEdit={
mobileSheetForMsg.senderId === myUserId
? () => {
/* edit not implemented via sheet yet */
}
: undefined
}
onPin={() => handlePin(mobileSheetForMsg.id)}
isPinned={mobileSheetForMsg.pinned}
onCopyText={() => handleCopyText(mobileSheetForMsg.content)}
onDelete={
mobileSheetForMsg.senderId === myUserId
? () => handleDelete(mobileSheetForMsg.id)
: undefined
}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,101 @@
.content {
display: flex;
flex-direction: column;
padding: 0;
max-height: 70vh;
overflow-y: auto;
}
.sections {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px 20px;
}
.section {
background-color: var(--background-secondary, rgba(255, 255, 255, 0.03));
border: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
border-radius: 6px;
overflow: hidden;
}
.sectionAccent {
border-color: rgba(234, 197, 50, 0.5);
background-color: rgba(234, 197, 50, 0.06);
}
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--background-modifier-accent, rgba(255, 255, 255, 0.06));
}
.sectionLabel {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-primary-muted, #a0a3a8);
}
.copyButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--text-primary-muted, #a0a3a8);
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
}
.copyButton:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.06));
color: var(--text-primary, #fff);
}
.sectionValue {
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary, #fff);
word-break: break-all;
}
.jsonBlock {
margin: 0;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11.5px;
line-height: 1.5;
color: var(--text-primary, #fff);
white-space: pre-wrap;
word-break: break-word;
max-height: 280px;
overflow-y: auto;
}
.emptyState {
padding: 32px 24px;
text-align: center;
color: var(--text-primary-muted, #a0a3a8);
font-size: 14px;
}
.warning {
padding: 8px 12px;
background-color: rgba(234, 80, 80, 0.15);
border: 1px solid rgba(234, 80, 80, 0.4);
border-radius: 6px;
color: var(--text-primary, #fff);
font-size: 13px;
}

View File

@@ -0,0 +1,185 @@
/**
* MessageInspectModal — debug/dev view that dumps the raw Convex message
* document behind a rendered message. Callers pass the already-decrypted
* message object (the same shape that flows through Messages /
* MessageGroup props), and this modal pretty-prints the document plus
* a handful of broken-out fields that are commonly useful when
* debugging rendering issues.
*/
import { useMemo, useState } from 'react';
import { Copy, CheckCircle } from '@phosphor-icons/react';
import { Modal, Button } from '@discord-clone/ui';
import styles from './MessageInspectModal.module.css';
interface MessageInspectModalProps {
isOpen: boolean;
onClose: () => void;
message: Record<string, unknown> | null;
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function MessageInspectModal({ isOpen, onClose, message }: MessageInspectModalProps) {
const [copied, setCopied] = useState<string | null>(null);
const summary = useMemo(() => {
if (!message) return null;
const m = message as any;
return {
id: String(m._id ?? ''),
channelId: String(m.channelId ?? ''),
senderId: String(m.senderId ?? ''),
timestamp: typeof m._creationTime === 'number' ? m._creationTime : Number(m.createdAt ?? 0),
content: typeof m.decryptedContent === 'string' ? m.decryptedContent : null,
attachments: m.attachments ?? null,
raw: m,
};
}, [message]);
const handleCopy = (label: string, text: string) => {
navigator.clipboard.writeText(text).then(
() => {
setCopied(label);
setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500);
},
() => {},
);
};
if (!isOpen) return null;
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="large">
<Modal.Header title="Inspect Message" onClose={onClose} />
<Modal.Content className={styles.content}>
{!summary ? (
<div className={styles.emptyState}>No message selected.</div>
) : (
<div className={styles.sections}>
<Section
label="Message ID"
value={summary.id}
onCopy={handleCopy}
copied={copied}
/>
<Section
label="Channel ID"
value={summary.channelId}
onCopy={handleCopy}
copied={copied}
/>
<Section
label="Sender ID"
value={summary.senderId}
onCopy={handleCopy}
copied={copied}
/>
<Section
label="Timestamp"
value={`${new Date(summary.timestamp).toISOString()} (${summary.timestamp})`}
onCopy={handleCopy}
copied={copied}
/>
{summary.content !== null && (
<JsonBlock
label="Decrypted Content"
value={summary.content}
onCopy={handleCopy}
copied={copied}
accent
/>
)}
{summary.attachments && (
<JsonBlock
label="Attachments"
value={safeStringify(summary.attachments)}
onCopy={handleCopy}
copied={copied}
/>
)}
<JsonBlock
label="Raw Document"
value={safeStringify(summary.raw)}
onCopy={handleCopy}
copied={copied}
/>
</div>
)}
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</Modal.Footer>
</Modal.Root>
);
}
function Section({
label,
value,
onCopy,
copied,
}: {
label: string;
value: string;
onCopy: (label: string, text: string) => void;
copied: string | null;
}) {
return (
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionLabel}>{label}</span>
<button
type="button"
className={styles.copyButton}
onClick={() => onCopy(label, value)}
aria-label={`Copy ${label}`}
>
{copied === label ? <CheckCircle size={14} weight="fill" /> : <Copy size={14} weight="fill" />}
</button>
</div>
<div className={styles.sectionValue}>{value}</div>
</div>
);
}
function JsonBlock({
label,
value,
onCopy,
copied,
accent,
}: {
label: string;
value: string;
onCopy: (label: string, text: string) => void;
copied: string | null;
accent?: boolean;
}) {
return (
<div className={`${styles.section} ${accent ? styles.sectionAccent : ''}`}>
<div className={styles.sectionHeader}>
<span className={styles.sectionLabel}>{label}</span>
<button
type="button"
className={styles.copyButton}
onClick={() => onCopy(label, value)}
aria-label={`Copy ${label}`}
>
{copied === label ? <CheckCircle size={14} weight="fill" /> : <Copy size={14} weight="fill" />}
</button>
</div>
<pre className={styles.jsonBlock}>{value}</pre>
</div>
);
}

View File

@@ -0,0 +1,120 @@
.container {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
overflow-anchor: none;
/* ── Fluxer chat scrollbar ────────────────────────────────────
Exact copy of Fluxer's `Scroller.module.css` rules. The track
gets pinned to `--background-secondary-lighter` (same override
Fluxer's Messages.module.css uses) so the thumb has a matching
surface to ride on. Firefox fallback uses `scrollbar-color`.
The 4px transparent border + `background-clip: padding-box`
trick is what gives the thumb its visible inset — the element
is 16px wide total but only 8px of thumb renders inside the
border, floating in the middle of the gutter. */
--scrollbar-track-bg: var(--background-secondary-lighter);
scrollbar-color: var(--scrollbar-thumb-bg) var(--scrollbar-track-bg);
}
.container::-webkit-scrollbar {
width: 16px;
height: 16px;
}
.container::-webkit-scrollbar-corner {
background-color: transparent;
}
.container::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-bg);
min-height: 40px;
}
.container::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-bg-hover);
}
.container::-webkit-scrollbar-thumb,
.container::-webkit-scrollbar-track {
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 8px;
}
.container::-webkit-scrollbar-track {
background-color: var(--scrollbar-track-bg);
}
.scroller {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
padding-bottom: 8px;
overflow-anchor: none;
}
/* ── Day divider ─────────────────────────────────────────────────
Horizontal rule with the date centered on it, mirroring fluxer's
per-day separator between message groups. The rule uses flex
pseudo-elements so the line fills every remaining pixel on both
sides of the label regardless of how wide the date string is. */
.dayDivider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 1rem 0.5rem;
color: var(--text-tertiary);
font-size: 0.75rem;
font-weight: 600;
user-select: none;
}
.dayDivider::before,
.dayDivider::after {
content: '';
flex: 1;
height: 1px;
background-color: var(--background-modifier-accent);
}
.pollWrapper {
padding: 0.25rem 1rem 0.25rem 4.5rem;
}
/* ── Day divider ──────────────────────────────────────────────────
Renders between message groups whenever the calendar day changes.
Centred date label with thin horizontal lines on both sides — the
`flex: 1` pseudo-elements grow to fill whatever the label doesn't. */
.dayDivider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 1rem 0.5rem;
color: var(--text-tertiary);
font-size: 0.75rem;
font-weight: 600;
user-select: none;
}
.dayDivider::before,
.dayDivider::after {
content: '';
flex: 1;
height: 1px;
background-color: var(--background-modifier-accent);
}
/* ── Jump highlight ───────────────────────────────────────────────
Pulsed background applied to a message row by the
`brycord:scroll-to-message` listener. Class is added globally
(via `.messageJumpHighlight`) so it can target the
`[data-message-id]` node from MessageGroup, which sits inside
`.scroller` but uses its own module class names. */
:global(.messageJumpHighlight) {
background-color: rgba(88, 101, 242, 0.16);
box-shadow: inset 2px 0 0 var(--brand-primary, #5865f2);
transition: background-color 0.25s ease, box-shadow 0.25s ease;
}

View File

@@ -0,0 +1,748 @@
import { usePaginatedQuery, useQuery } from 'convex/react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import { MessageGroup } from './MessageGroup';
import { PollCard } from './PollCard';
import { ChannelWelcomeSection } from './ChannelWelcomeSection';
import type { Id } from '../../../../../convex/_generated/dataModel';
import styles from './Messages.module.css';
interface MessagesProps {
channelId: string;
onReply?: (eventId: string, username: string) => void;
}
import type { AttachmentMetadata } from './EncryptedAttachment';
export interface ReactionUser {
userId: string;
username: string;
displayName: string | null;
}
export interface DecryptedMessage {
id: string;
channelId: string;
senderId: string;
authorName: string;
authorAvatarUrl: string | null;
authorRoleColor: string | null;
content: string;
timestamp: number;
editedTimestamp: number | null;
replyToId: string | null;
replyToAuthorName: string | null;
replyToContent: string | null;
attachments: AttachmentMetadata[];
reactions: Array<{
emoji: string;
count: number;
me: boolean;
users: ReactionUser[];
}>;
pinned: boolean;
}
// Ciphertext format on disk is `content + tag` as hex, where the tag is
// the last 32 hex chars (16 bytes of GCM auth tag). Must be split before
// calling crypto.decryptData, which expects them as separate args.
const TAG_LENGTH = 32;
// Small LRU-ish cache for decrypted messages so re-renders don't redecrypt.
const decryptionCache = new Map<string, string>();
const MAX_CACHE = 2000;
function cacheSet(id: string, content: string) {
if (decryptionCache.size >= MAX_CACHE) {
const firstKey = decryptionCache.keys().next().value;
if (firstKey !== undefined) decryptionCache.delete(firstKey);
}
decryptionCache.set(id, content);
}
// ── Day divider helpers ─────────────────────────────────────────────
//
// Inserts a "Tuesday, April 7, 2026"-style separator between message
// groups whenever the calendar day changes. Compares year/month/day
// rather than the timestamp delta — two messages 23 hours apart can
// still cross midnight.
function isSameDay(a: number, b: number): boolean {
const da = new Date(a);
const db = new Date(b);
return (
da.getFullYear() === db.getFullYear() &&
da.getMonth() === db.getMonth() &&
da.getDate() === db.getDate()
);
}
const DAY_DIVIDER_FORMATTER = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
function formatDayDivider(ts: number): string {
return DAY_DIVIDER_FORMATTER.format(new Date(ts));
}
function DayDivider({ timestamp }: { timestamp: number }) {
return (
<div className={styles.dayDivider} role="separator">
{formatDayDivider(timestamp)}
</div>
);
}
export function Messages({ channelId, onReply }: MessagesProps) {
const { crypto } = usePlatform();
const scrollerRef = useRef<HTMLDivElement>(null);
// Pending jump target — set by the `brycord:scroll-to-message`
// listener when the message isn't rendered yet. A second effect
// further down reacts to it: if the target is now in the DOM,
// scroll to it; otherwise call `loadMore` to fetch another page
// of history and try again on the next render.
const pendingJumpRef = useRef<string | null>(null);
// channelKeysByVersion holds every key we have for this channel,
// keyed by the server-stored keyVersion. A DM that's been rotated
// will have multiple entries — old messages decrypt with the
// version they were written under, new messages use the latest.
const [channelKeysByVersion, setChannelKeysByVersion] = useState<
Map<number, string>
>(new Map());
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const privateKeyPem = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('privateKey') : null;
const allKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId: userId as any } : 'skip',
);
// 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
// bundles can carry multiple channel ids), so we still merge by
// channelId, then bucket by the row's `key_version`.
useEffect(() => {
let cancelled = false;
if (!allKeys || !privateKeyPem) {
setChannelKeysByVersion(new Map());
return;
}
(async () => {
const next = new Map<number, string>();
for (const item of allKeys) {
try {
const bundleJson = await crypto.privateDecrypt(
privateKeyPem,
item.encrypted_key_bundle,
);
const parsed = JSON.parse(bundleJson) as Record<string, string>;
const keyForThisChannel = parsed[channelId];
if (keyForThisChannel) {
next.set(item.key_version ?? 1, keyForThisChannel);
}
} catch (err) {
console.error(
`Failed to decrypt key bundle for ${item.channel_id}`,
err,
);
}
}
if (cancelled) return;
setChannelKeysByVersion(next);
})();
return () => {
cancelled = true;
};
}, [allKeys, privateKeyPem, channelId]);
// Fallback "default" key — use the highest version we have. Needed
// for legacy messages that were written before `keyVersion` was
// tracked on messages (they come back without the field).
const channelKey = useMemo(() => {
if (channelKeysByVersion.size === 0) return null;
let maxVersion = -Infinity;
let latest: string | null = null;
for (const [ver, key] of channelKeysByVersion) {
if (ver > maxVersion) {
maxVersion = ver;
latest = key;
}
}
return latest;
}, [channelKeysByVersion]);
const keyBundle = allKeys?.find((k) => k.channel_id === channelId) ?? null;
const {
results: pagedMessages,
status,
loadMore,
} = usePaginatedQuery(
api.messages.list,
channelId ? { channelId: channelId as any, userId: (userId as any) ?? undefined } : 'skip',
{ initialNumItems: 50 },
);
const [decryptedMap, setDecryptedMap] = useState<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.
const [replyPreviewMap, setReplyPreviewMap] = useState<Map<string, string>>(
new Map(),
);
// Helper: try to scroll to whatever's in `pendingJumpRef`. If the
// target is in the DOM we scroll + flash, clear the pending ref,
// and we're done. Otherwise we kick off another paginate-up so
// older history loads, then re-poll on the next render via the
// effect below.
const tryFulfillJump = useCallback(() => {
const target = pendingJumpRef.current;
if (!target) return;
const scroller = scrollerRef.current;
if (!scroller) return;
const el = scroller.querySelector<HTMLElement>(
`[data-message-id="${CSS.escape(target)}"]`,
);
if (el) {
pendingJumpRef.current = null;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('messageJumpHighlight');
window.setTimeout(() => {
el.classList.remove('messageJumpHighlight');
}, 1600);
return;
}
// Not in the DOM — load more older pages until either the
// message appears or the listing is exhausted.
if (status === 'CanLoadMore') {
loadMore(50);
} else if (status === 'Exhausted') {
pendingJumpRef.current = null;
}
}, [status, loadMore]);
// Listen for `brycord:scroll-to-message` window events fired by
// reply previews, pin entries, and search results.
useEffect(() => {
const onScrollTo = (e: Event) => {
const detail = (e as CustomEvent<{
channelId?: string;
messageId?: string;
}>).detail;
if (!detail?.messageId) return;
if (detail.channelId && detail.channelId !== channelId) return;
pendingJumpRef.current = detail.messageId;
tryFulfillJump();
};
window.addEventListener('brycord:scroll-to-message', onScrollTo);
return () =>
window.removeEventListener('brycord:scroll-to-message', onScrollTo);
}, [channelId, tryFulfillJump]);
// Re-attempt the jump whenever a new page of messages lands. The
// loader call above triggers a re-render with more rows, this
// effect runs, and we either find the target or queue the next
// paginate-up.
useEffect(() => {
if (pendingJumpRef.current) tryFulfillJump();
}, [pagedMessages, tryFulfillJump]);
useEffect(() => {
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
let cancelled = false;
(async () => {
const next = new Map(decryptedMap);
let changed = false;
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (next.has(id)) continue;
const cached = decryptionCache.get(id);
if (cached) {
next.set(id, cached);
changed = true;
continue;
}
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
next.set(id, '[Invalid Encrypted Message]');
changed = true;
continue;
}
// Pick the key matching this message's version. Messages
// that predate key rotation default to version 1. If the
// exact version is missing (shouldn't happen normally),
// fall back to the latest key and try that so we never
// lock the user out of their own history.
const msgVersion: number = msg.keyVersion ?? 1;
const keyForVersion =
channelKeysByVersion.get(msgVersion) ?? channelKey;
if (!keyForVersion) {
next.set(id, '[Unable to decrypt]');
changed = true;
continue;
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
keyForVersion,
msg.nonce,
tag,
);
if (cancelled) return;
cacheSet(id, plaintext);
next.set(id, plaintext);
changed = true;
} catch {
next.set(id, '[Unable to decrypt]');
changed = true;
}
}
if (changed && !cancelled) setDecryptedMap(next);
})();
return () => {
cancelled = true;
};
}, [pagedMessages, channelKeysByVersion, channelKey]);
// Reply preview decryption — same pattern as the main loop but
// keyed by child message id and using the parent's `replyToContent`
// + `replyToNonce` + `replyToKeyVersion` fields. Cached so a
// re-render of pagedMessages doesn't redecrypt every preview.
useEffect(() => {
if (channelKeysByVersion.size === 0 || !pagedMessages) return;
let cancelled = false;
(async () => {
const next = new Map(replyPreviewMap);
let changed = false;
for (const msg of pagedMessages as any[]) {
const id = msg.id as string;
if (next.has(id)) continue;
if (
!msg.replyToContent ||
!msg.replyToNonce ||
msg.replyToContent.length < TAG_LENGTH
) {
continue;
}
const cacheKey = `reply:${id}`;
const cached = decryptionCache.get(cacheKey);
if (cached) {
next.set(id, cached);
changed = true;
continue;
}
const replyVersion: number = msg.replyToKeyVersion ?? 1;
const keyForVersion =
channelKeysByVersion.get(replyVersion) ?? channelKey;
if (!keyForVersion) continue;
const tag = msg.replyToContent.slice(-TAG_LENGTH);
const contentHex = msg.replyToContent.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
keyForVersion,
msg.replyToNonce,
tag,
);
if (cancelled) return;
// Strip JSON wrappers so the preview shows the
// human-readable text, not raw {"text":"…"} dumps.
let preview = plaintext;
try {
const parsed = JSON.parse(plaintext);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed)) {
preview = parsed
.filter((p: any) => p?.type === 'attachment')
.map((p: any) => p?.filename || 'attachment')
.join(', ');
} else if (parsed.type === 'attachment') {
preview = parsed.filename || parsed.mimeType || 'Attachment';
} else if (parsed.text !== undefined) {
preview = String(parsed.text);
}
}
} catch {
/* plain text */
}
cacheSet(cacheKey, preview);
next.set(id, preview);
changed = true;
} catch {
/* leave unset — UI shows the missing-context fallback */
}
}
if (changed && !cancelled) setReplyPreviewMap(next);
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagedMessages, channelKeysByVersion, channelKey]);
const decrypted: DecryptedMessage[] = useMemo(() => {
if (!pagedMessages) return [];
return (pagedMessages as any[])
.slice()
.reverse()
.map((msg) => {
const id = msg.id as string;
const content = decryptedMap.get(id) ?? '';
const raw = (() => {
try {
return JSON.parse(content);
} catch {
return null;
}
})();
let text = content;
const attachments: AttachmentMetadata[] = [];
// Two legacy formats:
// 1. Single object: { type: 'attachment', url, key, iv, ... }
// 2. Array of attachment objects
// 3. { text: '...' } wrapper from newer sends
if (raw) {
if (Array.isArray(raw)) {
for (const item of raw) {
if (item?.type === 'attachment' && item.url && item.key && item.iv) {
attachments.push(item as AttachmentMetadata);
}
}
text = '';
} else if (raw.type === 'attachment' && raw.url && raw.key && raw.iv) {
attachments.push(raw as AttachmentMetadata);
text = '';
} else if (raw.text !== undefined) {
text = String(raw.text);
}
}
// Reply preview comes from `replyPreviewMap`, populated
// asynchronously by the dedicated decryption effect above.
const replyPreview = replyPreviewMap.get(id) ?? null;
// `msg.reactions` is either an array of {emoji,count,me,users}
// (new server shape) or null. Older cached rows can still
// surface a Record without `users` — fall back to an
// empty users list in that case so the client never
// crashes on forward-compat.
const reactionsArray: DecryptedMessage['reactions'] = Array.isArray(
msg.reactions,
)
? msg.reactions.map((r: any) => ({
emoji: String(r.emoji ?? ''),
count: Number(r.count ?? 0),
me: Boolean(r.me ?? false),
users: Array.isArray(r.users)
? r.users.map((u: any) => ({
userId: String(u.userId ?? ''),
username: String(u.username ?? 'Unknown'),
displayName: u.displayName ?? null,
}))
: [],
}))
: msg.reactions && typeof msg.reactions === 'object'
? Object.entries(msg.reactions).map(([emoji, info]) => ({
emoji,
count: (info as any).count ?? 0,
me: (info as any).me ?? false,
users: [],
}))
: [];
return {
id,
channelId,
senderId: msg.sender_id as string,
authorName: msg.displayName || msg.username || 'User',
authorAvatarUrl: msg.avatarUrl ?? null,
authorRoleColor: msg.senderRoleColor ?? null,
content: text,
timestamp: msg.created_at ? new Date(msg.created_at).getTime() : Date.now(),
editedTimestamp: msg.editedAt ?? null,
replyToId: msg.replyToId ?? null,
replyToAuthorName: msg.replyToDisplayName || msg.replyToUsername || null,
replyToContent: replyPreview,
attachments,
reactions: reactionsArray,
pinned: msg.pinned ?? false,
} as DecryptedMessage;
});
}, [pagedMessages, decryptedMap, replyPreviewMap, channelId]);
// 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
// behaviour based on this one bit.
const pinnedRef = useRef(true);
// Anchor for scroll-preservation on pagination. Instead of tracking
// an absolute scrollTop we remember the distance between the top of
// the oldest visible message and the bottom of the scroller (i.e.
// `scrollHeight - scrollTop`). As long as this anchor is set, every
// observed mutation re-pins scrollTop so that the same "bottom
// offset" is preserved — the reader stays glued to whichever
// message they were looking at when they scrolled into the loader
// region, regardless of how many growth events the decryption
// pipeline produces.
//
// The anchor is cleared when:
// - The query reports no more pages (`status !== 'CanLoadMore'`)
// - OR the next onScroll fires AFTER the user actually moves
// the scroller (tracked via a "recently restored" flag)
const scrollAnchorRef = useRef<{ bottomOffset: number } | null>(null);
const restoreActiveRef = useRef(false);
// On channel switch, re-pin and scroll to bottom.
useLayoutEffect(() => {
pinnedRef.current = true;
scrollAnchorRef.current = null;
restoreActiveRef.current = false;
const el = scrollerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [channelId]);
// Observe DOM mutations + resizes inside the scroller. If the user
// is pinned to bottom, snap to bottom on every content change. If
// the user triggered a paginate-up, preserve their position by
// adjusting scrollTop against the height delta.
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onContentChange = () => {
// Pagination anchor wins over pinned — the user is clearly
// scrolled up and reading older context, so we must NOT
// slam them to the bottom.
const anchor = scrollAnchorRef.current;
if (anchor) {
// Restore the same distance-from-bottom on every
// mutation until the anchor is cleared. This handles
// the case where decryption triggers multiple rounds
// of height growth after a single loadMore call.
restoreActiveRef.current = true;
el.scrollTop = Math.max(0, el.scrollHeight - anchor.bottomOffset);
// Flip the flag off on the next tick so a real user
// scroll after this restore can still update pinned
// tracking without being mistaken for our own restore.
requestAnimationFrame(() => {
restoreActiveRef.current = false;
});
return;
}
if (pinnedRef.current) {
el.scrollTop = el.scrollHeight;
}
};
const mutationObs = new MutationObserver(onContentChange);
mutationObs.observe(el, { childList: true, subtree: true });
// ResizeObserver catches async content like attachments loading
// after the DOM is already in place (images decoding, blob URLs
// resolving, emoji images flushing). Also observes the scroller
// itself so window resizes (e.g. dragging the bottom edge up)
// keep the bottom-pinned position locked instead of clipping
// the latest messages.
const content = el.firstElementChild;
let resizeObs: ResizeObserver | undefined;
if (content) {
resizeObs = new ResizeObserver(onContentChange);
resizeObs.observe(content);
resizeObs.observe(el);
}
// Final safety net for non-ResizeObserver-friendly resizes
// (split-pane drags that don't trigger an element resize) —
// listen for window resize and re-run the pin check too.
const onWindowResize = () => onContentChange();
window.addEventListener('resize', onWindowResize);
// Image / video / audio attachments fire this once their
// decoded bytes are painted. Re-runs the same pin-to-bottom
// path so the scroll anchor catches the late layout shift
// even when the placeholder + final image have identical
// box dimensions (the ResizeObserver wouldn't fire then).
const onAttachmentLoaded = () => onContentChange();
window.addEventListener(
'brycord:attachment-loaded',
onAttachmentLoaded,
);
return () => {
mutationObs.disconnect();
resizeObs?.disconnect();
window.removeEventListener('resize', onWindowResize);
window.removeEventListener(
'brycord:attachment-loaded',
onAttachmentLoaded,
);
};
}, [channelId]);
const handleScroll = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
// Ignore scroll events triggered by our own anchor-restore in
// the observer. Without this guard, the restore would look
// like a user scroll and nuke the anchor before the next
// mutation arrives.
if (restoreActiveRef.current) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
pinnedRef.current = distanceFromBottom < 150;
// If the user has visibly moved away from the loader region
// (far enough down that another paginate-up can't trigger),
// clear the anchor. This lets the next near-top scroll start
// a fresh pagination cycle instead of chaining onto stale
// coords from a previous one.
if (scrollAnchorRef.current && el.scrollTop > 200) {
scrollAnchorRef.current = null;
}
// Load older messages when the user nears the top. Capture
// the current distance-from-bottom into the anchor ref BEFORE
// firing loadMore so the content-change observer can pin the
// viewport to the same message the reader was looking at.
if (el.scrollTop < 80 && status === 'CanLoadMore' && !scrollAnchorRef.current) {
scrollAnchorRef.current = {
bottomOffset: el.scrollHeight - el.scrollTop,
};
loadMore(50);
}
}, [status, loadMore]);
const groups = useMemo(() => {
const result: DecryptedMessage[][] = [];
for (const msg of decrypted) {
const last = result[result.length - 1];
if (last && last[last.length - 1].senderId === msg.senderId) {
const gap = msg.timestamp - last[last.length - 1].timestamp;
if (gap < 7 * 60 * 1000 && !msg.replyToId) {
last.push(msg);
continue;
}
}
result.push([msg]);
}
return result;
}, [decrypted]);
// Channel info — used by the welcome header at the top of the
// scroller so we can render either a DM intro or a "Welcome to
// #name" block. Cheap reactive query, same as ChannelView.
const channelDoc = useQuery(
api.channels.get,
channelId ? { id: channelId as Id<'channels'> } : 'skip',
);
// Polls are independent Convex documents — fetch every poll in
// this channel and interleave them with the message groups by
// creation timestamp. Poll counts are small per channel so
// collecting them in one query is fine.
const pollsInChannel =
useQuery(
api.polls.listByChannel,
channelId ? { channelId: channelId as Id<'channels'> } : 'skip',
) ?? [];
type TimelineItem =
| { kind: 'group'; key: string; ts: number; group: DecryptedMessage[] }
| { kind: 'poll'; key: string; ts: number; pollId: Id<'polls'> };
const items = useMemo<TimelineItem[]>(() => {
const list: TimelineItem[] = groups.map((group) => ({
kind: 'group',
key: `g-${group[0].id}`,
ts: group[0].timestamp,
group,
}));
for (const poll of pollsInChannel) {
list.push({
kind: 'poll',
key: `p-${poll._id}`,
ts: poll.createdAt,
pollId: poll._id,
});
}
list.sort((a, b) => a.ts - b.ts);
return list;
}, [groups, pollsInChannel]);
return (
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
<div className={styles.scroller}>
{status === 'LoadingMore' && (
<div
style={{
padding: '8px 16px',
color: 'var(--text-tertiary)',
fontSize: 13,
textAlign: 'center',
}}
>
Loading older messages
</div>
)}
{/* Welcome header — only shown once we've loaded every
page so the user actually sees the start of the
history. While there are still older messages to
fetch, the loading row above takes its place. */}
{status === 'Exhausted' && (
<ChannelWelcomeSection
channelId={channelId}
channelName={channelDoc?.name}
channelType={channelDoc?.type}
/>
)}
{(() => {
// 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.
let lastTs: number | null = null;
const out: React.ReactNode[] = [];
for (const item of items) {
if (lastTs === null || !isSameDay(lastTs, item.ts)) {
out.push(
<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
);
}
lastTs = item.ts;
if (item.kind === 'group') {
out.push(
<MessageGroup
key={item.key}
messages={item.group as any}
channelId={channelId}
onReply={onReply}
/>,
);
} else {
out.push(
<div key={item.key} className={styles.pollWrapper}>
<PollCard pollId={item.pollId} />
</div>,
);
}
}
return out;
})()}
</div>
</div>
);
}

View File

@@ -0,0 +1,296 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
padding: 4px 12px 8px;
gap: 12px;
}
/* ── Segmented tab selector ───────────────────────────────────────────
Matches the Fluxer ExpressionPicker header tab list — rounded pill
container, inset padding, animated active tab background. Uses a
CSS-transform transition on the .tabBackground element instead of
framer-motion's layoutId so it stays lightweight. */
.tabBar {
position: relative;
display: flex;
border-radius: 10px;
background: var(--background-tertiary);
padding: 3px;
flex-shrink: 0;
}
.tabBackground {
position: absolute;
top: 3px;
bottom: 3px;
height: calc(100% - 6px);
border-radius: 8px;
background: var(--background-secondary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
transition: left 0.22s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
}
.tab {
position: relative;
z-index: 10;
flex: 1;
border: none;
border-radius: 8px;
padding: 8px 12px;
font: inherit;
font-size: 14px;
font-weight: 600;
line-height: 18px;
text-align: center;
background: transparent;
color: var(--text-secondary);
transition: color 0.15s ease;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.tabActive {
color: var(--text-primary);
}
.tabDisabled {
opacity: 0.55;
cursor: default;
}
/* ── Search bar ──────────────────────────────────────────────────────
Mirrors Fluxer's PickerSearchInput — 36px min height, rounded,
magnifying-glass leading icon, clear-X trailing button. */
.searchBar {
position: relative;
display: flex;
align-items: center;
min-height: 44px;
padding: 0 2.25rem 0 2.25rem;
border-radius: 8px;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-tertiary);
flex-shrink: 0;
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
.searchInput {
flex: 1;
background: transparent;
border: none;
outline: none;
box-shadow: none;
-webkit-appearance: none;
appearance: none;
-webkit-tap-highlight-color: transparent;
padding: 8px 0;
font: inherit;
font-size: 15px;
color: var(--text-primary);
}
/* Kill every browser focus/active/autofill ring so the only visual
is the rounded `.searchBar` surface. Firefox and Chromium ship
different default blue rings on text inputs. */
.searchInput:focus,
.searchInput:focus-visible,
.searchInput:active {
outline: none;
box-shadow: none;
border: none;
}
.searchInput::placeholder {
color: var(--text-tertiary-muted, var(--text-tertiary));
}
.searchClear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: var(--background-modifier-hover, rgba(255, 255, 255, 0.1));
color: var(--text-primary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* ── Body / scrolling area ───────────────────────────────────────────*/
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.comingSoon {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-tertiary);
font-size: 14px;
font-weight: 500;
}
/* ── Collapsible category sections ───────────────────────────────────
The header is a clickable row with a caret that rotates; the emoji
grid slides in/out below when toggled. Headers are intentionally
NOT position: sticky — the user wants them to scroll naturally with
the content, matching the Fluxer drawer layout in the reference. */
.categorySection {
display: flex;
flex-direction: column;
margin-bottom: 4px;
}
.categoryHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 4px;
border: none;
background: none;
color: var(--text-primary);
font: inherit;
font-size: 15px;
font-weight: 700;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.categoryCaret {
flex-shrink: 0;
color: var(--text-primary-muted, var(--text-tertiary));
transition: transform 0.15s ease;
}
.categoryCaretOpen {
transform: rotate(90deg);
}
.categoryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 2px;
padding: 4px 0 12px;
}
.emojiButton {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
border: none;
border-radius: 6px;
background: none;
padding: 4px;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.emojiButton:active {
background-color: var(--background-modifier-hover);
}
.emojiImg {
width: 28px;
height: 28px;
display: block;
pointer-events: none;
}
/* ── Search results layout ──────────────────────────────────────────*/
.searchResults {
display: flex;
flex-direction: column;
padding-top: 4px;
}
.searchResultsLabel {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
padding: 4px 4px 6px;
}
.noResults {
text-align: center;
color: var(--text-tertiary);
font-size: 14px;
padding: 32px 0;
}
/* ── Bottom category nav bar ────────────────────────────────────────
Horizontal row of icon buttons below the body, for jumping directly
to a category. Mirrors Fluxer's `categoryListBottom` — pinned to
the sheet's bottom, subtle top divider, safe-area padding so it
clears the home indicator on notched phones. */
.bottomCategoryBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 8px 4px calc(8px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--background-header-secondary);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
}
.bottomCategoryBar::-webkit-scrollbar {
display: none;
}
.bottomCategoryButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary-muted, var(--text-tertiary));
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.bottomCategoryButton:active {
background-color: var(--background-modifier-hover);
}
.bottomCategoryButtonActive {
color: var(--text-primary);
background-color: var(--background-modifier-hover);
}

View File

@@ -0,0 +1,369 @@
/**
* MobileExpressionPickerSheet — the mobile emoji drawer. Ported from
* the new UI so the mobile layout matches: segmented tab selector at
* the top (GIFs / Media / Stickers / Emojis — only Emojis wired),
* search bar, collapsible category sections, and a bottom icon bar
* for jump-to-category navigation. Opens from the composer's emoji
* button when `useIsMobile()` is true, replacing the desktop
* portal-based picker.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bicycle,
BowlFood,
CaretRight,
Flag,
GameController,
Heart,
Leaf,
Magnet,
MagnifyingGlass,
Smiley,
X,
} from '@phosphor-icons/react';
import { BottomSheet } from '@discord-clone/ui';
import { emojiToCodepoint, getTwemojiUrl } from '../../utils/twemoji';
import emojiData from '@app/data/emojis.json';
import type { EmojiPickerValue } from './EmojiPicker';
import styles from './MobileExpressionPickerSheet.module.css';
interface EmojiEntry {
names: string[];
surrogates: string;
}
type EmojiCategory = keyof typeof emojiData;
const CATEGORIES: Array<{
key: EmojiCategory;
label: string;
icon: React.ComponentType<{ size?: number; weight?: 'regular' | 'fill' | 'bold' }>;
}> = [
{ key: 'people' as EmojiCategory, label: 'People', icon: Smiley },
{ key: 'nature' as EmojiCategory, label: 'Nature', icon: Leaf },
{ key: 'food' as EmojiCategory, label: 'Food & Drink', icon: BowlFood },
{ key: 'activity' as EmojiCategory, label: 'Activities', icon: GameController },
{ key: 'travel' as EmojiCategory, label: 'Travel & Places', icon: Bicycle },
{ key: 'objects' as EmojiCategory, label: 'Objects', icon: Magnet },
{ key: 'symbols' as EmojiCategory, label: 'Symbols', icon: Heart },
{ key: 'flags' as EmojiCategory, label: 'Flags', icon: Flag },
];
type Tab = 'gifs' | 'media' | 'stickers' | 'emojis';
const TABS: Array<{ id: Tab; label: string; enabled: boolean }> = [
{ id: 'gifs', label: 'GIFs', enabled: false },
{ id: 'media', label: 'Media', enabled: false },
{ id: 'stickers', label: 'Stickers', enabled: false },
{ id: 'emojis', label: 'Emojis', enabled: true },
];
interface MobileExpressionPickerSheetProps {
isOpen: boolean;
onClose: () => void;
onSelect: (value: EmojiPickerValue) => void;
initialTab?: Tab;
}
function TwemojiTile({ surrogates }: { surrogates: string }) {
return (
<img
src={getTwemojiUrl(surrogates)}
alt={surrogates}
className={styles.emojiImg}
loading="lazy"
draggable={false}
/>
);
}
export function MobileExpressionPickerSheet({
isOpen,
onClose,
onSelect,
initialTab = 'emojis',
}: MobileExpressionPickerSheetProps) {
const [activeTab, setActiveTab] = useState<Tab>(initialTab);
const [search, setSearch] = useState('');
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [activeCategory, setActiveCategory] = useState<string>('people');
const searchRef = useRef<HTMLInputElement>(null);
const tabBarRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const categoryRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Snap to the caller's preferred tab every time the sheet opens fresh.
useEffect(() => {
if (isOpen) {
setActiveTab(initialTab);
setSearch('');
}
}, [isOpen, initialTab]);
// Focus the search input once the slide-in animation settles.
useEffect(() => {
if (!isOpen) return;
const t = setTimeout(() => searchRef.current?.focus(), 260);
return () => clearTimeout(t);
}, [isOpen]);
const allEmojis = useMemo(() => {
const result: Record<string, EmojiEntry[]> = {};
for (const cat of CATEGORIES) {
const entries: EmojiEntry[] = (emojiData as any)[cat.key] || [];
result[cat.key] = entries;
}
return result;
}, []);
const filtered = useMemo(() => {
if (!search.trim()) return null;
const q = search.toLowerCase();
const unicodeResults: EmojiEntry[] = [];
for (const cat of Object.values(allEmojis)) {
for (const emoji of cat) {
if (emoji.names.some((n) => n.includes(q))) {
unicodeResults.push(emoji);
}
}
}
return { unicodeResults };
}, [search, allEmojis]);
const handleSelectUnicode = useCallback(
(entry: EmojiEntry) => {
onSelect({
kind: 'unicode',
surrogates: entry.surrogates,
name: entry.names[0] ?? entry.surrogates,
});
onClose();
},
[onSelect, onClose],
);
const toggleCategory = (key: string) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const scrollToCategory = (key: string) => {
setCollapsed((prev) => {
if (!prev.has(key)) return prev;
const next = new Set(prev);
next.delete(key);
return next;
});
setActiveCategory(key);
requestAnimationFrame(() => {
const el = categoryRefs.current[key];
const body = bodyRef.current;
if (!el || !body) return;
const bodyRect = body.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const target = body.scrollTop + (elRect.top - bodyRect.top);
body.scrollTo({ top: target, behavior: 'smooth' });
});
};
const handleBodyScroll = () => {
if (search) return;
const body = bodyRef.current;
if (!body) return;
const bodyTop = body.getBoundingClientRect().top;
let current = activeCategory;
for (const cat of CATEGORIES) {
const el = categoryRefs.current[cat.key];
if (!el) continue;
const elTop = el.getBoundingClientRect().top - bodyTop;
if (elTop <= 24) current = cat.key;
}
if (current !== activeCategory) setActiveCategory(current);
};
const activeIndex = TABS.findIndex((t) => t.id === activeTab);
const tabBackgroundStyle: React.CSSProperties = {
width: `calc((100% - 6px) / ${TABS.length})`,
left: `calc(3px + (100% - 6px) * ${activeIndex} / ${TABS.length})`,
};
return (
<BottomSheet isOpen={isOpen} onClose={onClose} disableDefaultHeader showHandle>
<div className={styles.root}>
{/* Segmented tab selector */}
<div className={styles.tabBar} ref={tabBarRef} role="tablist">
<div className={styles.tabBackground} style={tabBackgroundStyle} />
{TABS.map((tab) => {
const isActive = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
className={`${styles.tab} ${isActive ? styles.tabActive : ''} ${
!tab.enabled ? styles.tabDisabled : ''
}`}
onClick={() => {
if (tab.enabled) setActiveTab(tab.id);
}}
>
{tab.label}
</button>
);
})}
</div>
<div className={styles.searchBar}>
<MagnifyingGlass
size={18}
weight="regular"
className={styles.searchIcon}
/>
<input
ref={searchRef}
className={styles.searchInput}
placeholder="Find the emoji of your dreams"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className={styles.searchClear}
onClick={() => setSearch('')}
aria-label="Clear search"
>
<X size={12} weight="bold" />
</button>
)}
</div>
<div
className={styles.body}
ref={bodyRef}
onScroll={handleBodyScroll}
>
{activeTab !== 'emojis' ? (
<div className={styles.comingSoon}>Coming Soon</div>
) : filtered ? (
<div className={styles.searchResults}>
<div className={styles.searchResultsLabel}>Results</div>
{filtered.unicodeResults.length === 0 ? (
<div className={styles.noResults}>No emoji found</div>
) : (
<div className={styles.categoryGrid}>
{filtered.unicodeResults.map((emoji, idx) => (
<button
key={`${emoji.surrogates}-${idx}`}
type="button"
className={styles.emojiButton}
onClick={() => handleSelectUnicode(emoji)}
>
<TwemojiTile surrogates={emoji.surrogates} />
</button>
))}
</div>
)}
</div>
) : (
<>
{CATEGORIES.map((cat) => (
<CategorySection
key={cat.key}
title={cat.label}
isCollapsed={collapsed.has(cat.key)}
onToggle={() => toggleCategory(cat.key)}
sectionRef={(el) => {
categoryRefs.current[cat.key] = el;
}}
>
{allEmojis[cat.key].map((emoji, idx) => (
<button
key={`${cat.key}-${emoji.surrogates}-${idx}`}
type="button"
className={styles.emojiButton}
onClick={() => handleSelectUnicode(emoji)}
>
<TwemojiTile surrogates={emoji.surrogates} />
</button>
))}
</CategorySection>
))}
</>
)}
</div>
{activeTab === 'emojis' && !search && (
<div className={styles.bottomCategoryBar}>
{CATEGORIES.map((cat) => {
const Icon = cat.icon;
return (
<button
key={cat.key}
type="button"
className={`${styles.bottomCategoryButton} ${
activeCategory === cat.key
? styles.bottomCategoryButtonActive
: ''
}`}
onClick={() => scrollToCategory(cat.key)}
aria-label={cat.label}
>
<Icon size={20} weight="fill" />
</button>
);
})}
</div>
)}
</div>
{/* Silence the unused import warning — emojiToCodepoint is
referenced elsewhere in TwemojiTile variants and kept for
parity with the desktop picker's helpers. */}
<span style={{ display: 'none' }}>{emojiToCodepoint('')}</span>
</BottomSheet>
);
}
interface CategorySectionProps {
title: string;
isCollapsed: boolean;
onToggle: () => void;
children: React.ReactNode;
sectionRef?: (el: HTMLDivElement | null) => void;
}
function CategorySection({
title,
isCollapsed,
onToggle,
children,
sectionRef,
}: CategorySectionProps) {
return (
<div className={styles.categorySection} ref={sectionRef}>
<button
type="button"
className={styles.categoryHeader}
onClick={onToggle}
aria-expanded={!isCollapsed}
>
<CaretRight
size={14}
weight="bold"
className={`${styles.categoryCaret} ${
!isCollapsed ? styles.categoryCaretOpen : ''
}`}
/>
<span>{title}</span>
</button>
{!isCollapsed && <div className={styles.categoryGrid}>{children}</div>}
</div>
);
}

View File

@@ -0,0 +1,83 @@
/* Mirrors MobileMessageActionsSheet.module.css so the two mobile
action sheets share a visual language. Same body padding, same
rounded-card action list, same inset divider trick between rows. */
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 16px 32px;
}
.title {
text-align: center;
font-size: 14px;
font-weight: 700;
color: var(--text-primary-muted, #a0a3a8);
padding-bottom: 4px;
border-bottom: 1px solid var(--background-header-secondary);
margin: 0 -16px 4px;
padding: 0 16px 12px;
}
.actionList {
display: flex;
flex-direction: column;
gap: 0;
border-radius: 12px;
overflow: hidden;
}
.actionItem {
position: relative;
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 16px;
background-color: var(--background-secondary-alt);
border: none;
border-radius: 0;
color: var(--text-primary, #fff);
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.actionItem:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
}
.actionItem svg {
flex-shrink: 0;
color: var(--text-primary-muted, #a0a3a8);
}
/* Inset 1px divider between adjacent rows in the same card — same
trick MobileMessageActionsSheet uses so the rows read as a
single grouped card instead of floating buttons. */
.actionItem:not(:last-child)::after {
content: '';
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
/* Destructive variant — Fluxer's exact delete red, tied to the
saturation-factor token so it tracks theme changes. Overrides
both the label and the icon color. */
.actionItemDanger {
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}
.actionItemDanger svg {
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}

View File

@@ -0,0 +1,128 @@
/**
* MobileImageActionsSheet — three-dots menu opened from the mobile
* ImageLightbox header. Renders a Fluxer-style grouped action list
* inside a `BottomSheet`, same visual idiom as the existing
* MobileMessageActionsSheet so the two sheets feel like siblings.
*
* Group layout (matches the user's spec, not the richer reference):
* 1. Favorite / Remove from Favorites (single-row group)
* 2. Save Image • Open Link (two-row group)
* 3. Delete Message (danger, single-row group)
*
* Each row only renders when its callback is actually provided so
* callers with limited capability (e.g. the pinned-message popover,
* which can't delete the source message) can drop rows without
* leaving empty groups behind.
*/
import { BottomSheet } from '@brycord/ui';
import {
Star,
Download,
ArrowSquareOut,
Trash,
} from '@phosphor-icons/react';
import styles from './MobileImageActionsSheet.module.css';
interface MobileImageActionsSheetProps {
isOpen: boolean;
onClose: () => void;
isFavorited: boolean;
onToggleFavorite?: () => void;
onSaveImage?: () => void;
onOpenLink?: () => void;
/** When provided, a red "Delete Message" row is appended at the
* bottom. Lets callers hide the row entirely when the current
* user doesn't have permission or when the lightbox is rendered
* from a context with no parent message (pinned popover). */
onDeleteMessage?: () => void;
}
export function MobileImageActionsSheet({
isOpen,
onClose,
isFavorited,
onToggleFavorite,
onSaveImage,
onOpenLink,
onDeleteMessage,
}: MobileImageActionsSheetProps) {
// Wrap each action so tapping always closes the sheet after the
// handler fires. Mirrors the pattern in MobileMessageActionsSheet.
const wrap = (fn?: () => void) => () => {
if (!fn) return;
fn();
onClose();
};
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
disableDefaultHeader
showHandle
initialHeightSvh={40}
expandable
zIndex={17000}
>
<div className={styles.body}>
<div className={styles.title}>Media Options</div>
{onToggleFavorite && (
<div className={styles.actionList}>
<button
type="button"
className={styles.actionItem}
onClick={wrap(onToggleFavorite)}
>
<Star
size={20}
weight={isFavorited ? 'fill' : 'regular'}
/>
<span>
{isFavorited ? 'Remove from Favorites' : 'Add to Favorites'}
</span>
</button>
</div>
)}
{(onSaveImage || onOpenLink) && (
<div className={styles.actionList}>
{onSaveImage && (
<button
type="button"
className={styles.actionItem}
onClick={wrap(onSaveImage)}
>
<Download size={20} weight="regular" />
<span>Save Image</span>
</button>
)}
{onOpenLink && (
<button
type="button"
className={styles.actionItem}
onClick={wrap(onOpenLink)}
>
<ArrowSquareOut size={20} weight="regular" />
<span>Open Link</span>
</button>
)}
</div>
)}
{onDeleteMessage && (
<div className={styles.actionList}>
<button
type="button"
className={`${styles.actionItem} ${styles.actionItemDanger}`}
onClick={wrap(onDeleteMessage)}
>
<Trash size={20} weight="fill" />
<span>Delete Message</span>
</button>
</div>
)}
</div>
</BottomSheet>
);
}

View File

@@ -0,0 +1,108 @@
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 16px 32px;
}
/* Quick reactions row — the row itself is transparent; each button
is its own rounded tile on top of the drawer background, matching
the Fluxer mobile layout. */
.quickRow {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.quickButton {
display: flex;
align-items: center;
justify-content: center;
min-height: 56px;
padding: 0;
background-color: var(--background-modifier-hover);
border: none;
border-radius: 10px;
color: var(--text-primary, #fff);
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.quickButton:active {
background-color: var(--background-modifier-active, rgba(255, 255, 255, 0.08));
}
.quickEmoji {
width: 28px;
height: 28px;
display: block;
pointer-events: none;
}
/* Action list — items sit flush with no outer gap. The whole list
is a single rounded card; a 1px divider (inset by 1rem on each
side) separates adjacent items. First / last items get their
rounded corners via the parent's overflow: hidden. */
.actionList {
display: flex;
flex-direction: column;
gap: 0;
border-radius: 12px;
overflow: hidden;
}
.actionItem {
position: relative;
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 16px 16px;
background-color: var(--background-secondary-alt);
border: none;
border-radius: 0;
color: var(--text-primary, #fff);
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.actionItem:active {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
}
.actionItem svg {
flex-shrink: 0;
color: var(--text-primary-muted, #a0a3a8);
}
/* Thin inset divider between adjacent items — drawn as an absolutely
positioned element at the bottom of every item except the last. */
.actionItem:not(:last-child)::after {
content: '';
position: absolute;
left: 1rem;
right: 1rem;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
.actionItemDanger {
/* Fluxer's exact delete red — HSL tied to saturation-factor so
it follows the theme's saturation setting in both dark and
light modes. Overrides both the text and icon color. */
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}
.actionItemDanger svg {
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}

View File

@@ -0,0 +1,232 @@
/**
* MobileMessageActionsSheet — long-press menu for messages on mobile.
* Mirrors the action set in `MessageActionBar` (the desktop hover bar
* + its More menu) but laid out as a bottom sheet drawer instead of a
* floating popover, since hover-based UI doesn't work on touch.
*
* Layout matches Discord/Fluxer mobile:
* ┌────── drag handle ──────┐
* │ [👍] [👌] [🎉] [❤] [+] │ ← quick reactions row
* ├──────────────────────────┤
* │ ☺ Add Reaction │
* │ ↩ Reply │
* │ → Forward │
* │ ✎ Edit Message │ (own messages only)
* │ 📌 Pin Message │
* │ ⧉ Copy Text │
* │ 🔍 Inspect Message │
* │ 🗑 Delete Message │ (own messages or admins)
* └──────────────────────────┘
*
* Reuses `BottomSheet` from `@brycord/ui` (same component the emoji
* picker and channel-details drawer use). Each action call closes
* the sheet immediately so the user sees the result of their tap
* without an extra dismiss step.
*/
import {
Smiley,
ArrowBendUpLeft,
ArrowBendUpRight,
PencilSimple,
Trash,
PushPin,
Copy,
MagnifyingGlass,
Plus,
} from '@phosphor-icons/react';
import { BottomSheet } from '@discord-clone/ui';
import styles from './MobileMessageActionsSheet.module.css';
// Mirrors the QUICK_EMOJIS in MessageActionBar so users get the same
// shortcuts on mobile as on desktop. Adding a 4th gives the row
// better visual rhythm with the trailing "+" tile (5 cells total).
const QUICK_EMOJIS = [
{ emoji: '👍', name: 'thumbsup' },
{ emoji: '👌', name: 'ok_hand' },
{ emoji: '🎉', name: 'tada' },
{ emoji: '❤️', name: 'heart' },
];
function emojiToCodepoint(emoji: string): string {
const codepoints: string[] = [];
for (let i = 0; i < emoji.length; i++) {
const code = emoji.codePointAt(i)!;
if (code === 0xfe0f) continue;
codepoints.push(code.toString(16));
if (code > 0xffff) i++;
}
return codepoints.join('-');
}
interface MobileMessageActionsSheetProps {
isOpen: boolean;
onClose: () => void;
isOwnMessage: boolean;
canDelete: boolean;
hasContent: boolean;
/** Quick reaction handler — receives the unicode emoji string. */
onQuickReact: (emoji: string) => void;
/** Opens the full reaction picker. */
onAddReaction: () => void;
onReply: () => void;
onForward: () => void;
onEdit?: () => void;
onPin?: () => void;
/**
* True when the target message is currently pinned. Flips the
* "Pin Message" label to "Unpin Message" so the action mirrors
* its resulting state.
*/
isPinned?: boolean;
onCopyText?: () => void;
onInspect?: () => void;
onDelete?: () => void;
}
export function MobileMessageActionsSheet({
isOpen,
onClose,
isOwnMessage,
canDelete,
hasContent,
onQuickReact,
onAddReaction,
onReply,
onForward,
onEdit,
onPin,
isPinned,
onCopyText,
onInspect,
onDelete,
}: MobileMessageActionsSheetProps) {
// Wrap each action so the sheet always closes after a selection.
// Returning a no-arg handler keeps the JSX clean below.
const wrap = (fn?: () => void) => () => {
if (!fn) return;
fn();
onClose();
};
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
disableDefaultHeader
showHandle
// Open at half height by default. The user can drag the
// handle UP to expand toward the full sheet height if
// they need to see all the actions, or down past the
// threshold to dismiss.
initialHeightSvh={50}
expandable
>
<div className={styles.body}>
{/* Quick emoji row */}
<div className={styles.quickRow}>
{QUICK_EMOJIS.map((qe) => (
<button
key={qe.name}
type="button"
className={styles.quickButton}
onClick={() => {
onQuickReact(qe.emoji);
onClose();
}}
aria-label={`React with :${qe.name}:`}
>
<img
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${emojiToCodepoint(qe.emoji)}.svg`}
alt={qe.emoji}
className={styles.quickEmoji}
draggable={false}
/>
</button>
))}
<button
type="button"
className={styles.quickButton}
onClick={wrap(onAddReaction)}
aria-label="Add reaction"
>
<Plus size={22} weight="bold" />
</button>
</div>
{/* Group 1 — Add Reaction lives alone so it visually
echoes the quick reactions row above it. */}
<div className={styles.actionList}>
<button type="button" className={styles.actionItem} onClick={wrap(onAddReaction)}>
<Smiley size={20} weight="fill" />
<span>Add Reaction</span>
</button>
</div>
{/* Group 2 — message interaction actions (Reply /
Forward / Edit). Edit only shows on own messages. */}
<div className={styles.actionList}>
<button type="button" className={styles.actionItem} onClick={wrap(onReply)}>
<ArrowBendUpLeft size={20} weight="fill" />
<span>Reply</span>
</button>
<button type="button" className={styles.actionItem} onClick={wrap(onForward)}>
<ArrowBendUpRight size={20} weight="fill" />
<span>Forward</span>
</button>
{isOwnMessage && onEdit && (
<button type="button" className={styles.actionItem} onClick={wrap(onEdit)}>
<PencilSimple size={20} weight="fill" />
<span>Edit Message</span>
</button>
)}
</div>
{/* Group 3 — mod / own-message actions (Pin / Delete).
Only renders when at least one of the two is
available, so viewers with no power on someone
else's message don't see an empty card. */}
{(onPin || (canDelete && onDelete)) && (
<div className={styles.actionList}>
{onPin && (
<button type="button" className={styles.actionItem} onClick={wrap(onPin)}>
<PushPin size={20} weight="fill" />
<span>{isPinned ? 'Unpin Message' : 'Pin Message'}</span>
</button>
)}
{canDelete && onDelete && (
<button
type="button"
className={`${styles.actionItem} ${styles.actionItemDanger}`}
onClick={wrap(onDelete)}
>
<Trash size={20} weight="fill" />
<span>Delete Message</span>
</button>
)}
</div>
)}
{/* Group 4 — text / debug utilities (Copy Text +
Inspect Message). Copy Text is only shown when
there's actual text to copy — not for pure
attachment messages. */}
{((hasContent && onCopyText) || onInspect) && (
<div className={styles.actionList}>
{hasContent && onCopyText && (
<button type="button" className={styles.actionItem} onClick={wrap(onCopyText)}>
<Copy size={20} weight="fill" />
<span>Copy Text</span>
</button>
)}
{onInspect && (
<button type="button" className={styles.actionItem} onClick={wrap(onInspect)}>
<MagnifyingGlass size={20} weight="fill" />
<span>Inspect Message</span>
</button>
)}
</div>
)}
</div>
</BottomSheet>
);
}

View File

@@ -0,0 +1,68 @@
/* ── Mobile pin actions sheet ────────────────────────────────────────
Bottom sheet opened by long-pressing a pinned message in the
ChannelDetailsDrawer Pins tab. Currently only has one action
("Unpin Message"), but structured as a card stack so we can
drop in "Jump to Message" / "Copy Link" etc. later without
reshuffling the layout. */
.body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 16px 32px;
}
.actionList {
background-color: var(--background-secondary-alt);
border-radius: 0.75rem;
overflow: hidden;
}
.actionItem {
position: relative;
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 16px;
background: transparent;
border: none;
color: var(--text-primary);
font: inherit;
font-size: 15px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition: background-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.actionItem:active {
background-color: var(--background-modifier-hover);
}
.actionItem svg {
flex-shrink: 0;
color: var(--text-primary-muted);
}
.actionItem:not(:last-child)::after {
content: '';
position: absolute;
left: calc(16px + 20px + 14px);
right: 16px;
bottom: 0;
height: 1px;
background-color: var(--background-header-secondary);
pointer-events: none;
}
/* Danger variant — Unpin Message. Fluxer reference uses the same
red HSL as Delete Message in the message actions sheet. */
.actionItemDanger {
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}
.actionItemDanger svg {
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
}

View File

@@ -0,0 +1,65 @@
/**
* MobilePinActionsSheet — bottom sheet opened by long-pressing a
* pinned message in the mobile Pins tab. Mirrors the pattern used
* by MobileMessageActionsSheet: a rounded card of danger-styled
* action rows, with inset dividers between them.
*
* Currently only has one action ("Unpin Message") since that's the
* only contextual action the pinned list needs — jumping to the
* message is already the tap behaviour on the card itself, and
* there's no "reply to pin" or "react to pin" concept. Structured
* as a card stack so future actions can drop in without reshuffling.
*/
import { PushPinSlash } from '@phosphor-icons/react';
import { BottomSheet } from '@brycord/ui';
import styles from './MobilePinActionsSheet.module.css';
interface MobilePinActionsSheetProps {
isOpen: boolean;
onClose: () => void;
/** True when the local user has permission to unpin — the sheet
* simply doesn't render the Unpin action otherwise. */
canUnpin: boolean;
onUnpin: () => void;
}
export function MobilePinActionsSheet({
isOpen,
onClose,
canUnpin,
onUnpin,
}: MobilePinActionsSheetProps) {
// Wrap each action so tapping it always closes the sheet. Mirrors
// the pattern in MobileMessageActionsSheet so behaviour is
// consistent across both bottom sheets.
const wrap = (fn: () => void) => () => {
fn();
onClose();
};
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
disableDefaultHeader
showHandle
initialHeightSvh={25}
expandable
>
<div className={styles.body}>
{canUnpin && (
<div className={styles.actionList}>
<button
type="button"
className={`${styles.actionItem} ${styles.actionItemDanger}`}
onClick={wrap(onUnpin)}
>
<PushPinSlash size={20} weight="fill" />
<span>Unpin Message</span>
</button>
</div>
)}
</div>
</BottomSheet>
);
}

View File

@@ -0,0 +1,278 @@
/* ── Pending attachment row ──────────────────────────────────
Horizontal strip of cards that sits directly above the chat
input. Horizontally scrollable when the user has too many
files for the visible width, with a subtle bottom border so
the row visually detaches from the textarea pill below it. */
.row {
display: flex;
gap: 10px;
/* Top padding is bumped so the floating action bar (which
hangs above each card by 14px) has room to live inside
the row's own clip box — `overflow-x: auto` forces
overflow-y to also clip, so we can't just let it leak. */
padding: 24px 16px 12px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: var(--background-modifier-accent) transparent;
}
.row::-webkit-scrollbar {
height: 6px;
}
.row::-webkit-scrollbar-track {
background: transparent;
}
.row::-webkit-scrollbar-thumb {
background-color: var(--background-modifier-accent);
border-radius: 999px;
}
/* ── Individual card ────────────────────────────────────────── */
.card {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
min-width: 200px;
max-width: 200px;
min-height: 200px;
max-height: 200px;
padding: 8px;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-accent);
border-radius: 0.5rem;
transition: border-color 0.12s;
box-sizing: border-box;
}
.card:hover {
border-color: var(--background-modifier-hover);
}
.cardSpoiler {
border-color: var(--brand-primary);
}
.cardError {
border-color: hsl(0, calc(70% * var(--saturation-factor, 1)), 55%);
}
/* ── Preview area ───────────────────────────────────────────── */
.preview {
position: relative;
flex: 1;
width: 100%;
min-height: 0;
background-color: var(--background-secondary-alt, var(--background-secondary));
border-radius: 0.375rem;
overflow: hidden;
}
.previewImage,
.previewVideo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.iconPreview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-muted, #a0a3a8);
}
/* Small play badge over video preview thumbnails so it reads
as playable (Fluxer pattern). */
.videoPlayBadge {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.55);
border-radius: 50%;
color: #fff;
pointer-events: none;
}
/* Spoiler overlay — dark film + eye-slash icon + "SPOILER"
label so the card reads as hidden even though it's staged. */
.spoilerOverlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
background-color: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.1em;
pointer-events: none;
}
.altBadge {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 6px;
background-color: rgba(0, 0, 0, 0.65);
border-radius: 4px;
color: #fff;
font-size: 0.625rem;
font-weight: 800;
letter-spacing: 0.06em;
}
/* ── Always-visible action bar (Spoiler / Edit / Delete) ────
Floating pill that hangs above the card's top-right edge,
matching MessageActionBar's visual language:
`--background-primary` surface with a 1px
`--background-header-secondary` border and 8px radius.
Lives as a direct child of the card (not .preview) so it
can escape the preview's overflow clip. The row reserves
enough top padding for the overhang to avoid being clipped
by its own `overflow-x: auto` clip box. */
.actions {
position: absolute;
top: -14px;
right: 8px;
display: flex;
align-items: center;
padding: 2px;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 8px;
z-index: 3;
}
.actionButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 30px;
padding: 4px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
}
.actionButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.actionButtonActive {
color: var(--brand-primary);
}
.actionButtonActive:hover {
background-color: var(--background-modifier-hover);
color: var(--brand-primary);
filter: brightness(1.1);
}
.actionButtonDanger {
color: var(--status-danger);
}
.actionButtonDanger:hover {
background-color: var(--background-modifier-hover);
color: var(--status-danger);
}
/* ── Upload progress bar ─────────────────────────────────────
Fixed to the bottom of the preview area. Track is the same
--background-modifier-accent as the card border; fill is
brand-primary and scales via a --progress CSS variable so
we never reach into the DOM to mutate widths. */
.progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background-color: rgba(0, 0, 0, 0.55);
overflow: hidden;
z-index: 1;
}
.progressFill {
height: 100%;
width: var(--progress, 0%);
background-color: var(--brand-primary);
transition: width 0.12s linear;
}
/* ── Meta row below the preview ─────────────────────────── */
.meta {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 4px 0;
background-color: var(--background-primary);
}
.filename {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Bottom row of the meta — size on the left, uppercase file-type
badge flush to the right. Matches the Fluxer pattern of
surfacing the extension as a brand-primary label. */
.metaBottomRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.size {
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-primary-muted, #a0a3a8);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ext {
font-size: 0.6875rem;
font-weight: 800;
letter-spacing: 0.04em;
color: var(--brand-primary-light);
flex-shrink: 0;
}
.cardError .size {
color: hsl(0, calc(70% * var(--saturation-factor, 1)), 60%);
}

View File

@@ -0,0 +1,105 @@
import { FileAudio, FileText, FileVideo, X } from '@phosphor-icons/react';
import styles from './PendingAttachmentRow.module.css';
export interface PendingAttachment {
id: string;
file: File;
previewUrl?: string | null;
progress?: number | null;
error?: string | null;
}
interface PendingAttachmentRowProps {
attachments: PendingAttachment[];
onRemove: (id: string) => void;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function fileExt(name: string): string {
const idx = name.lastIndexOf('.');
return idx >= 0 ? name.slice(idx + 1).toUpperCase() : 'FILE';
}
/**
* Horizontal strip of staged attachments shown above the composer.
* Each card has a preview (image / video / icon), filename + size,
* a remove button, and an optional progress bar during upload.
*/
export function PendingAttachmentRow({ attachments, onRemove }: PendingAttachmentRowProps) {
if (attachments.length === 0) return null;
return (
<div className={styles.row}>
{attachments.map((att) => {
const kind = att.file.type.split('/')[0];
const hasProgress = att.progress !== null && att.progress !== undefined;
return (
<div
key={att.id}
className={`${styles.card} ${att.error ? styles.cardError : ''}`}
>
<div className={styles.preview}>
{kind === 'image' && att.previewUrl ? (
<img
src={att.previewUrl}
alt={att.file.name}
className={styles.previewImage}
/>
) : kind === 'video' && att.previewUrl ? (
<video src={att.previewUrl} className={styles.previewVideo} muted />
) : kind === 'audio' ? (
<div className={styles.iconPreview}>
<FileAudio size={32} weight="regular" />
</div>
) : kind === 'video' ? (
<div className={styles.iconPreview}>
<FileVideo size={32} weight="regular" />
</div>
) : (
<div className={styles.iconPreview}>
<FileText size={32} weight="regular" />
</div>
)}
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.actionButton} ${styles.actionButtonDanger}`}
onClick={() => onRemove(att.id)}
aria-label="Remove attachment"
title="Remove"
>
<X size={16} weight="bold" />
</button>
</div>
{hasProgress && (
<div className={styles.progress}>
<div
className={styles.progressFill}
style={{ width: `${Math.round((att.progress ?? 0) * 100)}%` }}
/>
</div>
)}
<div className={styles.meta}>
<div className={styles.filename} title={att.file.name}>
{att.file.name}
</div>
<div className={styles.metaBottomRow}>
<span className={styles.size}>{formatBytes(att.file.size)}</span>
<span className={styles.ext}>{fileExt(att.file.name)}</span>
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,119 @@
/* ── Pin confirmation modal ──────────────────────────────────────────
Fluxer-style "Pin it. Pin it good." dialog shown when the user
taps Pin Message from either the desktop action bar or the
mobile long-press sheet. Uses the shared Modal primitive for
the chrome (backdrop, centring, size), so the styling here is
just the content layout: description + static preview card +
two stacked action buttons. */
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0;
}
/* Override the shared Modal.Header — strip its default 1px
bottom divider so the title sits flush against the body. The
title + description + preview form one cohesive block, and
the divider added visual noise between the two. */
.headerFlush {
border-bottom: none;
}
.description {
font-size: 0.9375rem;
line-height: 1.4;
color: var(--text-secondary);
margin: 0;
}
/* The PinnedMessageRow card already has its own margin/padding;
wrap it in a container that resets the horizontal margin so the
preview spans the modal body cleanly. */
.previewWrap {
/* Zero out the 12px horizontal margin PinnedMessageRow adds so
the preview card aligns with the modal body padding. */
margin: 0 -12px;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.primaryButton {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
background-color: var(--brand-primary);
border: none;
border-radius: 0.75rem;
color: #fff;
font: inherit;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.15s;
-webkit-tap-highlight-color: transparent;
}
.primaryButton:active:not(:disabled) {
filter: brightness(0.92);
}
.primaryButton:disabled {
opacity: 0.55;
cursor: default;
}
/* Danger variant — used by the unpin confirmation. Uses the
global `--button-danger-fill` token so the red matches every
other destructive button in the app (and picks up theme
changes for free). Active state darkens via
`--button-danger-active-fill`. */
.primaryButtonDanger {
background-color: var(--button-danger-fill);
}
.primaryButtonDanger:hover:not(:disabled) {
filter: brightness(1.05);
}
.primaryButtonDanger:active:not(:disabled) {
background-color: var(--button-danger-active-fill);
filter: none;
}
.secondaryButton {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 16px;
background-color: var(--background-secondary-alt);
border: none;
border-radius: 0.75rem;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.15s;
-webkit-tap-highlight-color: transparent;
}
.secondaryButton:hover,
.secondaryButton:active {
background-color: var(--background-modifier-hover);
}
.error {
padding: 10px 14px;
background-color: hsl(0, calc(60% * var(--saturation-factor)), 22%);
color: hsl(0, calc(80% * var(--saturation-factor)), 85%);
border-radius: 0.5rem;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,136 @@
/**
* PinConfirmationModal — confirmation dialog for both the pin and
* unpin flows. Renders a read-only `PinnedMessageRow` preview of the
* target message, a variant-specific headline + description, and a
* primary confirm button (danger-styled for unpin).
*
* - `'pin'` → "Pin it. Pin it good." + blue primary button
* - `'unpin'` → "Unpin Message" + red primary button
*
* Calls `api.messages.setPinned` on confirm.
*/
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { Modal } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { PinnedMessageRow, type PinnedMessage } from './PinnedMessageRow';
import styles from './PinConfirmationModal.module.css';
export type PinConfirmationVariant = 'pin' | 'unpin';
interface PinConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
channelId: string;
messageId: string | null;
message: PinnedMessage | null;
variant?: PinConfirmationVariant;
}
const VARIANT_COPY: Record<
PinConfirmationVariant,
{
title: string;
description: string;
primaryLabel: string;
busyLabel: string;
failedLabel: string;
}
> = {
pin: {
title: 'Pin it. Pin it good.',
description:
"Pin this message to the channel for all to see. Unless you're chicken.",
primaryLabel: 'Pin it real good',
busyLabel: 'Pinning…',
failedLabel: 'Failed to pin message.',
},
unpin: {
title: 'Unpin Message',
description: 'Do you want to send this pin back in time?',
primaryLabel: 'Unpin it',
busyLabel: 'Unpinning…',
failedLabel: 'Failed to unpin message.',
},
};
export function PinConfirmationModal({
isOpen,
onClose,
channelId,
messageId,
message,
variant = 'pin',
}: PinConfirmationModalProps) {
const setPinned = useMutation(api.messages.pin);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const copy = VARIANT_COPY[variant];
const handleConfirm = async () => {
if (!messageId || busy) return;
setBusy(true);
setError(null);
try {
await setPinned({
id: messageId as Id<'messages'>,
pinned: variant === 'pin',
});
onClose();
} catch (err: any) {
setError(err?.message || copy.failedLabel);
} finally {
setBusy(false);
}
};
// Silence unused import lint — channelId is kept on the props
// surface for future bulk-unpin flows that need scoping.
void channelId;
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="small">
<Modal.Header
title={copy.title}
onClose={onClose}
className={styles.headerFlush}
/>
<Modal.Content>
<div className={styles.body}>
<p className={styles.description}>{copy.description}</p>
{message && (
<div className={styles.previewWrap}>
<PinnedMessageRow message={message} />
</div>
)}
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
type="button"
className={`${styles.primaryButton} ${
variant === 'unpin' ? styles.primaryButtonDanger : ''
}`}
onClick={handleConfirm}
disabled={busy || !messageId}
>
{busy ? copy.busyLabel : copy.primaryLabel}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={onClose}
disabled={busy}
>
Cancel
</button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -0,0 +1,250 @@
/* Shared card used by the mobile Pins tab, the desktop pins
popover, and the Pin-it-good confirmation modal. Renders a
pinned message with its author, timestamp, text content, and
any image / file attachments inline. Each row is its own
rounded `--background-secondary` surface so the list reads
as a stack of cards (matches the Fluxer reference). */
.card {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
margin: 6px 12px;
background-color: var(--background-secondary);
border: 1px solid var(--background-header-secondary);
border-radius: 0.625rem;
color: var(--text-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.1s, border-color 0.1s;
-webkit-tap-highlight-color: transparent;
width: auto;
}
.card:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-accent);
}
.cardStatic {
cursor: default;
}
.cardStatic:hover {
background-color: var(--background-secondary);
border-color: var(--background-header-secondary);
}
/* Mobile (≤768px) overrides — the Pins tab lives inside the
ChannelDetailsDrawer, whose own sheet surface already uses
`--background-secondary`, so a card on that same color would
disappear into the background. Flip to
`--background-modifier-hover` (hsla(220, 13%, 100%, 0.05) — a
translucent white wash) so each card visually lifts off the
drawer surface. Desktop keeps the default `--background-secondary`
because the popover uses `--background-primary` as its own
surface and the card stands out against it naturally. */
@media (max-width: 768px) {
.card {
background-color: var(--background-modifier-hover);
}
.cardStatic:hover {
background-color: var(--background-modifier-hover);
}
}
.avatar {
flex-shrink: 0;
padding-top: 2px;
}
.body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: nowrap;
}
.author {
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timestamp {
font-size: 0.75rem;
color: var(--text-primary-muted);
font-weight: 500;
flex-shrink: 0;
}
/* Desktop hover actions — sit at the far right of the meta row
(pushed there via `margin-left: auto`) and fade in on card
hover. Hidden by default so the author + timestamp read
cleanly when the card is idle. */
.hoverActions {
display: flex;
align-items: center;
gap: 6px;
margin-left: auto;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0.12s ease;
flex-shrink: 0;
}
.card:hover .hoverActions,
.card:focus-within .hoverActions {
opacity: 1;
visibility: visible;
}
.jumpButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 0.375rem;
color: var(--text-primary-muted);
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.1s, border-color 0.1s, color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.jumpButton:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-accent);
color: var(--text-primary);
}
.closeButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 0;
color: var(--text-primary-muted);
cursor: pointer;
transition: background-color 0.1s, color 0.1s, border-color 0.1s;
-webkit-tap-highlight-color: transparent;
}
.closeButton:hover {
background-color: var(--background-modifier-hover);
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
border-color: var(--background-modifier-accent);
}
.content {
font-size: 0.9375rem;
line-height: 1.4;
color: var(--text-primary);
word-break: break-word;
}
.content :global(p) {
margin: 0;
}
.undecryptable {
font-style: italic;
color: var(--text-primary-muted);
}
/* Attachments — mirror the same layout MessageGroup uses, sized
smaller so previews fit comfortably inside the card. */
.attachments {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 2px;
}
.imageAttachment {
display: block;
max-width: 100%;
max-height: 260px;
border-radius: 0.5rem;
object-fit: contain;
cursor: zoom-in;
background-color: var(--background-secondary);
}
.fileAttachment {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background-color: var(--background-secondary);
border: 1px solid var(--background-header-secondary);
border-radius: 0.5rem;
color: var(--text-link, var(--brand-primary-light));
font-size: 0.8125rem;
font-weight: 600;
text-decoration: none;
max-width: 100%;
word-break: break-all;
}
.fileAttachment:hover {
text-decoration: underline;
}
/* Terminator block shown at the bottom of the pinned list — flag
icon + "You've reached the end" + explanatory body. Also used
as the empty state when there are zero pins. */
.reachedEnd {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 24px 20px;
text-align: center;
}
.reachedEndIcon {
color: var(--text-primary-muted);
margin-bottom: 4px;
}
.reachedEndTitle {
font-size: 1.0625rem;
font-weight: 800;
color: var(--text-primary);
}
.reachedEndBody {
font-size: 0.8125rem;
color: var(--text-primary-muted);
line-height: 1.4;
max-width: 280px;
}

View File

@@ -0,0 +1,178 @@
/**
* PinnedMessageRow — shared card used by the desktop pins popover,
* the mobile Pins drawer, and the pin confirmation modal. Matches
* the new UI's behaviour:
*
* - `showHoverActions` (desktop) → card is non-clickable; a "Jump"
* button + close X appear on the right side of the meta row and
* fire `onJumpTo` / `onUnpin` respectively.
* - `showHoverActions === false` (mobile / preview) → whole card
* is tappable to `onJumpTo`; no hover buttons. The confirmation
* modal renders with `onJumpTo={undefined}` so the card is a
* read-only preview.
*/
import { Flag, X } from '@phosphor-icons/react';
import { Avatar } from '@discord-clone/ui';
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
import styles from './PinnedMessageRow.module.css';
export interface PinnedMessage {
id: string;
authorName: string;
authorAvatarUrl: string | null;
content: string;
timestamp: number;
attachments?: AttachmentMetadata[];
}
interface PinnedMessageRowProps {
message: PinnedMessage;
/** Handler for navigating to the pinned message. */
onJumpTo?: () => void;
/** Handler for unpinning the message. Only rendered when the
* caller also enables `showHoverActions`. */
onUnpin?: () => void;
/**
* Desktop layout flag. When true, the card is not clickable and
* the meta row shows a Jump button + close X. When false (mobile
* / static preview), the whole card is the button.
*/
showHoverActions?: boolean;
/** Whether the viewer has permission to unpin. Hides the X when
* they don't, even if `showHoverActions` is on. */
canUnpin?: boolean;
}
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()}, ${time}`;
}
export function PinnedMessageRow({
message,
onJumpTo,
onUnpin,
showHoverActions = false,
canUnpin = true,
}: PinnedMessageRowProps) {
const isClickable = !showHoverActions && !!onJumpTo;
const handleCardClick = () => {
if (isClickable && onJumpTo) onJumpTo();
};
const handleJumpClick = (e: React.MouseEvent) => {
e.stopPropagation();
onJumpTo?.();
};
const handleUnpinClick = (e: React.MouseEvent) => {
e.stopPropagation();
onUnpin?.();
};
return (
<div
className={`${styles.card} ${isClickable ? '' : styles.cardStatic}`}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={isClickable ? handleCardClick : undefined}
onKeyDown={
isClickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCardClick();
}
}
: undefined
}
>
<div className={styles.avatar}>
<Avatar
src={message.authorAvatarUrl || undefined}
fallback={message.authorName}
size={36}
/>
</div>
<div className={styles.body}>
<div className={styles.meta}>
<span className={styles.author}>{message.authorName}</span>
<span className={styles.timestamp}>
{formatTimestamp(message.timestamp)}
</span>
{showHoverActions && (
<div className={styles.hoverActions}>
{onJumpTo && (
<button
type="button"
className={styles.jumpButton}
onClick={handleJumpClick}
>
Jump
</button>
)}
{canUnpin && onUnpin && (
<button
type="button"
className={styles.closeButton}
onClick={handleUnpinClick}
aria-label="Unpin message"
title="Unpin message"
>
<X size={14} weight="bold" />
</button>
)}
</div>
)}
</div>
{message.content ? (
<div className={styles.content}>{message.content}</div>
) : !message.attachments || message.attachments.length === 0 ? (
<div className={`${styles.content} ${styles.undecryptable}`}>
(no preview)
</div>
) : null}
{message.attachments && message.attachments.length > 0 && (
<div
className={styles.attachments}
onClick={(e) => e.stopPropagation()}
>
{message.attachments.map((att, i) => (
<EncryptedAttachment
key={`${message.id}-att-${i}`}
metadata={att}
/>
))}
</div>
)}
</div>
</div>
);
}
/** "You've reached the end" terminator used below the list and as
* the empty state. */
export function ReachedEndNotice() {
return (
<div className={styles.reachedEnd}>
<Flag size={32} weight="regular" className={styles.reachedEndIcon} />
<div className={styles.reachedEndTitle}>You've reached the end</div>
<div className={styles.reachedEndBody}>
Members with the "Pin Messages" permission can pin messages for
everyone to see.
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
.card {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.875rem 1rem;
margin-top: 0.25rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 0.5rem;
background-color: var(--background-secondary);
max-width: 520px;
}
.question {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
word-wrap: break-word;
}
.meta {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.metaEnded {
color: var(--status-warning, #f0b232);
}
.answers {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
/* Each answer row is a button so the whole row is tappable.
The fill bar is a pseudo-element that sits behind the label
and grows from 0% to `var(--pct)` based on the tally. */
.answer {
position: relative;
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 0.375rem;
background-color: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 500;
text-align: left;
cursor: pointer;
overflow: hidden;
transition: background-color 0.12s, border-color 0.12s;
}
.answer::before {
content: '';
position: absolute;
inset: 0;
width: var(--pct, 0%);
background-color: var(--brand-primary-muted, rgba(70, 65, 217, 0.15));
transition: width 0.25s ease;
pointer-events: none;
}
.answer:hover:not(.answerDisabled) {
border-color: var(--brand-primary, #4641d9);
background-color: var(--background-modifier-hover);
}
.answer > * {
position: relative;
z-index: 1;
}
.answerSelected {
border-color: var(--brand-primary, #4641d9);
}
.answerSelected::before {
background-color: var(--brand-primary-faded, rgba(70, 65, 217, 0.22));
}
.answerDisabled {
cursor: default;
}
.radio {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
border: 2px solid var(--background-modifier-accent);
background-color: var(--background-primary);
}
.radioCheckbox {
border-radius: 0.25rem;
}
.radioChecked {
border-color: var(--brand-primary, #4641d9);
background-color: var(--brand-primary, #4641d9);
box-shadow: inset 0 0 0 3px var(--background-primary);
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
}
.totalRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: 0.125rem;
font-size: 0.75rem;
color: var(--text-tertiary);
}
.endButton {
padding: 0.25rem 0.625rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 0.25rem;
background-color: transparent;
color: var(--text-tertiary);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: border-color 0.12s, color 0.12s, background-color 0.12s;
}
.endButton:hover {
color: var(--text-primary);
border-color: var(--brand-primary, #4641d9);
background-color: var(--background-modifier-hover);
}
.endModalBody {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 0.25rem 0.25rem 0.5rem;
}
.endModalDescription {
margin: 0;
font-size: 0.875rem;
line-height: 1.45;
color: var(--text-secondary, var(--text-primary));
}
.endModalQuestion {
padding: 0.625rem 0.75rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 0.375rem;
background-color: var(--background-secondary);
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
word-wrap: break-word;
}
.endModalError {
color: var(--status-danger);
font-size: 0.8125rem;
}
.endModalActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* ── Reaction row ──────────────────────────────────────────────
Matches MessageGroup's reaction chips so polls and messages
feel visually unified. */
.reactions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.25rem;
}
.reactionChip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
height: 1.5rem;
padding: 0 0.5rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 0.5rem;
background-color: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.reactionChip:hover {
background-color: var(--background-modifier-hover);
}
.reactionMe {
border-color: var(--brand-primary, #4641d9);
background-color: var(--brand-primary-faded, rgba(70, 65, 217, 0.22));
}
.reactionCount {
color: var(--text-secondary);
}
.addReactionChip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.5rem;
padding: 0;
border: 1px dashed var(--background-modifier-accent);
border-radius: 0.5rem;
background-color: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: color 0.12s, border-color 0.12s, background-color 0.12s;
}
.addReactionChip:hover {
color: var(--text-primary);
border-color: var(--brand-primary, #4641d9);
background-color: var(--background-modifier-hover);
}

View File

@@ -0,0 +1,349 @@
/**
* PollCard — renders a Convex-backed poll inline in the message
* timeline. Single- and multi-selection polls are distinguished by
* `poll.allowMultiple` (radio vs checkbox). Disclosed polls show the
* live tally; undisclosed polls hide counts until the poll is closed.
*
* Clicks send (or clear) the viewer's vote via `api.polls.vote` /
* `api.polls.clearVote`. Because the parent `useQuery(api.polls.get)`
* is reactive, the tally updates live for every viewer whenever
* anyone votes.
*/
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useMutation, useQuery } from 'convex/react';
import { Smiley } from '@phosphor-icons/react';
import { Modal, Button } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
import { TwemojiImg } from './TwemojiImg';
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
import styles from './PollCard.module.css';
interface PollCardProps {
pollId: Id<'polls'>;
}
export function PollCard({ pollId }: PollCardProps) {
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const result = useQuery(api.polls.get, {
pollId,
userId: (myUserId ?? undefined) as Id<'userProfiles'> | undefined,
});
const voteMutation = useMutation(api.polls.vote);
const clearVoteMutation = useMutation(api.polls.clearVote);
const closeMutation = useMutation(api.polls.close);
const addReactionMutation = useMutation(api.polls.addReaction);
const removeReactionMutation = useMutation(api.polls.removeReaction);
// Reaction picker — anchored off the Add Reaction button. `null`
// means the picker is closed.
const addReactionButtonRef = useRef<HTMLButtonElement | null>(null);
const [reactPickerPos, setReactPickerPos] = useState<
{ top: number; left: number } | null
>(null);
const [endConfirmOpen, setEndConfirmOpen] = useState(false);
const [ending, setEnding] = useState(false);
const [endError, setEndError] = useState<string | null>(null);
if (!result) {
return <div className={styles.card}>Loading poll</div>;
}
const { poll, totals, totalVotes, myVote, reactions } = result;
const isEnded = poll.closed || (poll.closesAt != null && poll.closesAt < Date.now());
const isMultiple = poll.allowMultiple;
const countsVisible = poll.disclosed || isEnded;
const mySelectionSet = new Set<string>(myVote ?? []);
const handleToggleReaction = (emoji: string, me: boolean) => {
if (!myUserId) return;
if (me) {
void removeReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji,
});
} else {
void addReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji,
});
}
};
const openReactPicker = () => {
const btn = addReactionButtonRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
setReactPickerPos({
// Anchor the picker so its bottom edge sits just above the
// button — matches the message reaction picker placement.
top: Math.max(8, rect.top - 440),
left: Math.max(8, rect.left - 240),
});
};
const handlePickReaction = (value: EmojiPickerValue) => {
if (!myUserId) return;
// GIF picks are meaningless as reactions — silently drop them.
if (value.kind === 'gif') {
setReactPickerPos(null);
return;
}
const emojiKey =
value.kind === 'custom' ? value.shortcode : value.surrogates;
// If the viewer already reacted with this emoji, toggle it off
// instead of inserting a duplicate row (mutation is idempotent
// either way, but this matches chat behaviour).
const already = reactions?.some((r) => r.emoji === emojiKey && r.me);
if (already) {
void removeReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji: emojiKey,
});
} else {
void addReactionMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
emoji: emojiKey,
});
}
setReactPickerPos(null);
};
const handleClick = (optionId: string) => {
if (isEnded) return;
if (!myUserId) return;
let next: string[];
if (isMultiple) {
const current = new Set(mySelectionSet);
if (current.has(optionId)) current.delete(optionId);
else current.add(optionId);
next = Array.from(current);
} else {
next = mySelectionSet.has(optionId) ? [] : [optionId];
}
if (next.length === 0) {
void clearVoteMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
});
} else {
void voteMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
optionIds: next,
});
}
};
// Bars scale to the leading answer so the winner reaches 100% and
// the rest scale proportionally — matches the new UI's visuals.
let maxCount = 0;
for (const opt of poll.options) {
const c = totals[opt.id] ?? 0;
if (c > maxCount) maxCount = c;
}
return (
<div className={styles.card}>
<div className={styles.question}>{poll.question || 'Poll'}</div>
{!countsVisible && !isEnded && (
<div className={styles.meta}>Results hidden until the poll ends</div>
)}
{isEnded && (
<div className={`${styles.meta} ${styles.metaEnded}`}>Poll ended</div>
)}
<div className={styles.answers}>
{poll.options.map((option) => {
const count = totals[option.id] ?? 0;
const selected = mySelectionSet.has(option.id);
const pct = !countsVisible
? 0
: maxCount === 0
? 0
: (count / maxCount) * 100;
return (
<button
type="button"
key={option.id}
className={[
styles.answer,
selected ? styles.answerSelected : '',
isEnded ? styles.answerDisabled : '',
]
.filter(Boolean)
.join(' ')}
style={{ ['--pct' as any]: `${pct}%` }}
onClick={() => handleClick(option.id)}
disabled={isEnded}
aria-pressed={selected}
>
<span
className={[
styles.radio,
isMultiple ? styles.radioCheckbox : '',
selected ? styles.radioChecked : '',
]
.filter(Boolean)
.join(' ')}
/>
<span className={styles.label}>{option.text}</span>
{countsVisible && (
<span className={styles.count}>
{count} {count === 1 ? 'vote' : 'votes'}
</span>
)}
</button>
);
})}
</div>
<div className={styles.totalRow}>
<span>
{totalVotes} {totalVotes === 1 ? 'voter' : 'voters'}
{isMultiple ? ' · multi-select' : ''}
</span>
{myUserId === poll.createdBy && !isEnded && (
<button
type="button"
className={styles.endButton}
onClick={() => {
setEndError(null);
setEndConfirmOpen(true);
}}
>
End Poll
</button>
)}
</div>
{(reactions.length > 0 || myUserId) && (
<div className={styles.reactions}>
{reactions.map((r) => (
<button
key={r.emoji}
type="button"
className={`${styles.reactionChip} ${r.me ? styles.reactionMe : ''}`}
onClick={() => handleToggleReaction(r.emoji, r.me)}
>
<TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)}
size={16}
/>
<span className={styles.reactionCount}>{r.count}</span>
</button>
))}
{myUserId && (
<button
ref={addReactionButtonRef}
type="button"
className={styles.addReactionChip}
onClick={openReactPicker}
aria-label="Add reaction"
title="Add reaction"
>
<Smiley size={14} weight="regular" />
</button>
)}
</div>
)}
{reactPickerPos &&
createPortal(
<div
style={{
position: 'fixed',
top: reactPickerPos.top,
left: reactPickerPos.left,
zIndex: 15000,
}}
onClick={(e) => e.stopPropagation()}
>
<EmojiPicker
onSelect={handlePickReaction}
onClose={() => setReactPickerPos(null)}
/>
</div>,
document.body,
)}
<Modal.Root
isOpen={endConfirmOpen}
onClose={() => {
if (ending) return;
setEndConfirmOpen(false);
}}
size="small"
>
<Modal.Header
title="End poll?"
onClose={() => {
if (ending) return;
setEndConfirmOpen(false);
}}
/>
<Modal.Content>
<div className={styles.endModalBody}>
<p className={styles.endModalDescription}>
Voting will be locked and the results will be finalised.
{!poll.disclosed && (
<> Hidden vote counts will become visible to everyone.</>
)}
</p>
<div className={styles.endModalQuestion}>
{poll.question || 'Poll'}
</div>
{endError && <div className={styles.endModalError}>{endError}</div>}
<div className={styles.endModalActions}>
<Button
variant="secondary"
size="sm"
onClick={() => setEndConfirmOpen(false)}
disabled={ending}
>
Cancel
</Button>
<Button
variant="danger"
size="sm"
loading={ending}
onClick={async () => {
if (!myUserId) return;
setEnding(true);
setEndError(null);
try {
await closeMutation({
pollId: poll._id,
userId: myUserId as Id<'userProfiles'>,
});
setEndConfirmOpen(false);
} catch (err: any) {
setEndError(err?.message || 'Failed to end the poll.');
} finally {
setEnding(false);
}
}}
>
End Poll
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
</div>
);
}

View File

@@ -0,0 +1,128 @@
.body {
display: flex;
gap: 12px;
min-height: 320px;
max-height: 480px;
padding: 4px 4px 16px;
}
/* ── Emoji column ───────────────────────────────────────────── */
.emojiColumn {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 96px;
flex-shrink: 0;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
}
.emojiChip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 8px;
background-color: var(--background-tertiary);
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.1s, border-color 0.1s;
}
.emojiChip:hover {
background-color: var(--background-modifier-hover);
}
.emojiChipActive {
background-color: var(--background-modifier-selected, var(--background-modifier-hover));
border-color: var(--brand-primary, #5865f2);
}
.emojiChipImage {
width: 20px;
height: 20px;
object-fit: contain;
}
.emojiCount {
color: var(--text-secondary);
}
/* ── Users column ───────────────────────────────────────────── */
.usersColumn {
flex: 1;
min-width: 0;
padding: 8px;
border-radius: 8px;
background-color: var(--background-tertiary);
overflow-y: auto;
scrollbar-width: thin;
}
.userRow {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
transition: background-color 0.1s;
}
.userRow:hover {
background-color: var(--background-modifier-hover);
}
.userAvatar {
flex-shrink: 0;
}
.userMeta {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
flex: 1;
}
.userDisplayName {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.userTag {
font-size: 0.75rem;
color: var(--text-tertiary);
white-space: nowrap;
}
.removeButton {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
flex-shrink: 0;
}
.removeButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}

View File

@@ -0,0 +1,139 @@
/**
* ReactionsModal — full breakdown of every reaction on a single
* message. Matches the Discord "Reactions" popup: an emoji chip
* column on the left, the user list for the selected emoji on the
* right. Clicking the X next to your own row removes that reaction;
* other users' rows render the close affordance as `null` so the
* column only lets you manage your own state.
*/
import { useState } from 'react';
import { X } from '@phosphor-icons/react';
import { Avatar, Modal } from '@discord-clone/ui';
import type { ReactionUser } from './Messages';
import { TwemojiImg } from './TwemojiImg';
import { resolveReactionKeyToUnicode } from '../../utils/emojiLookup';
import styles from './ReactionsModal.module.css';
export interface ReactionsModalReaction {
emoji: string;
count: number;
me: boolean;
users: ReactionUser[];
}
interface ReactionsModalProps {
isOpen: boolean;
onClose: () => void;
reactions: ReactionsModalReaction[];
myUserId: string | null;
customEmojiByName: Map<string, string>;
/**
* Called when the viewer clicks the X next to their own row — the
* parent is responsible for wiring `api.reactions.remove`. The
* modal trusts the parent and optimistically just closes if this
* was the viewer's last remaining reaction.
*/
onRemoveOwnReaction: (emoji: string) => void;
}
export function ReactionsModal({
isOpen,
onClose,
reactions,
myUserId,
customEmojiByName,
onRemoveOwnReaction,
}: ReactionsModalProps) {
// Which emoji tab is active — defaults to the first reaction.
// Resets whenever the set of reactions changes (e.g. after removal).
const [activeEmoji, setActiveEmoji] = useState<string | null>(null);
const effectiveEmoji =
activeEmoji && reactions.some((r) => r.emoji === activeEmoji)
? activeEmoji
: reactions[0]?.emoji ?? null;
const activeReaction = reactions.find((r) => r.emoji === effectiveEmoji);
const renderEmojiGlyph = (emojiKey: string) => {
const customUrl = /^[a-zA-Z0-9_]+$/.test(emojiKey)
? customEmojiByName.get(emojiKey.toLowerCase())
: undefined;
if (customUrl) {
return (
<img
src={customUrl}
alt={`:${emojiKey}:`}
className={styles.emojiChipImage}
draggable={false}
/>
);
}
return (
<TwemojiImg
emoji={resolveReactionKeyToUnicode(emojiKey)}
size={20}
/>
);
};
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
<Modal.Header title="Reactions" onClose={onClose} />
<Modal.Content>
<div className={styles.body}>
<div className={styles.emojiColumn}>
{reactions.map((r) => {
const isActive = effectiveEmoji === r.emoji;
return (
<button
key={r.emoji}
type="button"
className={`${styles.emojiChip} ${isActive ? styles.emojiChipActive : ''}`}
onClick={() => setActiveEmoji(r.emoji)}
>
{renderEmojiGlyph(r.emoji)}
<span className={styles.emojiCount}>{r.count}</span>
</button>
);
})}
</div>
<div className={styles.usersColumn}>
{activeReaction?.users.map((u) => {
const isMe = myUserId !== null && u.userId === myUserId;
return (
<div key={u.userId} className={styles.userRow}>
<div className={styles.userAvatar}>
<Avatar
src={undefined}
fallback={u.displayName || u.username}
size={32}
/>
</div>
<div className={styles.userMeta}>
<span className={styles.userDisplayName}>
{u.displayName || u.username}
</span>
<span className={styles.userTag}>{u.username}</span>
</div>
{isMe && (
<button
type="button"
className={styles.removeButton}
onClick={() =>
onRemoveOwnReaction(activeReaction.emoji)
}
aria-label="Remove your reaction"
title="Remove your reaction"
>
<X size={14} weight="bold" />
</button>
)}
</div>
);
})}
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -0,0 +1,35 @@
.container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background-color: var(--background-secondary);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
border-bottom: 2px solid var(--brand-primary);
}
.text {
font-size: 0.875rem;
color: var(--text-secondary);
}
.text strong {
color: var(--text-primary);
}
.cancel {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: none;
border: none;
cursor: pointer;
color: var(--interactive-normal);
border-radius: var(--radius-sm);
}
.cancel:hover {
color: var(--interactive-hover);
}

View File

@@ -0,0 +1,20 @@
import { X } from '@phosphor-icons/react';
import styles from './ReplyBar.module.css';
interface ReplyBarProps {
replyingTo: string; // username
onCancel: () => void;
}
export function ReplyBar({ replyingTo, onCancel }: ReplyBarProps) {
return (
<div className={styles.container}>
<span className={styles.text}>
Replying to <strong>{replyingTo}</strong>
</span>
<button className={styles.cancel} onClick={onCancel}>
<X size={16} weight="bold" />
</button>
</div>
);
}

View File

@@ -0,0 +1,232 @@
/* ── Save Media modal body ────────────────────────────────────────
Shell chrome (backdrop, centring, header bar) comes from the
shared `Modal` primitive; this module only styles the form
controls inside `Modal.Content`. */
.body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-primary-muted, #a0a3a8);
}
.input {
height: 40px;
padding: 0 12px;
background-color: var(--form-surface-background, #1e2024);
border: 1px solid var(--background-modifier-accent);
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.12s;
}
.input:focus,
.input:focus-visible {
outline: none;
border-color: var(--brand-primary, #4641d9);
}
.input::placeholder {
color: var(--text-tertiary);
}
.input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.textarea {
min-height: 80px;
padding: 10px 12px;
background-color: var(--form-surface-background, #1e2024);
border: 1px solid var(--background-modifier-accent);
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
resize: vertical;
outline: none;
transition: border-color 0.12s;
}
.textarea:focus,
.textarea:focus-visible {
outline: none;
border-color: var(--brand-primary, #4641d9);
}
.textarea::placeholder {
color: var(--text-tertiary);
}
/* ── Tags ────────────────────────────────────────────────────── */
.tagChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tagChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.04));
border: 1px solid var(--background-modifier-accent);
border-radius: 999px;
font-size: 0.8125rem;
color: var(--text-primary);
}
.tagChipText {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tagChipRemove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-primary-muted, #a0a3a8);
cursor: pointer;
transition: background-color 0.1s, color 0.1s;
}
.tagChipRemove:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
/* Input + Add button row */
.tagRow {
display: flex;
align-items: center;
gap: 8px;
}
.tagRow .input {
flex: 1;
}
.addButton {
height: 40px;
padding: 0 16px;
background-color: var(--brand-primary, #4641d9);
border: none;
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.12s;
flex-shrink: 0;
}
.addButton:hover:not(:disabled) {
filter: brightness(1.08);
}
.addButton:active:not(:disabled) {
filter: brightness(0.92);
}
.addButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Footer actions ──────────────────────────────────────────── */
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.secondaryButton {
height: 40px;
padding: 0 18px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-primary);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s;
}
.secondaryButton:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
}
.secondaryButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.primaryButton {
height: 40px;
padding: 0 20px;
background-color: var(--brand-primary, #4641d9);
border: none;
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.875rem;
font-weight: 700;
cursor: pointer;
transition: filter 0.12s;
}
.primaryButton:hover:not(:disabled) {
filter: brightness(1.08);
}
.primaryButton:active:not(:disabled) {
filter: brightness(0.92);
}
.primaryButton:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* Inline save error row */
.error {
padding: 10px 14px;
background-color: hsl(0, calc(60% * var(--saturation-factor, 1)), 22%);
color: hsl(0, calc(80% * var(--saturation-factor, 1)), 85%);
border-radius: 6px;
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,245 @@
/**
* SaveMediaModal — "Add to Saved Media" dialog opened from the star
* button in ImageLightbox. Collects a user-editable name, alt text,
* and up to 10 tags, then writes the item through SavedMediaManager
* into `io.brycord.saved_media` account data.
*
* Name / alt text / tag limits match Fluxer's equivalent modal. On
* successful save we also push the fresh snapshot into SavedMediaStore
* optimistically so the EmojiPicker Media tab and the ImageLightbox
* star both reflect the new state instantly — the accountData
* listener in MatrixActions will fire on the next sync tick as an
* idempotent no-op.
*
* Save failures (homeserver rejection, network blip, account-data
* size limit) surface inline under the body via the `.error` row
* and keep the modal open so the user can retry.
*/
import { useEffect, useState } from 'react';
import { Modal } from '@brycord/ui';
import { X } from '@phosphor-icons/react';
import {
SavedMediaManager,
classifyMediaKind,
} from '@brycord/matrix-client';
import type { SavedMediaItem } from '@brycord/matrix-client';
import SavedMediaStore from '@app/stores/SavedMediaStore';
import styles from './SaveMediaModal.module.css';
interface SaveMediaModalProps {
isOpen: boolean;
onClose: () => void;
/** Raw mxc:// URL of the asset being saved. Dedupe key. */
mxcUrl: string;
/** Filename the media was originally uploaded with. Used as the
* default value of the Name field and preserved on the item. */
originalFilename: string;
/** MIME type captured from the Attachment — drives `kind`. */
contentType?: string;
width?: number;
height?: number;
/** Fired after a successful save so callers can flip their
* local "is saved" state (e.g. the ImageLightbox star). */
onSaved?: (item: SavedMediaItem) => void;
}
const NAME_MAX = 120;
const ALT_MAX = 320;
const TAGS_MAX = 10;
const TAG_MAX_LENGTH = 32;
export function SaveMediaModal({
isOpen,
onClose,
mxcUrl,
originalFilename,
contentType,
width,
height,
onSaved,
}: SaveMediaModalProps) {
const [name, setName] = useState(originalFilename);
const [altText, setAltText] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [tagDraft, setTagDraft] = useState('');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// Reset every time the modal opens on a new asset. Without this
// a stale tag draft from a previous save would bleed into the
// next favorite action.
useEffect(() => {
if (!isOpen) return;
const existing = SavedMediaStore.getByMxc(mxcUrl);
setName(existing?.name ?? originalFilename);
setAltText(existing?.altText ?? '');
setTags(existing?.tags ?? []);
setTagDraft('');
setError(null);
setSaving(false);
}, [isOpen, mxcUrl, originalFilename]);
const canSave = name.trim().length > 0 && !saving;
const addTag = () => {
const raw = tagDraft.trim();
if (!raw) return;
if (tags.length >= TAGS_MAX) return;
const tag = raw.slice(0, TAG_MAX_LENGTH);
// Drop duplicates (case-insensitive) so re-entering the
// same tag doesn't create visual dupes.
if (tags.some((t) => t.toLowerCase() === tag.toLowerCase())) {
setTagDraft('');
return;
}
setTags([...tags, tag]);
setTagDraft('');
};
const removeTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag));
};
const handleTagKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
};
const handleSave = async () => {
if (!canSave) return;
setSaving(true);
setError(null);
try {
const item = await SavedMediaManager.getInstance().saveItem({
mxcUrl,
kind: classifyMediaKind(contentType),
name: name.trim().slice(0, NAME_MAX),
altText: altText.trim() ? altText.trim().slice(0, ALT_MAX) : undefined,
tags,
originalFilename,
contentType,
width,
height,
});
// Optimistic store push — the accountData listener will
// also fire on the next sync tick with the same content,
// which is an idempotent no-op.
SavedMediaStore.handleDataLoaded(
SavedMediaManager.getInstance().getData(),
);
onSaved?.(item);
onClose();
} catch (err: any) {
setError(err?.message || 'Failed to save media.');
setSaving(false);
}
};
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="small" zIndex={17000}>
<Modal.Header title="Add to Saved Media" onClose={onClose} />
<Modal.Content>
<div className={styles.body}>
<div className={styles.field}>
<label className={styles.label} htmlFor="save-media-name">
Name
</label>
<input
id="save-media-name"
type="text"
className={styles.input}
value={name}
onChange={(e) => setName(e.target.value.slice(0, NAME_MAX))}
maxLength={NAME_MAX}
autoFocus
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="save-media-alt">
Alt Text
</label>
<textarea
id="save-media-alt"
className={styles.textarea}
placeholder="Describe the media"
value={altText}
onChange={(e) => setAltText(e.target.value.slice(0, ALT_MAX))}
maxLength={ALT_MAX}
rows={3}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor="save-media-tag">
Tags ({tags.length}/{TAGS_MAX})
</label>
{tags.length > 0 && (
<div className={styles.tagChips}>
{tags.map((tag) => (
<span key={tag} className={styles.tagChip}>
<span className={styles.tagChipText}>{tag}</span>
<button
type="button"
className={styles.tagChipRemove}
onClick={() => removeTag(tag)}
aria-label={`Remove tag ${tag}`}
>
<X size={12} weight="bold" />
</button>
</span>
))}
</div>
)}
<div className={styles.tagRow}>
<input
id="save-media-tag"
type="text"
className={styles.input}
placeholder="Add a tag"
value={tagDraft}
onChange={(e) => setTagDraft(e.target.value.slice(0, TAG_MAX_LENGTH))}
onKeyDown={handleTagKeyDown}
maxLength={TAG_MAX_LENGTH}
disabled={tags.length >= TAGS_MAX}
/>
<button
type="button"
className={styles.addButton}
onClick={addTag}
disabled={
tagDraft.trim().length === 0 || tags.length >= TAGS_MAX
}
>
Add
</button>
</div>
</div>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.actions}>
<button
type="button"
className={styles.secondaryButton}
onClick={onClose}
disabled={saving}
>
Cancel
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleSave}
disabled={!canSave}
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -0,0 +1,289 @@
.body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px 20px;
min-height: 360px;
}
.searchInputWrapper {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 40px;
border-radius: 12px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
border: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.08));
}
.searchInputIcon {
color: var(--text-tertiary);
flex-shrink: 0;
}
.searchInput {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font: inherit;
font-size: 0.9375rem;
}
.searchInput::placeholder {
color: var(--text-tertiary);
}
.searchClearButton {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-secondary);
cursor: pointer;
}
.searchClearButton:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
color: var(--text-primary);
}
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
padding: 6px 12px;
border-radius: 9999px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
border: 1px solid var(--user-area-divider-color, rgba(255, 255, 255, 0.08));
color: var(--text-primary);
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.chip:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
}
.searchButton {
display: flex;
align-items: center;
justify-content: center;
height: 44px;
margin-top: 4px;
padding: 0 16px;
background-color: var(--brand-primary, #5865f2);
color: #ffffff;
border: none;
border-radius: 12px;
font: inherit;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.searchButton:hover:not(:disabled) {
filter: brightness(1.1);
}
.searchButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.results {
display: flex;
flex-direction: column;
min-height: 200px;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 16px;
color: var(--text-tertiary);
text-align: center;
}
.placeholderTitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-top: 4px;
}
.placeholderSubtitle {
font-size: 0.8125rem;
color: var(--text-tertiary);
}
/* ── Mobile search result cards ──────────────────────────────────────── */
.resultsList {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 4px;
}
.resultsCount {
text-align: center;
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
padding: 4px 0 8px;
}
.resultsGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.resultsGroupHeader {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
font-size: 0.9375rem;
font-weight: 600;
}
.resultsGroupHeader svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.resultsGroupBody {
display: flex;
flex-direction: column;
gap: 8px;
}
.resultCard {
display: flex;
gap: 12px;
padding: 12px;
background-color: var(--background-tertiary, rgba(255, 255, 255, 0.04));
border: none;
border-radius: 12px;
color: var(--text-primary);
text-align: left;
cursor: pointer;
font: inherit;
transition: background-color 0.15s ease;
}
.resultCard:hover {
background-color: var(--background-modifier-hover, rgba(255, 255, 255, 0.08));
}
.resultCardAvatar {
flex-shrink: 0;
line-height: 0;
}
.resultCardBody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.resultCardHeader {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.resultCardAuthor {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resultCardTimestamp {
font-size: 0.75rem;
color: var(--text-tertiary);
font-weight: 400;
flex-shrink: 0;
white-space: nowrap;
}
.resultCardContent {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-secondary);
word-wrap: break-word;
overflow-wrap: break-word;
max-height: 5rem;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
/* Embed + attachment wrappers — render below the text preview in each
result card. The embed block hides itself when LinkEmbeds renders
nothing (no URLs in the message) so there's no phantom gap. */
.resultCardEmbeds {
margin-top: 6px;
}
.resultCardEmbeds:empty {
display: none;
}
.resultCardAttachments {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 6px;
}
.resultCardImage {
max-width: 100%;
max-height: 220px;
border-radius: 8px;
object-fit: cover;
display: block;
}
.resultCardFile {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background-color: var(--background-secondary, rgba(255, 255, 255, 0.05));
border-radius: 8px;
color: var(--text-link, var(--brand-primary));
font-size: 0.8125rem;
text-decoration: none;
word-break: break-all;
}
.resultCardFile:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,326 @@
/**
* SearchDrawer — mobile bottom sheet opened by tapping the search icon
* in ChannelHeader on mobile. Wraps SearchStore in a BottomSheet with a
* search input, filter chips, and a compact mobile-styled result list.
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { MagnifyingGlass, X, Hash } from '@phosphor-icons/react';
import { Avatar, BottomSheet } from '@brycord/ui';
import SearchStore, { type SearchResult } from '@app/stores/SearchStore';
import ChannelStore from '@app/stores/ChannelStore';
import { MessageContent } from './MessageContent';
import { LinkEmbeds } from './LinkEmbed';
import styles from './SearchDrawer.module.css';
interface SearchDrawerProps {
isOpen: boolean;
onClose: () => void;
serverId?: string;
onScrollToMessage?: (messageId: string) => void;
}
const FILTER_CHIPS: Array<{ key: string; label: string }> = [
{ key: 'from:', label: 'From' },
{ key: 'has:', label: 'Has' },
{ key: 'mentions:', label: 'Mentions' },
{ key: 'before:', label: 'Before' },
{ key: 'after:', label: 'After' },
{ key: 'pinned:true', label: 'Pinned' },
];
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function groupByChannel(
results: SearchResult[],
): Array<{ channelId: string; results: SearchResult[] }> {
const groups = new Map<string, SearchResult[]>();
for (const result of results) {
const cId = result.message.channelId;
if (!groups.has(cId)) groups.set(cId, []);
groups.get(cId)!.push(result);
}
return Array.from(groups.entries()).map(([channelId, results]) => ({ channelId, results }));
}
/**
* Compact mobile search result list — renders a "N Results" counter, then
* channel-grouped cards. Each card shows the avatar, author, timestamp,
* and a preview of the message content. Tapping a card jumps to the
* message in the underlying chat view.
*/
const MobileSearchResults = observer(function MobileSearchResults({
onJump,
}: {
onJump: (messageId: string) => void;
}) {
const { query, results, isSearching } = SearchStore;
const hasQuery = query.trim().length > 0;
const grouped = useMemo(() => groupByChannel(results), [results]);
if (!hasQuery) {
return (
<div className={styles.placeholder}>
<MagnifyingGlass size={48} weight="regular" />
<div className={styles.placeholderTitle}>Search Messages</div>
<div className={styles.placeholderSubtitle}>
Use filters or enter keywords to find messages
</div>
</div>
);
}
if (isSearching) {
return (
<div className={styles.placeholder}>
<div className={styles.placeholderSubtitle}>Searching</div>
</div>
);
}
if (results.length === 0) {
return (
<div className={styles.placeholder}>
<div className={styles.placeholderTitle}>No results</div>
<div className={styles.placeholderSubtitle}>Try a different search term.</div>
</div>
);
}
return (
<div className={styles.resultsList}>
<div className={styles.resultsCount}>
{results.length} {results.length === 1 ? 'Result' : 'Results'}
</div>
{grouped.map((group) => {
const channel = ChannelStore.getChannel(group.channelId);
return (
<div key={group.channelId} className={styles.resultsGroup}>
<div className={styles.resultsGroupHeader}>
<Hash size={14} weight="bold" />
<span>{channel?.name || 'Unknown'}</span>
</div>
<div className={styles.resultsGroupBody}>
{group.results.map((result) => {
const { message } = result;
const author = message.author;
return (
<div
key={message.id}
role="button"
tabIndex={0}
className={styles.resultCard}
onClick={() => onJump(message.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onJump(message.id);
}
}}
>
<div className={styles.resultCardAvatar}>
<Avatar
src={author.avatar}
fallback={author.displayName || author.username}
size={32}
/>
</div>
<div className={styles.resultCardBody}>
<div className={styles.resultCardHeader}>
<span className={styles.resultCardAuthor}>
{author.displayName || author.username}
</span>
<span className={styles.resultCardTimestamp}>
{formatTimestamp(message.timestamp)}
</span>
</div>
{message.content && (
<div className={styles.resultCardContent}>
<MessageContent content={message.content} />
</div>
)}
{!message.decryptionFailed && (
<div
className={styles.resultCardEmbeds}
onClick={(e) => e.stopPropagation()}
>
<LinkEmbeds content={message.content} />
</div>
)}
{message.attachments.length > 0 && (
<div className={styles.resultCardAttachments}>
{message.attachments.map((att) =>
att.contentType.startsWith('image/') ? (
<img
key={att.id}
src={att.url}
alt={att.filename}
className={styles.resultCardImage}
loading="lazy"
/>
) : (
<a
key={att.id}
href={att.url}
className={styles.resultCardFile}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{att.filename}
</a>
),
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
});
export const SearchDrawer = observer(function SearchDrawer({
isOpen,
onClose,
serverId,
onScrollToMessage,
}: SearchDrawerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Focus the input whenever the drawer opens — matches Fluxer's UX
// where the keyboard appears immediately on open.
useEffect(() => {
if (!isOpen) return;
const t = setTimeout(() => inputRef.current?.focus(), 120);
return () => clearTimeout(t);
}, [isOpen]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
SearchStore.setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (value.trim() && serverId) {
if (!SearchStore.isOpen) SearchStore.openSearch();
debounceRef.current = setTimeout(() => {
SearchStore.searchMessages(serverId, value);
}, 300);
} else if (SearchStore.isOpen) {
SearchStore.closeSearch();
}
},
[serverId],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && SearchStore.query.trim() && serverId) {
if (!SearchStore.isOpen) SearchStore.openSearch();
SearchStore.searchMessages(serverId, SearchStore.query);
}
},
[serverId],
);
const handleChipClick = useCallback((key: string) => {
const current = SearchStore.query;
const separator = current.length > 0 && !current.endsWith(' ') ? ' ' : '';
SearchStore.setQuery(current + separator + key + ' ');
inputRef.current?.focus();
}, []);
const handleClear = useCallback(() => {
SearchStore.setQuery('');
SearchStore.closeSearch();
inputRef.current?.focus();
}, []);
const handleSearch = useCallback(() => {
if (!serverId || !SearchStore.query.trim()) return;
if (!SearchStore.isOpen) SearchStore.openSearch();
SearchStore.searchMessages(serverId, SearchStore.query);
}, [serverId]);
return (
<BottomSheet isOpen={isOpen} onClose={onClose} title="Search">
<div className={styles.body}>
<div className={styles.searchInputWrapper}>
<MagnifyingGlass size={16} className={styles.searchInputIcon} />
<input
ref={inputRef}
className={styles.searchInput}
placeholder="Search messages"
value={SearchStore.query}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
{SearchStore.query && (
<button
type="button"
className={styles.searchClearButton}
onClick={handleClear}
aria-label="Clear search"
>
<X size={14} weight="bold" />
</button>
)}
</div>
<div className={styles.chipRow}>
{FILTER_CHIPS.map((chip) => (
<button
key={chip.key}
type="button"
className={styles.chip}
onClick={() => handleChipClick(chip.key)}
>
{chip.label}
</button>
))}
</div>
<button
type="button"
className={styles.searchButton}
onClick={handleSearch}
disabled={!SearchStore.query.trim() || !serverId}
>
Search
</button>
<div className={styles.results}>
<MobileSearchResults
onJump={(id) => {
onScrollToMessage?.(id);
onClose();
}}
/>
</div>
</div>
</BottomSheet>
);
});

View File

@@ -0,0 +1,264 @@
.panel {
display: flex;
flex-direction: column;
width: 420px;
flex-shrink: 0;
background-color: var(--background-secondary);
border-left: 1px solid var(--background-modifier-accent);
height: 100%;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 3.5rem;
padding: 0 1rem;
border-bottom: 1px solid var(--background-modifier-accent);
flex-shrink: 0;
}
.headerTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: background-color 0.12s, color 0.12s;
}
.closeButton:hover {
background: var(--background-modifier-hover);
color: var(--text-primary);
}
.resultsList {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
/* ── Channel group headers ──────────────────────────────────── */
.channelGroup {
margin-bottom: 4px;
}
.channelGroupHeader {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 8px 4px;
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-primary);
}
.channelGroupIcon {
color: var(--channel-icon, var(--text-tertiary));
flex-shrink: 0;
}
.channelGroupName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channelGroupCount {
margin-left: auto;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-tertiary);
}
/* ── Result row ────────────────────────────────────────────── */
.resultItem {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
width: 100%;
text-align: left;
font: inherit;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary, var(--background-modifier-accent));
background-color: var(--background-secondary-lighter, var(--background-primary));
position: relative;
cursor: pointer;
transition: background-color 0.15s;
}
.resultItem:hover {
background-color: var(--background-modifier-hover);
}
.resultItem:hover .jumpButton {
opacity: 1;
}
.resultAvatar {
flex-shrink: 0;
padding-top: 2px;
}
.resultBody {
flex: 1;
min-width: 0;
}
.resultHeader {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: nowrap;
margin-bottom: 2px;
}
.resultAuthor {
font-size: 0.9375rem;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resultTimestamp {
font-size: 0.75rem;
color: var(--text-primary-muted);
font-weight: 500;
flex-shrink: 0;
}
.resultContent {
font-size: 0.875rem;
color: var(--text-primary);
line-height: 1.375rem;
word-wrap: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.resultAttachments {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 6px;
min-width: 0;
}
/* Constrain image / video / link-embed inside the 645px column so
they don't stretch out the result card. */
.resultAttachments > * {
max-width: 100%;
}
.resultAttachments img,
.resultAttachments video {
max-width: 100%;
max-height: 260px;
border-radius: 6px;
}
.resultEmbeds {
margin-top: 6px;
}
.highlight {
background-color: rgba(250, 168, 26, 0.3);
color: var(--text-primary);
border-radius: 2px;
padding: 0 1px;
}
/* Jump button — lives inside the header row right after the
timestamp (pushed flush-right via `margin-left: auto`) so the
message content below gets the full width of the result card.
Matches PinnedMessageRow's muted tile-button look and only
fades in on card hover to keep the idle state clean. */
.jumpButton {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 4px 10px;
background-color: var(--background-primary);
border: 1px solid var(--background-header-secondary);
border-radius: 0.375rem;
color: var(--text-primary-muted);
font: inherit;
font-size: 0.75rem;
font-weight: 700;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition:
opacity 0.12s ease,
visibility 0.12s ease,
background-color 0.1s,
border-color 0.1s,
color 0.1s;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.resultItem:hover .jumpButton,
.resultItem:focus-within .jumpButton {
opacity: 1;
visibility: visible;
}
.jumpButton:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-accent);
color: var(--text-primary);
}
/* ── Empty / loading states ─────────────────────────────────── */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1.5rem;
text-align: center;
}
.emptyIcon {
color: var(--text-tertiary);
margin-bottom: 12px;
}
.emptyTitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.emptyDescription {
font-size: 0.8125rem;
color: var(--text-tertiary);
margin: 0;
}

View File

@@ -0,0 +1,490 @@
/**
* SearchPanel — cross-channel message search rendered to the right of
* the chat column (replacing the members list) whenever the channel
* header's search input has a non-empty query. Pulls the latest N
* messages from every non-DM channel via `api.messages.searchScan`
* in one round-trip, decrypts them client-side using the per-channel
* key bundles we already hold, and does a case-insensitive substring
* match against the decrypted plaintext.
*
* Results are grouped by channel (with a `# channel-name` header),
* and each result row renders the author / timestamp / content plus
* any attachments (via `EncryptedAttachment`) and link embeds (via
* `LinkEmbed`) — matching the Brycord reference layout so messages
* read the same in search as they do in the timeline.
*
* Clicking a result dispatches `brycord:scroll-to-message` so the
* Messages component can jump to the corresponding message in its
* channel. Navigation to a different channel is the caller's
* responsibility (the same event already pops open the right view).
*/
import { useQuery } from 'convex/react';
import { Hash, MagnifyingGlass, X } from '@phosphor-icons/react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import {
EncryptedAttachment,
type AttachmentMetadata,
} from './EncryptedAttachment';
import { LinkEmbed } from './LinkEmbed';
import styles from './SearchPanel.module.css';
interface SearchPanelProps {
channelId: string;
query: string;
onClose: () => void;
}
const TAG_LENGTH = 32;
const PER_CHANNEL_LIMIT = 100;
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
interface SearchResult {
id: string;
channelId: string;
authorName: string;
authorAvatarUrl: string | null;
content: string;
attachments: AttachmentMetadata[];
timestamp: number;
}
interface ChannelGroup {
channelId: string;
channelName: string;
results: SearchResult[];
}
function stripFilters(raw: string): string {
return raw
.split(/\s+/)
.filter(
(tok) =>
!/^(from:|mentions:|has:|before:|during:|after:|pinned:)/i.test(tok),
)
.join(' ')
.trim();
}
function extractUrls(text: string): string[] {
const matches = text.match(URL_REGEX) ?? [];
const cleaned = matches.map((m) => m.replace(/[),.;!?]+$/, ''));
return Array.from(new Set(cleaned));
}
function highlight(text: string, needle: string): React.ReactNode {
if (!needle) return text;
const lower = text.toLowerCase();
const target = needle.toLowerCase();
const out: React.ReactNode[] = [];
let cursor = 0;
let idx = lower.indexOf(target, cursor);
let key = 0;
while (idx !== -1) {
if (idx > cursor) out.push(text.slice(cursor, idx));
out.push(
<mark key={`h-${key++}`} className={styles.highlight}>
{text.slice(idx, idx + needle.length)}
</mark>,
);
cursor = idx + needle.length;
idx = lower.indexOf(target, cursor);
}
if (cursor < text.length) out.push(text.slice(cursor));
return out;
}
function formatTimestamp(ms: number): string {
const date = new Date(ms);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
/**
* Parse a decrypted message blob and split it into plaintext + a
* (possibly empty) list of attachments. Mirrors the exact shape
* handling used by `Messages.tsx`, so attachments in search results
* render the same way they do in the live timeline.
*/
function parseDecrypted(raw: string): {
text: string;
attachments: AttachmentMetadata[];
} {
if (!raw) return { text: '', attachments: [] };
let text = raw;
const attachments: AttachmentMetadata[] = [];
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (
item?.type === 'attachment' &&
item.url &&
item.key &&
item.iv
) {
attachments.push(item as AttachmentMetadata);
}
}
text = '';
} else if (
parsed.type === 'attachment' &&
parsed.url &&
parsed.key &&
parsed.iv
) {
attachments.push(parsed as AttachmentMetadata);
text = '';
} else if (parsed.text !== undefined) {
text = String(parsed.text);
}
}
} catch {
/* plain string */
}
return { text, attachments };
}
export function SearchPanel({ channelId, query, onClose }: SearchPanelProps) {
const { crypto } = usePlatform();
const navigate = useNavigate();
const needle = useMemo(() => stripFilters(query).toLowerCase(), [query]);
const userId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const privateKeyPem =
typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('privateKey')
: null;
// --- Channel metadata -----------------------------------------
const allChannels = useQuery(api.channels.list, {}) ?? [];
const textChannels = useMemo(
() => allChannels.filter((c) => c.type === 'text'),
[allChannels],
);
const channelNameById = useMemo(() => {
const map = new Map<string, string>();
for (const c of textChannels) map.set(c._id, c.name);
return map;
}, [textChannels]);
// --- Channel key decryption -----------------------------------
const allKeys = useQuery(
api.channelKeys.getKeysForUser,
userId ? { userId: userId as any } : 'skip',
);
const [keyByChannel, setKeyByChannel] = useState<Map<string, string>>(
new Map(),
);
useEffect(() => {
let cancelled = false;
if (!allKeys || !privateKeyPem) {
setKeyByChannel(new Map());
return;
}
(async () => {
const merged: Record<string, string> = {};
for (const item of allKeys) {
try {
const bundleJson = await crypto.privateDecrypt(
privateKeyPem,
item.encrypted_key_bundle,
);
Object.assign(merged, JSON.parse(bundleJson));
} catch (err) {
console.error(
`Failed to decrypt key bundle for ${item.channel_id}`,
err,
);
}
}
if (cancelled) return;
setKeyByChannel(new Map(Object.entries(merged)));
})();
return () => {
cancelled = true;
};
}, [allKeys, privateKeyPem, crypto]);
// --- Multi-channel message fetch ------------------------------
const channelIdList = useMemo(
() => textChannels.map((c) => c._id),
[textChannels],
);
const scan = useQuery(
api.messages.searchScan,
channelIdList.length > 0
? {
channelIds: channelIdList as any,
perChannelLimit: PER_CHANNEL_LIMIT,
userId: (userId as any) ?? undefined,
}
: 'skip',
) as Array<{ channelId: string; messages: any[] }> | undefined;
// --- Decrypt ---------------------------------------------------
// Keyed by message id so decryption is incremental — new scan
// results only redo the rows we haven't seen yet.
const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(
new Map(),
);
useEffect(() => {
if (!scan || keyByChannel.size === 0) return;
let cancelled = false;
(async () => {
const next = new Map(decryptedMap);
let changed = false;
for (const group of scan) {
const key = keyByChannel.get(group.channelId);
if (!key) continue;
for (const msg of group.messages) {
const id = msg.id as string;
if (next.has(id)) continue;
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
next.set(id, '');
changed = true;
continue;
}
const tag = msg.ciphertext.slice(-TAG_LENGTH);
const contentHex = msg.ciphertext.slice(0, -TAG_LENGTH);
try {
const plaintext = await crypto.decryptData(
contentHex,
key,
msg.nonce,
tag,
);
if (cancelled) return;
next.set(id, plaintext);
changed = true;
} catch {
next.set(id, '');
changed = true;
}
}
}
if (changed && !cancelled) setDecryptedMap(next);
})();
return () => {
cancelled = true;
};
// decryptedMap intentionally excluded — we mutate a copy
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scan, keyByChannel, crypto]);
// --- Filter + shape results -----------------------------------
const grouped: ChannelGroup[] = useMemo(() => {
if (!scan || !needle) return [];
const groups: ChannelGroup[] = [];
for (const group of scan) {
const channelName = channelNameById.get(group.channelId) ?? 'channel';
const hits: SearchResult[] = [];
for (const msg of group.messages) {
const id = msg.id as string;
const raw = decryptedMap.get(id);
if (!raw) continue;
const { text, attachments } = parseDecrypted(raw);
// Match against the visible text OR an attachment filename so
// queries like "screenshot.png" still surface the message.
const filenameHit = attachments.some((a) =>
a.filename?.toLowerCase().includes(needle),
);
if (!text.toLowerCase().includes(needle) && !filenameHit) continue;
hits.push({
id,
channelId: group.channelId,
authorName: msg.displayName || msg.username || 'User',
authorAvatarUrl: msg.avatarUrl ?? null,
content: text,
attachments,
timestamp: msg.created_at
? new Date(msg.created_at).getTime()
: Date.now(),
});
}
if (hits.length > 0) {
hits.sort((a, b) => b.timestamp - a.timestamp);
groups.push({ channelId: group.channelId, channelName, results: hits });
}
}
// Channel containing the most matches first; within a channel,
// most-recent first (already sorted above).
groups.sort((a, b) => b.results.length - a.results.length);
return groups;
}, [scan, decryptedMap, needle, channelNameById]);
const totalResults = useMemo(
() => grouped.reduce((acc, g) => acc + g.results.length, 0),
[grouped],
);
const handleJump = (targetChannelId: string, messageId: string) => {
// Server channels all live under the single `home` route in
// this app (convex is single-server). If the hit is in a
// different channel, navigate there first, then dispatch the
// scroll-to event so Messages.tsx can find and scroll into view.
if (targetChannelId !== channelId) {
navigate(`/channels/home/${targetChannelId}`);
}
window.dispatchEvent(
new CustomEvent('brycord:scroll-to-message', {
detail: { messageId, channelId: targetChannelId },
}),
);
};
const isLoading =
!scan || (keyByChannel.size === 0 && (allKeys?.length ?? 0) > 0);
return (
<div className={styles.panel}>
<div className={styles.header}>
<h2 className={styles.headerTitle}>
{needle && !isLoading
? `${totalResults} ${totalResults === 1 ? 'Result' : 'Results'}`
: 'Search'}
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close search"
className={styles.closeButton}
>
<X size={18} weight="bold" />
</button>
</div>
<div className={styles.resultsList}>
{!needle ? (
<div className={styles.emptyState}>
<MagnifyingGlass size={40} className={styles.emptyIcon} />
<p className={styles.emptyTitle}>Search Messages</p>
<p className={styles.emptyDescription}>
Type in the search bar above to find messages.
</p>
</div>
) : isLoading ? (
<div className={styles.emptyState}>
<MagnifyingGlass size={40} className={styles.emptyIcon} />
<p className={styles.emptyTitle}>Searching</p>
<p className={styles.emptyDescription}>
Scanning your channels.
</p>
</div>
) : grouped.length === 0 ? (
<div className={styles.emptyState}>
<MagnifyingGlass size={40} className={styles.emptyIcon} />
<p className={styles.emptyTitle}>No Results</p>
<p className={styles.emptyDescription}>
Try a different search term.
</p>
</div>
) : (
grouped.map((group) => (
<div key={group.channelId} className={styles.channelGroup}>
<div className={styles.channelGroupHeader}>
<Hash
size={16}
weight="bold"
className={styles.channelGroupIcon}
/>
<span className={styles.channelGroupName}>
{group.channelName}
</span>
<span className={styles.channelGroupCount}>
{group.results.length}{' '}
{group.results.length === 1 ? 'result' : 'results'}
</span>
</div>
{group.results.map((r) => {
const urls = r.content ? extractUrls(r.content).slice(0, 2) : [];
return (
<div
key={r.id}
role="button"
tabIndex={0}
className={styles.resultItem}
onClick={() => handleJump(r.channelId, r.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleJump(r.channelId, r.id);
}
}}
>
<div className={styles.resultAvatar}>
<Avatar
src={r.authorAvatarUrl ?? undefined}
size={40}
fallback={r.authorName}
/>
</div>
<div className={styles.resultBody}>
<div className={styles.resultHeader}>
<span className={styles.resultAuthor}>
{r.authorName}
</span>
<span className={styles.resultTimestamp}>
{formatTimestamp(r.timestamp)}
</span>
<button
type="button"
className={styles.jumpButton}
onClick={(e) => {
e.stopPropagation();
handleJump(r.channelId, r.id);
}}
>
Jump
</button>
</div>
{r.content && (
<div className={styles.resultContent}>
{highlight(r.content, needle)}
</div>
)}
{urls.length > 0 && (
<div className={styles.resultEmbeds}>
{urls.map((url) => (
<LinkEmbed key={url} url={url} />
))}
</div>
)}
{r.attachments.length > 0 && (
<div className={styles.resultAttachments}>
{r.attachments.map((att, j) => (
<EncryptedAttachment
key={`${r.id}-${j}`}
metadata={att}
/>
))}
</div>
)}
</div>
</div>
);
})}
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { emojiToCodepoint, getTwemojiUrl } from '../../utils/twemoji';
interface TwemojiImgProps {
emoji: string;
size?: number;
className?: string;
}
/**
* Render a unicode emoji via the Twemoji 14 CDN. Falls back to the
* system emoji if the CDN image 404s (Unicode 15+ characters that
* Twemoji 14 never shipped).
*/
export function TwemojiImg({ emoji, size = 22, className }: TwemojiImgProps) {
const [broken, setBroken] = useState(false);
if (broken) {
return (
<span
className={className}
style={{ fontSize: size, lineHeight: 1, display: 'inline-block' }}
>
{emoji}
</span>
);
}
return (
<img
src={getTwemojiUrl(emoji)}
alt={emoji}
width={size}
height={size}
loading="lazy"
draggable={false}
className={className}
style={{ display: 'inline-block', verticalAlign: 'middle' }}
onError={() => setBroken(true)}
data-emoji-codepoint={emojiToCodepoint(emoji)}
/>
);
}

View File

@@ -0,0 +1,36 @@
.container {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 12px;
font-size: 0.75rem;
}
.dots {
display: flex;
align-items: center;
gap: 2px;
}
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--text-primary);
animation: typingBounce 1.4s infinite ease-in-out;
}
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typingBounce {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.text {
color: var(--text-secondary);
font-weight: 500;
}

View File

@@ -0,0 +1,44 @@
import { useQuery } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import styles from './TypingUsers.module.css';
interface TypingUsersProps {
channelId: string;
}
export function TypingUsers({ channelId }: TypingUsersProps) {
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const typing = useQuery(
api.typing.getTyping,
channelId ? { channelId: channelId as any } : 'skip',
);
const users = useQuery(api.auth.getPublicKeys) ?? [];
const others = (typing ?? []).filter((t: any) => t.userId !== userId);
if (others.length === 0) return null;
const names = others
.map((t: any) => {
const u = users.find((x) => x.id === t.userId);
return u?.displayName || u?.username || 'Someone';
})
.slice(0, 3);
const text =
names.length === 1
? `${names[0]} is typing…`
: names.length === 2
? `${names[0]} and ${names[1]} are typing…`
: 'Several people are typing…';
return (
<div className={styles.container}>
<span className={styles.dots}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</span>
<span className={styles.text}>{text}</span>
</div>
);
}

View File

@@ -0,0 +1,46 @@
/**
* UploadErrorModal — shown when a background upload (shift-to-instant
* drop) fails for any reason other than a size limit. Size-limit
* failures get their own dedicated modal with more explanatory
* copy; this one is for generic errors like 520 from the homeserver's
* media CDN, 413, network drops, or auth issues.
*
* Only a Cancel (OK) button — retrying a silent background upload
* doesn't have a great UX. The user can just drag the file again.
*/
import { Modal, Button } from '@brycord/ui';
import { WarningCircle } from '@phosphor-icons/react';
import styles from './FileSizeLimitModal.module.css';
interface UploadErrorModalProps {
isOpen: boolean;
onClose: () => void;
fileName: string;
message: string;
}
export function UploadErrorModal({ isOpen, onClose, fileName, message }: UploadErrorModalProps) {
if (!isOpen) return null;
return (
<Modal.Root isOpen={isOpen} onClose={onClose} size="small">
<Modal.Header title="Upload failed" onClose={onClose} />
<Modal.Content className={styles.content}>
<div className={styles.iconRow}>
<div className={styles.iconBadge}>
<WarningCircle size={28} weight="fill" />
</div>
</div>
<p className={styles.body}>
Failed to upload <strong>{fileName}</strong>.
</p>
<p className={styles.hint}>{message}</p>
</Modal.Content>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
OK
</Button>
</Modal.Footer>
</Modal.Root>
);
}

View File

@@ -0,0 +1,11 @@
export { ChannelView } from './ChannelView';
export { ChannelHeader } from './ChannelHeader';
export { ChannelChatLayout } from './ChannelChatLayout';
export { Messages } from './Messages';
export { MessageGroup } from './MessageGroup';
export { MessageActionBar } from './MessageActionBar';
export { ReplyBar } from './ReplyBar';
export { ChannelTextarea } from './ChannelTextarea';
export { TypingUsers } from './TypingUsers';
export { ChannelWelcomeSection } from './ChannelWelcomeSection';
export { SearchPanel } from './SearchPanel';