825160ee46
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.
Highlights:
- All 17 Tauri release commits ported (audio fixes, custom notification
sound, Discord-style chat UX, profile banner, changelog page,
Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
fix).
- Native napi-rs audio-loopback addon with WASAPI process-loopback:
* EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
never hear themselves echoed back through the capture.
* INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
picked window's audio is captured, not the whole OS mixer
(Discord parity).
* HWND -> PID resolution via Win32 GetWindowThreadProcessId.
- Discord-style screen-source picker (thumbnail grid, screens vs
apps tabs, live-refreshing thumbnails).
- Hash routing fix for packaged builds (file:// can't resolve
BrowserRouter paths).
- Tauri sources removed (apps/desktop/src-tauri).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
414 lines
13 KiB
TypeScript
414 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 dark:bg-[#2b2d31]"
|
|
>
|
|
<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 dark:bg-[#383a40]"
|
|
/>
|
|
</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 dark:hover:bg-[#383a40]"
|
|
>
|
|
{entry.e}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|