672c8738c7
Attachments
- Video: native <video controls>, decrypted blob, metadata preload
- PDF: collapsible <object> viewer with download + preview toggle
- Generic: file card with lazy decrypt, mime-to-extension map
- MessageBubble dispatch: audio/image/video/pdf/generic by mime
- Composer accept widened from image/* to any; AttachmentPreview renders
non-images as file cards
User status
- usePeerPresence returns { state, statusMessage } and tracks both fields
via realtime updates
- ConversationHeader subtitle shows custom status_message when peer is
online/idle/dnd (with message set); falls back to localized presence
label; always "Offline" when state === 'offline'
- UserBar: status_message input in dropdown (max 128 chars, save on
blur/Enter), subtitle uses same precedence as peer view
- AuthContext: auto-flip offline → online on session mount; best-effort
offline on beforeunload/pagehide; respects explicit idle/dnd/invisible
- i18n: presence.status_placeholder (DE + EN)
DND
- CallUI: incoming ring suppressed when own presence === 'dnd' (outgoing
rings stay audible — user-initiated)
- CallContext: presenceRef mirrors profile.presenceState, incoming-call
and missed-call OS notifications skipped under DND
- ConversationsContext already gated message tone + notify
Drag/drop + paste
- Page-root drag handlers feed ingestFiles; overlay shown while dragging
- Textarea onPaste extracts clipboard image items
@mentions in groups
- MentionAutocomplete: keyboard-driven dropdown, arrow/enter/tab/esc
- Composer onChange parses trailing @token, auto-open
- renderBodyWithMentions highlights @username tokens in bubbles
Link previews
- Migration 20260421000003_link_previews.sql (cache table, auth read,
service-role write via edge function)
- Edge function og-preview: POST {url}, fetches HTML (6s timeout, 1MB
cap), regex-parses OG/twitter/description, upserts cache
- useLinkPreview hook with in-memory cache + inflight dedup
- LinkPreviewCard rendered from first URL in message body
Emoji picker
- Built-in curated set (~250 emojis) across five categories
- Search by keyword/emoji char/category, recent list persisted in
localStorage
- Trigger button next to + and voice buttons in composer
Soundboard + ringtone + mic pipeline
- Pulled in (user-authored) SoundboardManagerDialog/Panel/Settings,
RingtoneSettings, mic pipeline + hotkeys + storage modules
- AuthContext imports updateOwnProfile for presence auto-flip
Fixes
- AttachmentAudio min-width so bubbles don't collapse to 0px on peer
side
- Focus flicker: visibility/online wake refresh throttled to 30s,
focus listener dropped, loading flag only on first fetch
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
interface EmojiEntry {
|
|
e: string;
|
|
k: string[]; // search keywords (incl. name)
|
|
}
|
|
|
|
interface Category {
|
|
label: string;
|
|
entries: EmojiEntry[];
|
|
}
|
|
|
|
// Curated emoji set — small enough to stay fast without a dependency, wide
|
|
// enough to cover everyday messaging. Keywords are the primary search
|
|
// surface; the emoji character itself is also matched so a user typing ❤️
|
|
// literally finds it.
|
|
const CATEGORIES: Category[] = [
|
|
{
|
|
label: 'Smileys',
|
|
entries: [
|
|
{ e: '😀', k: ['grin', 'smile', 'happy'] },
|
|
{ e: '😃', k: ['smile', 'happy'] },
|
|
{ e: '😄', k: ['smile', 'laugh'] },
|
|
{ e: '😁', k: ['grin', 'smile'] },
|
|
{ e: '😆', k: ['laugh', 'lol'] },
|
|
{ e: '😅', k: ['sweat', 'nervous', 'laugh'] },
|
|
{ e: '🤣', k: ['lol', 'rofl', 'laugh'] },
|
|
{ e: '😂', k: ['joy', 'laugh', 'tears'] },
|
|
{ e: '🙂', k: ['smile', 'slight'] },
|
|
{ e: '🙃', k: ['upside', 'irony'] },
|
|
{ e: '😉', k: ['wink'] },
|
|
{ e: '😊', k: ['blush', 'smile'] },
|
|
{ e: '😇', k: ['angel', 'innocent'] },
|
|
{ e: '🥰', k: ['love', 'hearts'] },
|
|
{ e: '😍', k: ['love', 'heart eyes'] },
|
|
{ e: '🤩', k: ['star', 'excited'] },
|
|
{ e: '😘', k: ['kiss'] },
|
|
{ e: '😗', k: ['kiss'] },
|
|
{ e: '😚', k: ['kiss'] },
|
|
{ e: '😙', k: ['kiss'] },
|
|
{ e: '🥲', k: ['tear', 'smile'] },
|
|
{ e: '😋', k: ['yum', 'tasty'] },
|
|
{ e: '😛', k: ['tongue'] },
|
|
{ e: '😜', k: ['tongue', 'wink'] },
|
|
{ e: '🤪', k: ['zany', 'silly'] },
|
|
{ e: '😝', k: ['tongue'] },
|
|
{ e: '🤑', k: ['money'] },
|
|
{ e: '🤗', k: ['hug'] },
|
|
{ e: '🤭', k: ['giggle', 'shy'] },
|
|
{ e: '🤫', k: ['shush', 'quiet'] },
|
|
{ e: '🤔', k: ['think'] },
|
|
{ e: '🤐', k: ['zip', 'quiet'] },
|
|
{ e: '🤨', k: ['raise brow'] },
|
|
{ e: '😐', k: ['neutral'] },
|
|
{ e: '😑', k: ['expressionless'] },
|
|
{ e: '😶', k: ['speechless'] },
|
|
{ e: '😏', k: ['smirk'] },
|
|
{ e: '😒', k: ['unamused'] },
|
|
{ e: '🙄', k: ['eye roll'] },
|
|
{ e: '😬', k: ['grimace', 'awkward'] },
|
|
{ e: '🤥', k: ['lying'] },
|
|
{ e: '😔', k: ['sad', 'pensive'] },
|
|
{ e: '😪', k: ['sleepy'] },
|
|
{ e: '😴', k: ['sleep'] },
|
|
{ e: '😷', k: ['mask', 'sick'] },
|
|
{ e: '🤒', k: ['sick', 'fever'] },
|
|
{ e: '🤕', k: ['injured'] },
|
|
{ e: '🤢', k: ['nauseated'] },
|
|
{ e: '🤮', k: ['vomit'] },
|
|
{ e: '🤧', k: ['sneeze'] },
|
|
{ e: '🥵', k: ['hot'] },
|
|
{ e: '🥶', k: ['cold'] },
|
|
{ e: '🥴', k: ['dizzy', 'woozy'] },
|
|
{ e: '😵', k: ['dizzy'] },
|
|
{ e: '🤯', k: ['mind blown'] },
|
|
{ e: '🤠', k: ['cowboy'] },
|
|
{ e: '🥳', k: ['party'] },
|
|
{ e: '😎', k: ['cool', 'sunglasses'] },
|
|
{ e: '🤓', k: ['nerd'] },
|
|
{ e: '🧐', k: ['monocle'] },
|
|
{ e: '😕', k: ['confused'] },
|
|
{ e: '😟', k: ['worried'] },
|
|
{ e: '🙁', k: ['frown'] },
|
|
{ e: '☹️', k: ['frown'] },
|
|
{ e: '😮', k: ['open mouth'] },
|
|
{ e: '😯', k: ['hushed'] },
|
|
{ e: '😲', k: ['astonished'] },
|
|
{ e: '😳', k: ['flushed'] },
|
|
{ e: '🥺', k: ['pleading'] },
|
|
{ e: '😦', k: ['frowning'] },
|
|
{ e: '😧', k: ['anguished'] },
|
|
{ e: '😨', k: ['fear'] },
|
|
{ e: '😰', k: ['anxious', 'sweat'] },
|
|
{ e: '😥', k: ['sad', 'relieved'] },
|
|
{ e: '😢', k: ['cry'] },
|
|
{ e: '😭', k: ['cry', 'loud'] },
|
|
{ e: '😱', k: ['scream', 'scared'] },
|
|
{ e: '😖', k: ['confounded'] },
|
|
{ e: '😣', k: ['persevere'] },
|
|
{ e: '😞', k: ['disappointed'] },
|
|
{ e: '😓', k: ['sweat'] },
|
|
{ e: '😩', k: ['weary'] },
|
|
{ e: '😫', k: ['tired'] },
|
|
{ e: '🥱', k: ['yawn'] },
|
|
{ e: '😤', k: ['triumph'] },
|
|
{ e: '😡', k: ['angry', 'rage'] },
|
|
{ e: '😠', k: ['angry'] },
|
|
{ e: '🤬', k: ['swear', 'curse'] },
|
|
{ e: '😈', k: ['devil'] },
|
|
{ e: '👿', k: ['imp'] },
|
|
{ e: '💀', k: ['skull', 'dead'] },
|
|
{ e: '🤡', k: ['clown'] },
|
|
{ e: '👻', k: ['ghost'] },
|
|
{ e: '👽', k: ['alien'] },
|
|
{ e: '🤖', k: ['robot'] },
|
|
{ e: '💩', k: ['poop', 'shit'] },
|
|
],
|
|
},
|
|
{
|
|
label: 'Gestures',
|
|
entries: [
|
|
{ e: '👋', k: ['wave', 'hi'] },
|
|
{ e: '🤚', k: ['hand'] },
|
|
{ e: '🖐️', k: ['hand'] },
|
|
{ e: '✋', k: ['stop', 'high five'] },
|
|
{ e: '🖖', k: ['spock'] },
|
|
{ e: '👌', k: ['ok'] },
|
|
{ e: '🤌', k: ['pinch'] },
|
|
{ e: '🤏', k: ['small'] },
|
|
{ e: '✌️', k: ['peace', 'victory'] },
|
|
{ e: '🤞', k: ['crossed fingers'] },
|
|
{ e: '🤟', k: ['love you'] },
|
|
{ e: '🤘', k: ['rock'] },
|
|
{ e: '🤙', k: ['call me'] },
|
|
{ e: '👈', k: ['point left'] },
|
|
{ e: '👉', k: ['point right'] },
|
|
{ e: '👆', k: ['point up'] },
|
|
{ e: '🖕', k: ['middle finger', 'fuck'] },
|
|
{ e: '👇', k: ['point down'] },
|
|
{ e: '☝️', k: ['point up'] },
|
|
{ e: '👍', k: ['thumbs up', 'like'] },
|
|
{ e: '👎', k: ['thumbs down', 'dislike'] },
|
|
{ e: '✊', k: ['fist'] },
|
|
{ e: '👊', k: ['punch'] },
|
|
{ e: '🤛', k: ['fist left'] },
|
|
{ e: '🤜', k: ['fist right'] },
|
|
{ e: '👏', k: ['clap'] },
|
|
{ e: '🙌', k: ['raised hands'] },
|
|
{ e: '👐', k: ['open hands'] },
|
|
{ e: '🤲', k: ['palms'] },
|
|
{ e: '🙏', k: ['pray', 'thanks'] },
|
|
{ e: '✍️', k: ['write'] },
|
|
{ e: '💪', k: ['flex', 'strong'] },
|
|
],
|
|
},
|
|
{
|
|
label: 'Hearts',
|
|
entries: [
|
|
{ e: '❤️', k: ['heart', 'love'] },
|
|
{ e: '🧡', k: ['orange heart'] },
|
|
{ e: '💛', k: ['yellow heart'] },
|
|
{ e: '💚', k: ['green heart'] },
|
|
{ e: '💙', k: ['blue heart'] },
|
|
{ e: '💜', k: ['purple heart'] },
|
|
{ e: '🖤', k: ['black heart'] },
|
|
{ e: '🤍', k: ['white heart'] },
|
|
{ e: '🤎', k: ['brown heart'] },
|
|
{ e: '💔', k: ['broken heart'] },
|
|
{ e: '❣️', k: ['heart exclamation'] },
|
|
{ e: '💕', k: ['hearts'] },
|
|
{ e: '💞', k: ['revolving hearts'] },
|
|
{ e: '💓', k: ['beating heart'] },
|
|
{ e: '💗', k: ['growing heart'] },
|
|
{ e: '💖', k: ['sparkle heart'] },
|
|
{ e: '💘', k: ['cupid'] },
|
|
{ e: '💝', k: ['heart gift'] },
|
|
],
|
|
},
|
|
{
|
|
label: 'Animals & Food',
|
|
entries: [
|
|
{ e: '🐶', k: ['dog'] },
|
|
{ e: '🐱', k: ['cat'] },
|
|
{ e: '🐭', k: ['mouse'] },
|
|
{ e: '🐹', k: ['hamster'] },
|
|
{ e: '🐰', k: ['rabbit'] },
|
|
{ e: '🦊', k: ['fox'] },
|
|
{ e: '🐻', k: ['bear'] },
|
|
{ e: '🐼', k: ['panda'] },
|
|
{ e: '🐨', k: ['koala'] },
|
|
{ e: '🐯', k: ['tiger'] },
|
|
{ e: '🦁', k: ['lion'] },
|
|
{ e: '🐸', k: ['frog'] },
|
|
{ e: '🐵', k: ['monkey'] },
|
|
{ e: '🐔', k: ['chicken'] },
|
|
{ e: '🐧', k: ['penguin'] },
|
|
{ e: '🐦', k: ['bird'] },
|
|
{ e: '🦆', k: ['duck'] },
|
|
{ e: '🍎', k: ['apple'] },
|
|
{ e: '🍌', k: ['banana'] },
|
|
{ e: '🍕', k: ['pizza'] },
|
|
{ e: '🍔', k: ['burger'] },
|
|
{ e: '🍟', k: ['fries'] },
|
|
{ e: '🌭', k: ['hotdog'] },
|
|
{ e: '🍿', k: ['popcorn'] },
|
|
{ e: '🍣', k: ['sushi'] },
|
|
{ e: '🍩', k: ['donut'] },
|
|
{ e: '🍪', k: ['cookie'] },
|
|
{ e: '🎂', k: ['cake', 'birthday'] },
|
|
{ e: '🍰', k: ['cake'] },
|
|
{ e: '🍫', k: ['chocolate'] },
|
|
{ e: '🍺', k: ['beer'] },
|
|
{ e: '🍷', k: ['wine'] },
|
|
{ e: '🥂', k: ['cheers'] },
|
|
{ e: '☕', k: ['coffee'] },
|
|
],
|
|
},
|
|
{
|
|
label: 'Objects & Symbols',
|
|
entries: [
|
|
{ e: '🔥', k: ['fire', 'lit'] },
|
|
{ e: '✨', k: ['sparkle'] },
|
|
{ e: '⭐', k: ['star'] },
|
|
{ e: '🌟', k: ['star glowing'] },
|
|
{ e: '💫', k: ['dizzy'] },
|
|
{ e: '💥', k: ['boom', 'explosion'] },
|
|
{ e: '⚡', k: ['lightning'] },
|
|
{ e: '☀️', k: ['sun'] },
|
|
{ e: '🌈', k: ['rainbow'] },
|
|
{ e: '☁️', k: ['cloud'] },
|
|
{ e: '🌧️', k: ['rain'] },
|
|
{ e: '❄️', k: ['snow'] },
|
|
{ e: '🎉', k: ['party', 'tada'] },
|
|
{ e: '🎊', k: ['confetti'] },
|
|
{ e: '🎁', k: ['gift'] },
|
|
{ e: '🎈', k: ['balloon'] },
|
|
{ e: '💯', k: ['100', 'perfect'] },
|
|
{ e: '✅', k: ['check'] },
|
|
{ e: '❌', k: ['x', 'no'] },
|
|
{ e: '⚠️', k: ['warning'] },
|
|
{ e: '❓', k: ['question'] },
|
|
{ e: '❗', k: ['exclamation'] },
|
|
{ e: '💬', k: ['speech'] },
|
|
{ e: '💭', k: ['thought'] },
|
|
{ e: '👀', k: ['eyes'] },
|
|
{ e: '🚀', k: ['rocket'] },
|
|
{ e: '🎵', k: ['music'] },
|
|
{ e: '🎶', k: ['music'] },
|
|
{ e: '🔔', k: ['bell'] },
|
|
{ e: '💡', k: ['idea', 'bulb'] },
|
|
],
|
|
},
|
|
];
|
|
|
|
const RECENT_KEY = 'chat.emoji.recents.v1';
|
|
const RECENT_MAX = 24;
|
|
|
|
function loadRecents(): string[] {
|
|
try {
|
|
const raw = localStorage.getItem(RECENT_KEY);
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.filter((x): x is string => typeof x === 'string').slice(0, RECENT_MAX);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveRecents(list: string[]): void {
|
|
try {
|
|
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
|
|
} catch {
|
|
/* ignore quota */
|
|
}
|
|
}
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onPick: (emoji: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function EmojiPicker({ open, onPick, onClose }: Props) {
|
|
const [query, setQuery] = useState('');
|
|
const [recents, setRecents] = useState<string[]>(() => loadRecents());
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onClose();
|
|
}
|
|
};
|
|
const onDown = (e: MouseEvent) => {
|
|
const t = e.target as HTMLElement | null;
|
|
if (rootRef.current && t && !rootRef.current.contains(t) && !t.closest('[data-emoji-trigger]')) {
|
|
onClose();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
window.addEventListener('mousedown', onDown);
|
|
return () => {
|
|
window.removeEventListener('keydown', onKey);
|
|
window.removeEventListener('mousedown', onDown);
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (open) setQuery('');
|
|
}, [open]);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return CATEGORIES;
|
|
return CATEGORIES.map((cat) => ({
|
|
label: cat.label,
|
|
entries: cat.entries.filter(
|
|
(entry) =>
|
|
entry.e.includes(q) ||
|
|
entry.k.some((k) => k.includes(q)) ||
|
|
cat.label.toLowerCase().includes(q),
|
|
),
|
|
})).filter((cat) => cat.entries.length > 0);
|
|
}, [query]);
|
|
|
|
if (!open) return null;
|
|
|
|
const handlePick = (emoji: string) => {
|
|
onPick(emoji);
|
|
const next = [emoji, ...recents.filter((e) => e !== emoji)].slice(0, RECENT_MAX);
|
|
setRecents(next);
|
|
saveRecents(next);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
role="dialog"
|
|
aria-label="Emoji auswählen"
|
|
className="absolute bottom-full right-0 z-30 mb-2 flex w-[320px] flex-col rounded-xl border border-line bg-surface-2 shadow-xl"
|
|
>
|
|
<div className="border-b border-line p-2">
|
|
<input
|
|
type="search"
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Suchen…"
|
|
className="w-full rounded-md border border-line bg-surface-3 px-2.5 py-1.5 text-sm text-fg placeholder-fg-muted outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
<div className="max-h-[320px] overflow-y-auto p-2">
|
|
{recents.length > 0 && !query && (
|
|
<CategoryBlock
|
|
label="Zuletzt"
|
|
entries={recents.map((e) => ({ e, k: [] }))}
|
|
onPick={handlePick}
|
|
/>
|
|
)}
|
|
{filtered.map((cat) => (
|
|
<CategoryBlock
|
|
key={cat.label}
|
|
label={cat.label}
|
|
entries={cat.entries}
|
|
onPick={handlePick}
|
|
/>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<p className="py-4 text-center text-xs text-fg-muted">Keine Treffer</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CategoryBlock({
|
|
label,
|
|
entries,
|
|
onPick,
|
|
}: {
|
|
label: string;
|
|
entries: EmojiEntry[];
|
|
onPick: (emoji: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="mb-2">
|
|
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{label}
|
|
</p>
|
|
<div className="grid grid-cols-8 gap-0.5">
|
|
{entries.map((entry, idx) => (
|
|
<button
|
|
key={entry.e + ':' + idx}
|
|
type="button"
|
|
onClick={() => onPick(entry.e)}
|
|
aria-label={entry.k[0] ?? entry.e}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-lg transition hover:bg-surface-3"
|
|
>
|
|
{entry.e}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|