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>
257 lines
8.4 KiB
TypeScript
257 lines
8.4 KiB
TypeScript
import {
|
||
muteDurationToIso,
|
||
setConversationArchived,
|
||
setConversationMutedUntil,
|
||
} from '@chat-app/shared/chat';
|
||
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
import { supabase } from '../lib/supabase';
|
||
import { ArchiveIcon, BellIcon, BellOffIcon, MoreVerticalIcon } from './icons';
|
||
|
||
interface Props {
|
||
conversationId: string;
|
||
archived: boolean;
|
||
mutedUntil: string | null;
|
||
}
|
||
|
||
interface MuteOption {
|
||
key: string;
|
||
labelKey: string;
|
||
labelDefault: string;
|
||
minutes: number | null;
|
||
}
|
||
|
||
// Muted-forever sentinel ≈ 100 years. UI treats any future timestamp as muted
|
||
// until that moment; 100y is indistinguishable from "forever" at the UX level
|
||
// without requiring a dedicated `bool muted_forever` column.
|
||
const FOREVER_MINUTES = 100 * 365 * 24 * 60;
|
||
|
||
const MUTE_OPTIONS: MuteOption[] = [
|
||
{ key: '1h', labelKey: 'app:chats.mute_1h', labelDefault: '1 Stunde', minutes: 60 },
|
||
{ key: '8h', labelKey: 'app:chats.mute_8h', labelDefault: '8 Stunden', minutes: 8 * 60 },
|
||
{ key: '24h', labelKey: 'app:chats.mute_24h', labelDefault: '24 Stunden', minutes: 24 * 60 },
|
||
{ key: '1w', labelKey: 'app:chats.mute_1w', labelDefault: '1 Woche', minutes: 7 * 24 * 60 },
|
||
{
|
||
key: 'forever',
|
||
labelKey: 'app:chats.mute_forever',
|
||
labelDefault: 'Bis auf Weiteres',
|
||
minutes: FOREVER_MINUTES,
|
||
},
|
||
];
|
||
|
||
interface MenuPos {
|
||
top: number;
|
||
left: number;
|
||
}
|
||
|
||
// Per-conversation row context-menu. Renders via portal so the submenu can
|
||
// escape the sidebar's `overflow-y-auto` clipping context. Position is
|
||
// computed from the trigger's bounding rect — menu anchors right-aligned
|
||
// under the trigger so it doesn't push off-screen on narrow windows.
|
||
export function ConversationRowMenu({ conversationId, archived, mutedUntil }: Props) {
|
||
const { t } = useTranslation(['app']);
|
||
const [open, setOpen] = useState(false);
|
||
const [submenuOpen, setSubmenuOpen] = useState<'mute' | null>(null);
|
||
const [menuPos, setMenuPos] = useState<MenuPos | null>(null);
|
||
const [submenuPos, setSubmenuPos] = useState<MenuPos | null>(null);
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
const menuRef = useRef<HTMLDivElement>(null);
|
||
const muteItemRef = useRef<HTMLButtonElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
function onDocClick(e: MouseEvent) {
|
||
const target = e.target as Node;
|
||
if (triggerRef.current?.contains(target)) return;
|
||
if (menuRef.current?.contains(target)) return;
|
||
setOpen(false);
|
||
setSubmenuOpen(null);
|
||
}
|
||
function onEsc(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
setOpen(false);
|
||
setSubmenuOpen(null);
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', onDocClick);
|
||
document.addEventListener('keydown', onEsc);
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDocClick);
|
||
document.removeEventListener('keydown', onEsc);
|
||
};
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setMenuPos(null);
|
||
setSubmenuPos(null);
|
||
return;
|
||
}
|
||
const rect = triggerRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
// Anchor: right edge aligns with trigger's right edge, menu hangs below.
|
||
const menuWidth = 208;
|
||
setMenuPos({
|
||
top: rect.bottom + 4,
|
||
left: Math.max(8, rect.right - menuWidth),
|
||
});
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (submenuOpen !== 'mute') {
|
||
setSubmenuPos(null);
|
||
return;
|
||
}
|
||
const rect = muteItemRef.current?.getBoundingClientRect();
|
||
if (!rect) return;
|
||
const submenuWidth = 192;
|
||
const viewportWidth = window.innerWidth;
|
||
// Prefer right of the item. Flip to left when it would overflow viewport.
|
||
const wantLeft = rect.right + 4;
|
||
const flip = wantLeft + submenuWidth > viewportWidth - 8;
|
||
setSubmenuPos({
|
||
top: rect.top,
|
||
left: flip ? rect.left - submenuWidth - 4 : wantLeft,
|
||
});
|
||
}, [submenuOpen]);
|
||
|
||
const isMuted =
|
||
mutedUntil !== null && new Date(mutedUntil).getTime() > Date.now();
|
||
|
||
const handleArchive = useCallback(
|
||
async (next: boolean) => {
|
||
setOpen(false);
|
||
try {
|
||
await setConversationArchived(supabase, conversationId, next);
|
||
} catch (err: unknown) {
|
||
console.error('archive toggle failed', err);
|
||
}
|
||
},
|
||
[conversationId],
|
||
);
|
||
|
||
const handleMute = useCallback(
|
||
async (minutes: number | null) => {
|
||
setOpen(false);
|
||
setSubmenuOpen(null);
|
||
try {
|
||
await setConversationMutedUntil(supabase, conversationId, muteDurationToIso(minutes));
|
||
} catch (err: unknown) {
|
||
console.error('mute toggle failed', err);
|
||
}
|
||
},
|
||
[conversationId],
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
ref={triggerRef}
|
||
type="button"
|
||
aria-label={t('app:chats.row_menu', { defaultValue: 'Aktionen' })}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setOpen((v) => !v);
|
||
}}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted opacity-0 transition group-hover:opacity-100 hover:bg-surface-3 hover:text-fg focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||
>
|
||
<MoreVerticalIcon className="h-4 w-4" />
|
||
</button>
|
||
|
||
{open &&
|
||
menuPos &&
|
||
createPortal(
|
||
<div
|
||
ref={menuRef}
|
||
role="menu"
|
||
style={{ top: menuPos.top, left: menuPos.left }}
|
||
className="fixed z-50 w-52 rounded-lg border border-line bg-surface-3 p-1 shadow-xl dark:bg-[#313338]"
|
||
>
|
||
<MenuItem
|
||
icon={<ArchiveIcon className="h-4 w-4" />}
|
||
label={
|
||
archived
|
||
? t('app:chats.unarchive', { defaultValue: 'Entarchivieren' })
|
||
: t('app:chats.archive', { defaultValue: 'Archivieren' })
|
||
}
|
||
onClick={() => void handleArchive(!archived)}
|
||
/>
|
||
<MenuItem
|
||
ref={muteItemRef}
|
||
icon={
|
||
isMuted ? <BellOffIcon className="h-4 w-4" /> : <BellIcon className="h-4 w-4" />
|
||
}
|
||
label={
|
||
isMuted
|
||
? t('app:chats.unmute', { defaultValue: 'Stummschaltung aufheben' })
|
||
: t('app:chats.mute', { defaultValue: 'Stummschalten' })
|
||
}
|
||
onClick={() => {
|
||
if (isMuted) void handleMute(null);
|
||
else setSubmenuOpen((v) => (v === 'mute' ? null : 'mute'));
|
||
}}
|
||
hasSubmenu={!isMuted}
|
||
/>
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
|
||
{open &&
|
||
submenuOpen === 'mute' &&
|
||
submenuPos &&
|
||
createPortal(
|
||
<div
|
||
role="menu"
|
||
style={{ top: submenuPos.top, left: submenuPos.left }}
|
||
className="fixed z-50 w-48 rounded-lg border border-line bg-surface-3 p-1 shadow-xl dark:bg-[#313338]"
|
||
>
|
||
{MUTE_OPTIONS.map((opt) => (
|
||
<MenuItem
|
||
key={opt.key}
|
||
label={t(opt.labelKey, { defaultValue: opt.labelDefault })}
|
||
onClick={() => void handleMute(opt.minutes)}
|
||
/>
|
||
))}
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
interface MenuItemProps {
|
||
icon?: React.ReactNode;
|
||
label: string;
|
||
onClick: () => void;
|
||
hasSubmenu?: boolean;
|
||
}
|
||
|
||
// React 18 requires forwardRef for function components to receive refs —
|
||
// without it the `ref` prop is stripped before reaching the component and
|
||
// measurement-dependent submenus never position.
|
||
const MenuItem = forwardRef<HTMLButtonElement, MenuItemProps>(
|
||
({ icon, label, onClick, hasSubmenu }, ref) => (
|
||
<button
|
||
ref={ref}
|
||
type="button"
|
||
role="menuitem"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onClick();
|
||
}}
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg transition hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:hover:bg-[#383a40]"
|
||
>
|
||
{icon && <span className="shrink-0 text-fg-muted">{icon}</span>}
|
||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||
{hasSubmenu && <span className="shrink-0 text-xs text-fg-muted">›</span>}
|
||
</button>
|
||
),
|
||
);
|
||
MenuItem.displayName = 'MenuItem';
|