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
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:
135
packages/shared/src/components/channel/AccessPicker.module.css
Normal file
135
packages/shared/src/components/channel/AccessPicker.module.css
Normal 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);
|
||||
}
|
||||
136
packages/shared/src/components/channel/AccessPicker.tsx
Normal file
136
packages/shared/src/components/channel/AccessPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
335
packages/shared/src/components/channel/AttachmentAudio.tsx
Normal file
335
packages/shared/src/components/channel/AttachmentAudio.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
348
packages/shared/src/components/channel/AttachmentVideo.tsx
Normal file
348
packages/shared/src/components/channel/AttachmentVideo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
38
packages/shared/src/components/channel/ChannelChatLayout.tsx
Normal file
38
packages/shared/src/components/channel/ChannelChatLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
301
packages/shared/src/components/channel/ChannelDetailsDrawer.tsx
Normal file
301
packages/shared/src/components/channel/ChannelDetailsDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
405
packages/shared/src/components/channel/ChannelHeader.module.css
Normal file
405
packages/shared/src/components/channel/ChannelHeader.module.css
Normal 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;
|
||||
}
|
||||
497
packages/shared/src/components/channel/ChannelHeader.tsx
Normal file
497
packages/shared/src/components/channel/ChannelHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
277
packages/shared/src/components/channel/ChannelSettingsModal.tsx
Normal file
277
packages/shared/src/components/channel/ChannelSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1037
packages/shared/src/components/channel/ChannelTextarea.tsx
Normal file
1037
packages/shared/src/components/channel/ChannelTextarea.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
125
packages/shared/src/components/channel/ChannelView.tsx
Normal file
125
packages/shared/src/components/channel/ChannelView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
124
packages/shared/src/components/channel/ChannelWelcomeSection.tsx
Normal file
124
packages/shared/src/components/channel/ChannelWelcomeSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
237
packages/shared/src/components/channel/CreatePollModal.tsx
Normal file
237
packages/shared/src/components/channel/CreatePollModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
packages/shared/src/components/channel/CustomEmojiImage.tsx
Normal file
25
packages/shared/src/components/channel/CustomEmojiImage.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
144
packages/shared/src/components/channel/EditAttachmentModal.tsx
Normal file
144
packages/shared/src/components/channel/EditAttachmentModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
581
packages/shared/src/components/channel/EmojiPicker.module.css
Normal file
581
packages/shared/src/components/channel/EmojiPicker.module.css
Normal 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);
|
||||
}
|
||||
736
packages/shared/src/components/channel/EmojiPicker.tsx
Normal file
736
packages/shared/src/components/channel/EmojiPicker.tsx
Normal 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;
|
||||
249
packages/shared/src/components/channel/EncryptedAttachment.tsx
Normal file
249
packages/shared/src/components/channel/EncryptedAttachment.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
128
packages/shared/src/components/channel/FileUploadDropZone.tsx
Normal file
128
packages/shared/src/components/channel/FileUploadDropZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
194
packages/shared/src/components/channel/FileUploadModal.tsx
Normal file
194
packages/shared/src/components/channel/FileUploadModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
packages/shared/src/components/channel/GifPicker.module.css
Normal file
202
packages/shared/src/components/channel/GifPicker.module.css
Normal 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);
|
||||
}
|
||||
260
packages/shared/src/components/channel/GifPicker.tsx
Normal file
260
packages/shared/src/components/channel/GifPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
packages/shared/src/components/channel/ImageLightbox.module.css
Normal file
238
packages/shared/src/components/channel/ImageLightbox.module.css
Normal 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);
|
||||
}
|
||||
298
packages/shared/src/components/channel/ImageLightbox.tsx
Normal file
298
packages/shared/src/components/channel/ImageLightbox.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
147
packages/shared/src/components/channel/InlineMessageEditor.tsx
Normal file
147
packages/shared/src/components/channel/InlineMessageEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
packages/shared/src/components/channel/LinkEmbed.module.css
Normal file
203
packages/shared/src/components/channel/LinkEmbed.module.css
Normal 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;
|
||||
}
|
||||
265
packages/shared/src/components/channel/LinkEmbed.tsx
Normal file
265
packages/shared/src/components/channel/LinkEmbed.tsx
Normal 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;
|
||||
@@ -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%;
|
||||
}
|
||||
257
packages/shared/src/components/channel/MentionAutocomplete.tsx
Normal file
257
packages/shared/src/components/channel/MentionAutocomplete.tsx
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
326
packages/shared/src/components/channel/MessageActionBar.tsx
Normal file
326
packages/shared/src/components/channel/MessageActionBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
packages/shared/src/components/channel/MessageContent.module.css
Normal file
163
packages/shared/src/components/channel/MessageContent.module.css
Normal 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;
|
||||
}
|
||||
213
packages/shared/src/components/channel/MessageContent.tsx
Normal file
213
packages/shared/src/components/channel/MessageContent.tsx
Normal 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')}</>;
|
||||
}
|
||||
374
packages/shared/src/components/channel/MessageGroup.module.css
Normal file
374
packages/shared/src/components/channel/MessageGroup.module.css
Normal 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;
|
||||
}
|
||||
735
packages/shared/src/components/channel/MessageGroup.tsx
Normal file
735
packages/shared/src/components/channel/MessageGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
185
packages/shared/src/components/channel/MessageInspectModal.tsx
Normal file
185
packages/shared/src/components/channel/MessageInspectModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
packages/shared/src/components/channel/Messages.module.css
Normal file
120
packages/shared/src/components/channel/Messages.module.css
Normal 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;
|
||||
}
|
||||
748
packages/shared/src/components/channel/Messages.tsx
Normal file
748
packages/shared/src/components/channel/Messages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
105
packages/shared/src/components/channel/PendingAttachmentRow.tsx
Normal file
105
packages/shared/src/components/channel/PendingAttachmentRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
136
packages/shared/src/components/channel/PinConfirmationModal.tsx
Normal file
136
packages/shared/src/components/channel/PinConfirmationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
178
packages/shared/src/components/channel/PinnedMessageRow.tsx
Normal file
178
packages/shared/src/components/channel/PinnedMessageRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
packages/shared/src/components/channel/PollCard.module.css
Normal file
251
packages/shared/src/components/channel/PollCard.module.css
Normal 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);
|
||||
}
|
||||
349
packages/shared/src/components/channel/PollCard.tsx
Normal file
349
packages/shared/src/components/channel/PollCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
packages/shared/src/components/channel/ReactionsModal.module.css
Normal file
128
packages/shared/src/components/channel/ReactionsModal.module.css
Normal 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);
|
||||
}
|
||||
139
packages/shared/src/components/channel/ReactionsModal.tsx
Normal file
139
packages/shared/src/components/channel/ReactionsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
packages/shared/src/components/channel/ReplyBar.module.css
Normal file
35
packages/shared/src/components/channel/ReplyBar.module.css
Normal 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);
|
||||
}
|
||||
20
packages/shared/src/components/channel/ReplyBar.tsx
Normal file
20
packages/shared/src/components/channel/ReplyBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
232
packages/shared/src/components/channel/SaveMediaModal.module.css
Normal file
232
packages/shared/src/components/channel/SaveMediaModal.module.css
Normal 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;
|
||||
}
|
||||
245
packages/shared/src/components/channel/SaveMediaModal.tsx
Normal file
245
packages/shared/src/components/channel/SaveMediaModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
packages/shared/src/components/channel/SearchDrawer.module.css
Normal file
289
packages/shared/src/components/channel/SearchDrawer.module.css
Normal 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;
|
||||
}
|
||||
326
packages/shared/src/components/channel/SearchDrawer.tsx
Normal file
326
packages/shared/src/components/channel/SearchDrawer.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
264
packages/shared/src/components/channel/SearchPanel.module.css
Normal file
264
packages/shared/src/components/channel/SearchPanel.module.css
Normal 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;
|
||||
}
|
||||
490
packages/shared/src/components/channel/SearchPanel.tsx
Normal file
490
packages/shared/src/components/channel/SearchPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
packages/shared/src/components/channel/TwemojiImg.tsx
Normal file
41
packages/shared/src/components/channel/TwemojiImg.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
44
packages/shared/src/components/channel/TypingUsers.tsx
Normal file
44
packages/shared/src/components/channel/TypingUsers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
packages/shared/src/components/channel/UploadErrorModal.tsx
Normal file
46
packages/shared/src/components/channel/UploadErrorModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
packages/shared/src/components/channel/index.ts
Normal file
11
packages/shared/src/components/channel/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user