Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d803773261 | |||
| 49855c5d3f | |||
| c9a64bf898 | |||
| 7f704e80f6 | |||
| 940432d287 | |||
| 28b6d64936 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@chat-app/desktop",
|
"name": "@chat-app/desktop",
|
||||||
"version": "0.19.1",
|
"version": "0.20.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop client (Windows / macOS / Linux)",
|
"description": "Electron desktop client (Windows / macOS / Linux)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import type { ConversationSummary } from '@chat-app/shared/chat';
|
|
||||||
import { useCall } from '../context/CallContext';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
conversation: ConversationSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STALE_AFTER_MS = 5000;
|
|
||||||
|
|
||||||
/** Discord-style live-captions overlay. Pinned to the bottom-center of the
|
|
||||||
* call surface; renders the most recent caption per participant, fading
|
|
||||||
* entries out after `STALE_AFTER_MS` of silence. Self-captions are shown
|
|
||||||
* too so the speaker can sanity-check what's being broadcast. */
|
|
||||||
export function CallCaptionsOverlay({ conversation }: Props) {
|
|
||||||
const { captions } = useCall();
|
|
||||||
// Re-render every second so stale entries fade without needing the data
|
|
||||||
// channel to fire — captions module just stores timestamps.
|
|
||||||
const [, setNow] = useState(Date.now());
|
|
||||||
useEffect(() => {
|
|
||||||
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => window.clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const visible = Object.entries(captions)
|
|
||||||
.filter(([, v]) => now - v.timestamp < STALE_AFTER_MS)
|
|
||||||
.sort(([, a], [, b]) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
if (visible.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-24 z-30 flex flex-col items-center gap-1.5 px-6">
|
|
||||||
{visible.map(([identity, c]) => {
|
|
||||||
const member = conversation.members.find((m) => m.userId === identity);
|
|
||||||
const name = member?.profile?.displayName ?? '?';
|
|
||||||
const age = now - c.timestamp;
|
|
||||||
const opacity = age > 3500 ? 1 - (age - 3500) / (STALE_AFTER_MS - 3500) : 1;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={identity}
|
|
||||||
style={{ opacity: Math.max(0, opacity) }}
|
|
||||||
className="max-w-[760px] rounded-lg bg-black/72 px-3.5 py-1.5 text-sm text-white shadow-md backdrop-blur-md transition-opacity"
|
|
||||||
>
|
|
||||||
<span className="mr-2 text-[11px] font-semibold uppercase tracking-wide text-white/55">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<span className={c.final ? '' : 'italic text-white/85'}>{c.text}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CaptionsIcon,
|
|
||||||
HeadphonesIcon,
|
HeadphonesIcon,
|
||||||
HeadphonesOffIcon,
|
HeadphonesOffIcon,
|
||||||
MicIcon,
|
MicIcon,
|
||||||
@@ -32,10 +31,6 @@ interface Props {
|
|||||||
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
/** Toggle the in-call soundboard popover. Active = panel currently open. */
|
||||||
onToggleSoundboard?: () => void;
|
onToggleSoundboard?: () => void;
|
||||||
soundboardOpen?: boolean;
|
soundboardOpen?: boolean;
|
||||||
/** Discord-style live-captions toggle. Optional — pages that don't support
|
|
||||||
* SpeechRecognition (Firefox) skip the prop and the button is hidden. */
|
|
||||||
onToggleCaptions?: () => void;
|
|
||||||
captionsOn?: boolean;
|
|
||||||
participantsOpen?: boolean;
|
participantsOpen?: boolean;
|
||||||
/** Compact variant used inside the docked call (36px buttons). */
|
/** Compact variant used inside the docked call (36px buttons). */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -59,8 +54,6 @@ export function CallControls({
|
|||||||
onOpenParticipants,
|
onOpenParticipants,
|
||||||
onToggleSoundboard,
|
onToggleSoundboard,
|
||||||
soundboardOpen = false,
|
soundboardOpen = false,
|
||||||
onToggleCaptions,
|
|
||||||
captionsOn = false,
|
|
||||||
participantsOpen = false,
|
participantsOpen = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
glass = false,
|
glass = false,
|
||||||
@@ -148,22 +141,6 @@ export function CallControls({
|
|||||||
<MusicIcon className="h-5 w-5" />
|
<MusicIcon className="h-5 w-5" />
|
||||||
</CallButton>
|
</CallButton>
|
||||||
)}
|
)}
|
||||||
{onToggleCaptions && (
|
|
||||||
<CallButton
|
|
||||||
label={
|
|
||||||
captionsOn
|
|
||||||
? t('app:call.captions_off', { defaultValue: 'Untertitel aus' })
|
|
||||||
: t('app:call.captions_on', { defaultValue: 'Untertitel an' })
|
|
||||||
}
|
|
||||||
active={captionsOn}
|
|
||||||
activeTone="accent"
|
|
||||||
onClick={onToggleCaptions}
|
|
||||||
glass={glass}
|
|
||||||
className={btnSize}
|
|
||||||
>
|
|
||||||
<CaptionsIcon className="h-5 w-5" />
|
|
||||||
</CallButton>
|
|
||||||
)}
|
|
||||||
{onOpenParticipants && (
|
{onOpenParticipants && (
|
||||||
<CallButton
|
<CallButton
|
||||||
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
label={t('app:call.participants', { defaultValue: 'Teilnehmer' })}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { MonitorShareIcon, PollIcon } from './icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
anchorRef: React.RefObject<HTMLButtonElement | null>;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAttachFile: () => void;
|
||||||
|
onCreatePoll: () => void;
|
||||||
|
onCreateWhiteboard: () => void;
|
||||||
|
onStartWatchTogether: () => void;
|
||||||
|
onStartGame: () => void;
|
||||||
|
canStartGame?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposerActionsMenu({
|
||||||
|
anchorRef,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onAttachFile,
|
||||||
|
onCreatePoll,
|
||||||
|
onCreateWhiteboard,
|
||||||
|
onStartWatchTogether,
|
||||||
|
onStartGame,
|
||||||
|
canStartGame = true,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const firstItemRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
// Auto-focus the first item when menu opens (a11y) + click-outside/Esc handlers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
firstItemRef.current?.focus();
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
if (anchorRef.current?.contains(target)) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open, onClose, anchorRef]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
// Each item: closes the menu, then runs the action.
|
||||||
|
const items: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
Icon: React.ComponentType<React.ComponentPropsWithoutRef<'svg'>>;
|
||||||
|
action: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledTitle?: string;
|
||||||
|
section: 'top' | 'activities';
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: 'attach',
|
||||||
|
label: t('app:composer.menu.attach', { defaultValue: 'Bild / Datei' }),
|
||||||
|
Icon: PaperclipIcon,
|
||||||
|
action: onAttachFile,
|
||||||
|
section: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'poll',
|
||||||
|
label: t('app:composer.menu.poll', { defaultValue: 'Umfrage' }),
|
||||||
|
Icon: PollIcon,
|
||||||
|
action: onCreatePoll,
|
||||||
|
section: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'whiteboard',
|
||||||
|
label: t('app:composer.menu.whiteboard', { defaultValue: 'Whiteboard' }),
|
||||||
|
Icon: MonitorShareIcon,
|
||||||
|
action: onCreateWhiteboard,
|
||||||
|
section: 'activities',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'watch',
|
||||||
|
label: t('app:composer.menu.watch', { defaultValue: 'Watch Together' }),
|
||||||
|
Icon: PlayBoxIcon,
|
||||||
|
action: onStartWatchTogether,
|
||||||
|
section: 'activities',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'game',
|
||||||
|
label: t('app:composer.menu.game', { defaultValue: 'Spiel starten' }),
|
||||||
|
Icon: GameIcon,
|
||||||
|
action: onStartGame,
|
||||||
|
disabled: !canStartGame,
|
||||||
|
disabledTitle: t('app:composer.menu.game_dm_only', {
|
||||||
|
defaultValue: 'Nur in 1:1-Chats',
|
||||||
|
}),
|
||||||
|
section: 'activities',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleItemClick = (item: (typeof items)[number]) => {
|
||||||
|
if (item.disabled) return;
|
||||||
|
onClose();
|
||||||
|
item.action();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const focusable = menuRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||||
|
'button[role="menuitem"]:not([disabled])',
|
||||||
|
);
|
||||||
|
if (!focusable || focusable.length === 0) return;
|
||||||
|
const list = Array.from(focusable);
|
||||||
|
const idx = list.findIndex((el) => el === document.activeElement);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
list[(idx + 1) % list.length]?.focus();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
list[(idx - 1 + list.length) % list.length]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const topItems = items.filter((i) => i.section === 'top');
|
||||||
|
const activityItems = items.filter((i) => i.section === 'activities');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
role="menu"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
// Positioned absolutely above the anchor; the wrapping parent (the
|
||||||
|
// composer) must be `position: relative` for this to anchor correctly.
|
||||||
|
className="absolute bottom-full left-0 z-30 mb-2 w-56 overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
|
||||||
|
>
|
||||||
|
{topItems.map((item, idx) => {
|
||||||
|
const Icon = item.Icon;
|
||||||
|
const isFirst = idx === 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
ref={isFirst ? firstItemRef : undefined}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
title={item.disabled ? item.disabledTitle : undefined}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||||
|
(item.disabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
className="border-t border-line/60"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||||
|
{t('app:composer.menu.section_activities', { defaultValue: 'Aktivitäten' })}
|
||||||
|
</div>
|
||||||
|
{activityItems.map((item) => {
|
||||||
|
const Icon = item.Icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
title={item.disabled ? item.disabledTitle : undefined}
|
||||||
|
className={
|
||||||
|
'flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-fg transition focus:outline-none ' +
|
||||||
|
(item.disabled
|
||||||
|
? 'cursor-not-allowed opacity-50'
|
||||||
|
: 'hover:bg-surface-3 focus-visible:bg-surface-3')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<span className="flex-1 truncate">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Inline icons not in the central icons module ----------------------
|
||||||
|
|
||||||
|
function PaperclipIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayBoxIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="14" rx="2" />
|
||||||
|
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameIcon(props: React.ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||||||
|
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,14 +15,7 @@ import {
|
|||||||
listSounds,
|
listSounds,
|
||||||
subscribeSoundboardChanges,
|
subscribeSoundboardChanges,
|
||||||
} from '../lib/soundboardStorage';
|
} from '../lib/soundboardStorage';
|
||||||
import {
|
|
||||||
getLiveCaptionsSettings,
|
|
||||||
isLiveCaptionsSupported,
|
|
||||||
subscribeLiveCaptionsSettings,
|
|
||||||
updateLiveCaptionsSettings,
|
|
||||||
} from '../lib/liveCaptions';
|
|
||||||
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
import { useActiveSpeakers } from '../lib/useActiveSpeakers';
|
||||||
import { CallCaptionsOverlay } from './CallCaptionsOverlay';
|
|
||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
import { CallParticipantTile, type TileConnectionQuality } from './CallParticipantTile';
|
||||||
import { CallStatsOverlay } from './CallStatsOverlay';
|
import { CallStatsOverlay } from './CallStatsOverlay';
|
||||||
@@ -119,17 +112,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
const [sharePickerOpen, setSharePickerOpen] = useState(false);
|
||||||
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
// Discord-style debug stats overlay (Ctrl+Shift+S toggles).
|
||||||
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
const [statsOverlayOpen, setStatsOverlayOpen] = useState(false);
|
||||||
// Live-captions toggle — mirror of getLiveCaptionsSettings().enabled so the
|
|
||||||
// controls bar can show an "active" state without polling. Captions
|
|
||||||
// broadcasting is wired in CallContext via useLiveCaptions; this only
|
|
||||||
// tracks the toggle state for the button.
|
|
||||||
const [captionsEnabled, setCaptionsEnabled] = useState<boolean>(
|
|
||||||
() => getLiveCaptionsSettings().enabled,
|
|
||||||
);
|
|
||||||
useEffect(
|
|
||||||
() => subscribeLiveCaptionsSettings((s) => setCaptionsEnabled(s.enabled)),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
// Match the modifier exactly to avoid clobbering other Ctrl+Shift combos.
|
||||||
@@ -362,15 +344,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
soundboardOpen,
|
soundboardOpen,
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
// Live-Captions only when SpeechRecognition is available in the
|
|
||||||
// runtime — Firefox lacks it, would just show a dead button.
|
|
||||||
{...(isLiveCaptionsSupported()
|
|
||||||
? {
|
|
||||||
onToggleCaptions: () =>
|
|
||||||
updateLiveCaptionsSettings({ enabled: !captionsEnabled }),
|
|
||||||
captionsOn: captionsEnabled,
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
onHangup={() => void hangup()}
|
onHangup={() => void hangup()}
|
||||||
compact={callMode !== 'fullscreen'}
|
compact={callMode !== 'fullscreen'}
|
||||||
glass={callMode === 'fullscreen'}
|
glass={callMode === 'fullscreen'}
|
||||||
@@ -499,7 +472,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setStatsOverlayOpen(false)}
|
onClose={() => setStatsOverlayOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -644,8 +616,6 @@ export function InCallPanel({ conversation }: Props) {
|
|||||||
onClose={() => setStatsOverlayOpen(false)}
|
onClose={() => setStatsOverlayOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{captionsEnabled && <CallCaptionsOverlay conversation={conversation} />}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,16 +150,15 @@ function EyeOffIconInner(props: IconProps) {
|
|||||||
}
|
}
|
||||||
export const EyeOffIcon = memo(EyeOffIconInner);
|
export const EyeOffIcon = memo(EyeOffIconInner);
|
||||||
|
|
||||||
function CaptionsIconInner(props: IconProps) {
|
function EyeIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<Base {...props}>
|
<Base {...props}>
|
||||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
||||||
<path d="M7 13a2 2 0 1 1 0-2" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M14 13a2 2 0 1 1 0-2" />
|
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export const CaptionsIcon = memo(CaptionsIconInner);
|
export const EyeIcon = memo(EyeIconInner);
|
||||||
|
|
||||||
function PinOffIconInner(props: IconProps) {
|
function PinOffIconInner(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -207,9 +207,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Pre-warm Supabase: fires the first round-trip in the background so the
|
// Pre-warm Supabase: fires the first round-trip in the background so the
|
||||||
// first user-triggered query (e.g. loading conversations) doesn't pay
|
// first user-triggered query (e.g. loading conversations) doesn't pay
|
||||||
// the cold-connection latency.
|
// the cold-connection latency.
|
||||||
|
//
|
||||||
|
// Uses auth.getSession() instead of a `profiles` SELECT because the
|
||||||
|
// SELECT race-fired before the supabase client committed its JWT to
|
||||||
|
// request headers, causing a 400 from PostgREST on app boot. Auth
|
||||||
|
// endpoints don't depend on RLS and tolerate the race.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
void supabase.from('profiles').select('id').limit(1).then(() => undefined);
|
void supabase.auth.getSession();
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
// Phase 3: ensure this install owns exactly one devices row. The row is
|
// Phase 3: ensure this install owns exactly one devices row. The row is
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import {
|
|||||||
playUndeafenBeep,
|
playUndeafenBeep,
|
||||||
playUnmuteBeep,
|
playUnmuteBeep,
|
||||||
} from '../lib/callSounds';
|
} from '../lib/callSounds';
|
||||||
import { useLiveCaptions } from '../lib/useLiveCaptions';
|
|
||||||
import { setCallWakeLock } from '../lib/wakeLock';
|
import { setCallWakeLock } from '../lib/wakeLock';
|
||||||
import { notify } from '../lib/osNotify';
|
import { notify } from '../lib/osNotify';
|
||||||
import {
|
import {
|
||||||
@@ -209,14 +208,6 @@ interface CallContextValue {
|
|||||||
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
|
||||||
* but only in group calls. Null while idle or in 1:1 contexts. */
|
* but only in group calls. Null while idle or in 1:1 contexts. */
|
||||||
callHostId: string | null;
|
callHostId: string | null;
|
||||||
/** identity -> latest live-caption fragment received via data channel.
|
|
||||||
* Includes own captions for self-overlay. Receivers prune entries whose
|
|
||||||
* timestamp is older than ~5s so stale lines fade out. */
|
|
||||||
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
|
|
||||||
/** Surface a caption for the local user — the live-captions hook calls
|
|
||||||
* this on every interim/final SpeechRecognition result so the overlay
|
|
||||||
* shows our own line without going through the SFU round-trip. */
|
|
||||||
pushLocalCaption: (text: string, final: boolean) => void;
|
|
||||||
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
/** identity -> mute state. Broadcast from peer whenever mic-gain flips.
|
||||||
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
* Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
|
||||||
* the mic pipeline keeps the track published with sound flowing even
|
* the mic pipeline keeps the track published with sound flowing even
|
||||||
@@ -347,9 +338,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
// useEffect) so peers don't hear themselves echoed back when the OS-level
|
||||||
// process-tree exclusion isn't watertight.
|
// process-tree exclusion isn't watertight.
|
||||||
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
|
||||||
const [captions, setCaptions] = useState<
|
|
||||||
Record<string, { text: string; final: boolean; timestamp: number }>
|
|
||||||
>({});
|
|
||||||
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
|
||||||
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
const [callMode, setCallModeState] = useState<CallMode>('grid');
|
||||||
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
const [focusedId, setFocusedIdState] = useState<string | null>(null);
|
||||||
@@ -828,7 +816,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setRemoteScreenShares([]);
|
setRemoteScreenShares([]);
|
||||||
setConnectionQualities({});
|
setConnectionQualities({});
|
||||||
setCallHostId(null);
|
setCallHostId(null);
|
||||||
setCaptions({});
|
|
||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
setIsE2EEActive(false);
|
setIsE2EEActive(false);
|
||||||
}
|
}
|
||||||
@@ -970,8 +957,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
type?: string;
|
type?: string;
|
||||||
deafened?: boolean;
|
deafened?: boolean;
|
||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
captionText?: string;
|
|
||||||
captionFinal?: boolean;
|
|
||||||
};
|
};
|
||||||
const id: string = participant.identity;
|
const id: string = participant.identity;
|
||||||
if (msg.type === 'presence') {
|
if (msg.type === 'presence') {
|
||||||
@@ -991,15 +976,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
|
|
||||||
const text2 = msg.captionText;
|
|
||||||
const final = msg.captionFinal === true;
|
|
||||||
setCaptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[id]: { text: text2, final, timestamp: Date.now() },
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore malformed */
|
/* ignore malformed */
|
||||||
}
|
}
|
||||||
@@ -1754,17 +1730,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pushLocalCaption = useCallback(
|
|
||||||
(text: string, final: boolean) => {
|
|
||||||
if (!myId) return;
|
|
||||||
setCaptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[myId]: { text, final, timestamp: Date.now() },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
[myId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleCamera = useCallback(async () => {
|
const toggleCamera = useCallback(async () => {
|
||||||
const r = roomRef.current;
|
const r = roomRef.current;
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
@@ -2603,15 +2568,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [callMode, state.kind]);
|
}, [callMode, state.kind]);
|
||||||
|
|
||||||
// Discord-style live-captions broadcaster — runs on the local mic while
|
|
||||||
// we're connected, and ships interim/final transcripts on the LiveKit
|
|
||||||
// DataChannel so peers can render them.
|
|
||||||
useLiveCaptions({
|
|
||||||
room,
|
|
||||||
active: state.kind === 'connected' || state.kind === 'reconnecting',
|
|
||||||
onLocalCaption: pushLocalCaption,
|
|
||||||
});
|
|
||||||
|
|
||||||
const value = useMemo<CallContextValue>(
|
const value = useMemo<CallContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
state,
|
state,
|
||||||
@@ -2626,8 +2582,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
@@ -2683,8 +2637,6 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
remoteMute,
|
remoteMute,
|
||||||
connectionQualities,
|
connectionQualities,
|
||||||
callHostId,
|
callHostId,
|
||||||
captions,
|
|
||||||
pushLocalCaption,
|
|
||||||
remoteScreenShares,
|
remoteScreenShares,
|
||||||
lastCallConversationId,
|
lastCallConversationId,
|
||||||
callMode,
|
callMode,
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
// Discord-style live captions. Uses the browser's SpeechRecognition API to
|
|
||||||
// transcribe the LOCAL user's mic, then broadcasts each interim/final result
|
|
||||||
// to peers via the LiveKit DataChannel. Receivers store and display them.
|
|
||||||
//
|
|
||||||
// Privacy note: speech recognition runs in the browser. On Chromium-based
|
|
||||||
// runtimes (incl. Tauri's WebView2 on Windows) this calls into the browser's
|
|
||||||
// own engine, which today reaches Google's cloud — same trade-off as Discord.
|
|
||||||
// We ship a hard off switch and require an explicit user toggle.
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'chatapp.liveCaptions.v1';
|
|
||||||
|
|
||||||
export interface LiveCaptionsSettings {
|
|
||||||
enabled: boolean;
|
|
||||||
/** BCP-47 language tag, e.g. "de-DE" or "en-US". Auto-detect uses navigator.language. */
|
|
||||||
lang: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULTS: LiveCaptionsSettings = {
|
|
||||||
enabled: false,
|
|
||||||
lang: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
type Listener = (s: LiveCaptionsSettings) => void;
|
|
||||||
const listeners = new Set<Listener>();
|
|
||||||
let cached: LiveCaptionsSettings | null = null;
|
|
||||||
|
|
||||||
function read(): LiveCaptionsSettings {
|
|
||||||
if (cached) return cached;
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
cached = DEFAULTS;
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw) as Partial<LiveCaptionsSettings>;
|
|
||||||
cached = {
|
|
||||||
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
|
|
||||||
lang:
|
|
||||||
typeof parsed.lang === 'string' && parsed.lang.length > 0
|
|
||||||
? parsed.lang
|
|
||||||
: DEFAULTS.lang,
|
|
||||||
};
|
|
||||||
return cached;
|
|
||||||
} catch {
|
|
||||||
cached = DEFAULTS;
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function write(s: LiveCaptionsSettings): void {
|
|
||||||
cached = s;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
|
||||||
} catch {
|
|
||||||
/* quota / private mode */
|
|
||||||
}
|
|
||||||
for (const l of listeners) l(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLiveCaptionsSettings(): LiveCaptionsSettings {
|
|
||||||
return read();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateLiveCaptionsSettings(
|
|
||||||
patch: Partial<LiveCaptionsSettings>,
|
|
||||||
): LiveCaptionsSettings {
|
|
||||||
const next = { ...read(), ...patch };
|
|
||||||
write(next);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeLiveCaptionsSettings(listener: Listener): () => void {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => listeners.delete(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser feature-detect. Chromium ships under both names; Firefox lacks it
|
|
||||||
// outright. Returns the constructor or null.
|
|
||||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
|
||||||
interface SpeechRecognitionLike extends EventTarget {
|
|
||||||
continuous: boolean;
|
|
||||||
interimResults: boolean;
|
|
||||||
lang: string;
|
|
||||||
start: () => void;
|
|
||||||
stop: () => void;
|
|
||||||
abort: () => void;
|
|
||||||
onresult: ((e: SpeechRecognitionEventLike) => void) | null;
|
|
||||||
onerror: ((e: SpeechRecognitionErrorLike) => void) | null;
|
|
||||||
onend: (() => void) | null;
|
|
||||||
}
|
|
||||||
interface SpeechRecognitionEventLike {
|
|
||||||
resultIndex: number;
|
|
||||||
results: ArrayLike<{
|
|
||||||
isFinal: boolean;
|
|
||||||
[index: number]: { transcript: string };
|
|
||||||
length: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
interface SpeechRecognitionErrorLike {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
|
|
||||||
const w = window as unknown as {
|
|
||||||
SpeechRecognition?: SpeechRecognitionCtor;
|
|
||||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
|
||||||
};
|
|
||||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLiveCaptionsSupported(): boolean {
|
|
||||||
return getSpeechRecognitionCtor() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
|
||||||
SpeechRecognitionLike,
|
|
||||||
SpeechRecognitionEventLike,
|
|
||||||
SpeechRecognitionErrorLike,
|
|
||||||
};
|
|
||||||
@@ -75,7 +75,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
text: string,
|
text: string,
|
||||||
images?: File[],
|
images?: File[],
|
||||||
replyToId?: string | null,
|
replyToId?: string | null,
|
||||||
opts?: { viewOnce?: boolean },
|
opts?: { viewOnceFlags?: boolean[] },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
pending: OutboxItem[];
|
pending: OutboxItem[];
|
||||||
@@ -569,7 +569,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
text: string,
|
text: string,
|
||||||
images: File[] = [],
|
images: File[] = [],
|
||||||
replyToId: string | null = null,
|
replyToId: string | null = null,
|
||||||
opts: { viewOnce?: boolean } = {},
|
opts: { viewOnceFlags?: boolean[] } = {},
|
||||||
) => {
|
) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
|
||||||
@@ -626,9 +626,13 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
|
|
||||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||||
// (so the public attachment row can reference the blob-level nonce).
|
// (so the public attachment row can reference the blob-level nonce).
|
||||||
|
// P7.T4: view-once is now a per-attachment flag rather than a
|
||||||
|
// composer-wide toggle. `opts.viewOnceFlags` is a parallel array;
|
||||||
|
// missing entries (or whole-array absence) default to false.
|
||||||
const handles: AttachmentHandle[] = [];
|
const handles: AttachmentHandle[] = [];
|
||||||
const blobNonceHexByHandleId = new Map<string, string>();
|
const blobNonceHexByHandleId = new Map<string, string>();
|
||||||
for (const file of images) {
|
for (let i = 0; i < images.length; i++) {
|
||||||
|
const file = images[i]!;
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||||||
throw new Error('attachment exceeds max size (10 MB)');
|
throw new Error('attachment exceeds max size (10 MB)');
|
||||||
}
|
}
|
||||||
@@ -649,12 +653,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
|||||||
...(dims.height !== undefined ? { height: dims.height } : {}),
|
...(dims.height !== undefined ? { height: dims.height } : {}),
|
||||||
...(thumbBlob ? { thumbBlob } : {}),
|
...(thumbBlob ? { thumbBlob } : {}),
|
||||||
});
|
});
|
||||||
// Stamp the view-once flag on each handle the caller requested it
|
// Stamp the view-once flag on each handle the caller flagged. The
|
||||||
// for. The flag rides inside the encrypted payload (so peers can
|
// flag rides inside the encrypted payload (so peers can render the
|
||||||
// render the locked card without leaking who-sent-what to the
|
// locked card without leaking who-sent-what to the server) AND
|
||||||
// server) AND lands on the public message_attachments row via
|
// lands on the public message_attachments row via insertAttachmentRow
|
||||||
// insertAttachmentRow below (where the mark-viewed RPC enforces it).
|
// below (where the mark-viewed RPC enforces it).
|
||||||
if (opts.viewOnce) {
|
if (opts.viewOnceFlags?.[i]) {
|
||||||
res.handle.viewOnce = true;
|
res.handle.viewOnce = true;
|
||||||
}
|
}
|
||||||
handles.push(res.handle);
|
handles.push(res.handle);
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
// Hook that runs SpeechRecognition on the local mic when live-captions are
|
|
||||||
// enabled and a Room is connected. Each interim/final result is broadcast as
|
|
||||||
// a `caption`-typed message via the LiveKit DataChannel so peers can render
|
|
||||||
// it. Recognition stops cleanly when the call ends or the toggle flips off.
|
|
||||||
|
|
||||||
import type { Room } from 'livekit-client';
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type LiveCaptionsSettings,
|
|
||||||
getLiveCaptionsSettings,
|
|
||||||
getSpeechRecognitionCtor,
|
|
||||||
type SpeechRecognitionEventLike,
|
|
||||||
type SpeechRecognitionLike,
|
|
||||||
subscribeLiveCaptionsSettings,
|
|
||||||
} from './liveCaptions';
|
|
||||||
|
|
||||||
interface Args {
|
|
||||||
room: Room | null;
|
|
||||||
/** True while we're connected and want captions to flow. */
|
|
||||||
active: boolean;
|
|
||||||
/** Callback fired locally for our own captions so the overlay can show
|
|
||||||
* them without going through the SFU round-trip. */
|
|
||||||
onLocalCaption: (text: string, final: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useLiveCaptions({ room, active, onLocalCaption }: Args): void {
|
|
||||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
|
||||||
const settingsRef = useRef<LiveCaptionsSettings>(getLiveCaptionsSettings());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return subscribeLiveCaptionsSettings((s) => {
|
|
||||||
settingsRef.current = s;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const Ctor = getSpeechRecognitionCtor();
|
|
||||||
if (!Ctor) return; // unsupported runtime
|
|
||||||
if (!active || !room) return;
|
|
||||||
if (!getLiveCaptionsSettings().enabled) return;
|
|
||||||
|
|
||||||
const send = (text: string, final: boolean) => {
|
|
||||||
onLocalCaption(text, final);
|
|
||||||
try {
|
|
||||||
const payload = new TextEncoder().encode(
|
|
||||||
JSON.stringify({ type: 'caption', captionText: text, captionFinal: final }),
|
|
||||||
);
|
|
||||||
// Reliable channel — captions are infrequent enough to afford it,
|
|
||||||
// and dropping interims looks worse than slight lag.
|
|
||||||
void room.localParticipant.publishData(payload, { reliable: true });
|
|
||||||
} catch {
|
|
||||||
/* ignore — best-effort */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
const r = new Ctor();
|
|
||||||
r.continuous = true;
|
|
||||||
r.interimResults = true;
|
|
||||||
const lang = settingsRef.current.lang ?? navigator.language ?? 'de-DE';
|
|
||||||
r.lang = lang;
|
|
||||||
r.onresult = (e: SpeechRecognitionEventLike) => {
|
|
||||||
// Pull whichever results arrived since last fire. Interim fires
|
|
||||||
// many times per second; the final one is sticky and persists.
|
|
||||||
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
||||||
const result = e.results[i];
|
|
||||||
if (!result || result.length === 0) continue;
|
|
||||||
const alt = result[0];
|
|
||||||
if (!alt) continue;
|
|
||||||
const transcript = alt.transcript.trim();
|
|
||||||
if (!transcript) continue;
|
|
||||||
send(transcript, result.isFinal);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
r.onerror = () => {
|
|
||||||
// Recoverable: stop + retry on next effect cycle. `not-allowed` and
|
|
||||||
// `service-not-allowed` are permission-permanent — bail.
|
|
||||||
try {
|
|
||||||
r.stop();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
r.onend = () => {
|
|
||||||
// SpeechRecognition tends to auto-stop after silence — if we still
|
|
||||||
// want captions, restart it. Guard against tear-down race.
|
|
||||||
if (recognitionRef.current === r && getLiveCaptionsSettings().enabled) {
|
|
||||||
try {
|
|
||||||
r.start();
|
|
||||||
} catch {
|
|
||||||
/* already running or browser refused */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
r.start();
|
|
||||||
recognitionRef.current = r;
|
|
||||||
} catch {
|
|
||||||
// Some browsers throw when start() is called too soon after a
|
|
||||||
// previous abort — wait a tick and retry.
|
|
||||||
window.setTimeout(() => {
|
|
||||||
try {
|
|
||||||
r.start();
|
|
||||||
recognitionRef.current = r;
|
|
||||||
} catch {
|
|
||||||
/* give up */
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
start();
|
|
||||||
|
|
||||||
const unsub = subscribeLiveCaptionsSettings((s) => {
|
|
||||||
const cur = recognitionRef.current;
|
|
||||||
if (!s.enabled && cur) {
|
|
||||||
recognitionRef.current = null;
|
|
||||||
try {
|
|
||||||
cur.abort();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
} else if (s.enabled && !cur) {
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsub();
|
|
||||||
const cur = recognitionRef.current;
|
|
||||||
recognitionRef.current = null;
|
|
||||||
if (cur) {
|
|
||||||
try {
|
|
||||||
cur.abort();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [active, room, onLocalCaption]);
|
|
||||||
}
|
|
||||||
@@ -213,7 +213,7 @@ async function runLegacyMigration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(
|
console.debug(
|
||||||
'[crypto-migration] vault scan:',
|
'[crypto-migration] vault scan:',
|
||||||
'serverDevices=' + report.serverDevices,
|
'serverDevices=' + report.serverDevices,
|
||||||
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
'keysFromServerList=' + report.strongholdKeysFromServerDevices,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
import { EmojiPicker } from '../components/EmojiPicker';
|
import { EmojiPicker } from '../components/EmojiPicker';
|
||||||
import { EmptyState } from '../components/EmptyState';
|
import { EmptyState } from '../components/EmptyState';
|
||||||
@@ -16,10 +17,10 @@ import {
|
|||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronUpIcon,
|
ChevronUpIcon,
|
||||||
|
EyeIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
PollIcon,
|
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
@@ -108,6 +109,15 @@ const EMPTY_REACTIONS: AggregatedReaction[] = [];
|
|||||||
// reading position even if some rows above re-render at different heights.
|
// reading position even if some rows above re-render at different heights.
|
||||||
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
const scrollPositions = new Map<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||||
|
|
||||||
|
/** Pending composer attachment: the raw File plus the per-attachment
|
||||||
|
* view-once flag the user can toggle from the thumb hover button (P7.T4).
|
||||||
|
* Lives only in composer state — the flag is forwarded into
|
||||||
|
* `message_attachments.view_once` per row when the message is sent. */
|
||||||
|
interface PendingAttachment {
|
||||||
|
file: File;
|
||||||
|
viewOnce: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export function ConversationPage() {
|
export function ConversationPage() {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -194,7 +204,11 @@ export function ConversationPage() {
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
// Pending composer attachments — each carries its own view-once flag so
|
||||||
|
// the user can mark individual images "burn after viewing" via the hover
|
||||||
|
// toggle on the thumb (P7.T4). Non-image attachments keep viewOnce=false
|
||||||
|
// but the field stays on the object so the shape is uniform.
|
||||||
|
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||||
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
const [annotatingIndex, setAnnotatingIndex] = useState<number | null>(null);
|
||||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||||
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
const [mediaDrawerOpen, setMediaDrawerOpen] = useState(false);
|
||||||
@@ -237,10 +251,8 @@ export function ConversationPage() {
|
|||||||
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
const [mentionState, setMentionState] = useState<{ query: string; start: number } | null>(null);
|
||||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
const [gifPickerOpen, setGifPickerOpen] = useState(false);
|
||||||
// Sticky toggle: when on, the next image(s) sent are marked view-once.
|
const [actionsMenuOpen, setActionsMenuOpen] = useState(false);
|
||||||
// Auto-clears on a successful send so the composer doesn't accidentally
|
const actionsMenuAnchorRef = useRef<HTMLButtonElement>(null);
|
||||||
// burn the message-after-next.
|
|
||||||
const [viewOnceNext, setViewOnceNext] = useState(false);
|
|
||||||
const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
|
const { pins, applyOptimisticPin, applyOptimisticUnpin, restorePins } = usePinnedMessages(id);
|
||||||
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
const [pinnedPanelOpen, setPinnedPanelOpen] = useState(false);
|
||||||
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
const pinnedIds = useMemo(() => new Set(pins.map((p) => p.messageId)), [pins]);
|
||||||
@@ -739,13 +751,15 @@ export function ConversationPage() {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
setSendError(null);
|
setSendError(null);
|
||||||
try {
|
try {
|
||||||
await send(text, attachments, replyTo?.id ?? null, { viewOnce: viewOnceNext });
|
await send(
|
||||||
|
text,
|
||||||
|
attachments.map((a) => a.file),
|
||||||
|
replyTo?.id ?? null,
|
||||||
|
{ viewOnceFlags: attachments.map((a) => a.viewOnce) },
|
||||||
|
);
|
||||||
setText('');
|
setText('');
|
||||||
setAttachments([]);
|
setAttachments([]);
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
// Reset the sticky view-once flag so it only applies to the message
|
|
||||||
// the user explicitly armed it for — Snapchat / WhatsApp parity.
|
|
||||||
setViewOnceNext(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
notifyStopTyping();
|
notifyStopTyping();
|
||||||
@@ -859,13 +873,15 @@ export function ConversationPage() {
|
|||||||
|
|
||||||
async function ingestFiles(files: File[]) {
|
async function ingestFiles(files: File[]) {
|
||||||
const compressed = await compressImages(files);
|
const compressed = await compressImages(files);
|
||||||
const next: File[] = [];
|
const next: PendingAttachment[] = [];
|
||||||
for (const f of compressed) {
|
for (const f of compressed) {
|
||||||
if (f.size > 10 * 1024 * 1024) {
|
if (f.size > 10 * 1024 * 1024) {
|
||||||
setSendError('Datei zu groß (max 10 MB)');
|
setSendError('Datei zu groß (max 10 MB)');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
next.push(f);
|
// New attachments default to viewOnce=false; user opts in per-thumb
|
||||||
|
// via the eye-toggle button on the preview (P7.T4).
|
||||||
|
next.push({ file: f, viewOnce: false });
|
||||||
}
|
}
|
||||||
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
setAttachments((prev) => [...prev, ...next].slice(0, 4));
|
||||||
}
|
}
|
||||||
@@ -1198,13 +1214,22 @@ export function ConversationPage() {
|
|||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="mb-2 flex flex-wrap gap-2">
|
<div className="mb-2 flex flex-wrap gap-2">
|
||||||
{attachments.map((file, idx) => (
|
{attachments.map((a, idx) => (
|
||||||
<AttachmentPreview
|
<AttachmentPreview
|
||||||
key={idx}
|
key={idx}
|
||||||
file={file}
|
file={a.file}
|
||||||
|
viewOnce={a.viewOnce}
|
||||||
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
onRemove={() => setAttachments((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
{...(file.type.startsWith('image/')
|
{...(a.file.type.startsWith('image/')
|
||||||
? { onEdit: () => setAnnotatingIndex(idx) }
|
? {
|
||||||
|
onEdit: () => setAnnotatingIndex(idx),
|
||||||
|
onToggleViewOnce: () =>
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((x, i) =>
|
||||||
|
i === idx ? { ...x, viewOnce: !x.viewOnce } : x,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -1243,55 +1268,32 @@ export function ConversationPage() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => handleFilesChosen(e.target.files)}
|
onChange={(e) => handleFilesChosen(e.target.files)}
|
||||||
/>
|
/>
|
||||||
|
{/* [+] popover trigger — opens ComposerActionsMenu (file/poll/whiteboard/watch/game) */}
|
||||||
<button
|
<button
|
||||||
|
ref={actionsMenuAnchorRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => setActionsMenuOpen((v) => !v)}
|
||||||
aria-label="Datei anhängen"
|
aria-label={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
|
||||||
title="Datei anhängen"
|
title={t('app:composer.more_actions', { defaultValue: 'Mehr Aktionen' })}
|
||||||
|
aria-expanded={actionsMenuOpen}
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<ComposerActionsMenu
|
||||||
type="button"
|
anchorRef={actionsMenuAnchorRef}
|
||||||
onClick={() => {
|
open={actionsMenuOpen}
|
||||||
|
onClose={() => setActionsMenuOpen(false)}
|
||||||
|
onAttachFile={() => fileInputRef.current?.click()}
|
||||||
|
onCreatePoll={() => {
|
||||||
setPollError(null);
|
setPollError(null);
|
||||||
setPollDialogOpen(true);
|
setPollDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
aria-label="Umfrage erstellen"
|
onCreateWhiteboard={() => void handleCreateWhiteboard()}
|
||||||
title="Umfrage erstellen"
|
onStartWatchTogether={() => setWatchDialogOpen(true)}
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
onStartGame={() => setGameDialogOpen(true)}
|
||||||
>
|
canStartGame={conversation?.members?.length === 2}
|
||||||
<PollIcon className="h-4 w-4" />
|
/>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleCreateWhiteboard()}
|
|
||||||
disabled={creatingWhiteboard}
|
|
||||||
title={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
|
||||||
aria-label={t('app:composer.whiteboard', { defaultValue: 'Whiteboard' })}
|
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-[#313338]"
|
|
||||||
>
|
|
||||||
<WhiteboardIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setWatchDialogOpen(true)}
|
|
||||||
title={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
|
||||||
aria-label={t('app:composer.watch_together', { defaultValue: 'Watch Together' })}
|
|
||||||
className="inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-3 hover:text-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 dark:hover:bg-[#313338]"
|
|
||||||
>
|
|
||||||
<PlayBoxIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setGameDialogOpen(true)}
|
|
||||||
title={t('app:composer.game', { defaultValue: 'Spielen' })}
|
|
||||||
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
|
|
||||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
|
||||||
>
|
|
||||||
<GameIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1337,20 +1339,6 @@ export function ConversationPage() {
|
|||||||
onPick={(gif) => void handleGifPick(gif)}
|
onPick={(gif) => void handleGifPick(gif)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setViewOnceNext((v) => !v)}
|
|
||||||
aria-pressed={viewOnceNext}
|
|
||||||
title={viewOnceNext ? 'Nächstes Bild: einmal ansehen' : 'Nächstes Bild: normal'}
|
|
||||||
className={
|
|
||||||
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-md transition ' +
|
|
||||||
(viewOnceNext
|
|
||||||
? 'bg-accent/20 text-accent'
|
|
||||||
: 'text-fg-muted hover:bg-surface-3 hover:text-fg')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<VoiceRecorder
|
<VoiceRecorder
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onComplete={async (file) => {
|
onComplete={async (file) => {
|
||||||
@@ -1487,10 +1475,17 @@ export function ConversationPage() {
|
|||||||
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
{annotatingIndex !== null && attachments[annotatingIndex] && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ImageAnnotator
|
<ImageAnnotator
|
||||||
file={attachments[annotatingIndex]!}
|
file={attachments[annotatingIndex]!.file}
|
||||||
onCancel={() => setAnnotatingIndex(null)}
|
onCancel={() => setAnnotatingIndex(null)}
|
||||||
onSave={(next) => {
|
onSave={(next) => {
|
||||||
setAttachments((prev) => prev.map((f, i) => (i === annotatingIndex ? next : f)));
|
// Preserve the per-attachment viewOnce flag across annotation —
|
||||||
|
// the user's burn-after-viewing intent shouldn't reset just
|
||||||
|
// because they redrew the image.
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a, i) =>
|
||||||
|
i === annotatingIndex ? { file: next, viewOnce: a.viewOnce } : a,
|
||||||
|
),
|
||||||
|
);
|
||||||
setAnnotatingIndex(null);
|
setAnnotatingIndex(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1847,13 +1842,18 @@ function Banner({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function AttachmentPreview({
|
function AttachmentPreview({
|
||||||
file,
|
file,
|
||||||
|
viewOnce,
|
||||||
onRemove,
|
onRemove,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onToggleViewOnce,
|
||||||
}: {
|
}: {
|
||||||
file: File;
|
file: File;
|
||||||
|
viewOnce: boolean;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
onToggleViewOnce?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation(['app']);
|
||||||
const isImage = file.type.startsWith('image/');
|
const isImage = file.type.startsWith('image/');
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1886,6 +1886,42 @@ function AttachmentPreview({
|
|||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isImage && onToggleViewOnce && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleViewOnce}
|
||||||
|
aria-label={
|
||||||
|
viewOnce
|
||||||
|
? t('app:composer.view_once_off', { defaultValue: 'Einmal-Ansicht deaktivieren' })
|
||||||
|
: t('app:composer.view_once_on', { defaultValue: 'Einmal-Ansicht aktivieren' })
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
viewOnce
|
||||||
|
? t('app:composer.view_once_on_hint', {
|
||||||
|
defaultValue: 'Empfänger sieht das Bild nur einmal',
|
||||||
|
})
|
||||||
|
: t('app:composer.view_once_off_hint', { defaultValue: 'Einmal-Ansicht ein/aus' })
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
'absolute bottom-1 right-1 flex h-5 w-5 cursor-pointer items-center justify-center rounded-full transition ' +
|
||||||
|
(viewOnce
|
||||||
|
? 'bg-accent text-accent-fg opacity-100'
|
||||||
|
: 'bg-black/70 text-white opacity-0 hover:bg-accent/80 group-hover:opacity-100')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{viewOnce ? <EyeIcon className="h-3 w-3" /> : <EyeOffIcon className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* When viewOnce is on, overlay a persistent "1×" badge so the user
|
||||||
|
has visual confirmation independent of the small toggle button. */}
|
||||||
|
{isImage && viewOnce && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute bottom-1 right-7 rounded-md bg-accent/90 px-1 py-0.5 text-[9px] font-bold text-accent-fg"
|
||||||
|
>
|
||||||
|
1×
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
@@ -1898,32 +1934,3 @@ function AttachmentPreview({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WhiteboardIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
||||||
<rect x="3" y="4" width="18" height="13" rx="2" />
|
|
||||||
<path d="M8 21h8M12 17v4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlayBoxIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
||||||
<rect x="3" y="4" width="18" height="14" rx="2" />
|
|
||||||
<path d="M10 9l5 3-5 3V9z" fill="currentColor" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
|
||||||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
||||||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
|
||||||
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
attempted: 0, noStrongholdKey: 0, decryptFailed: 0, rpcFailed: 0,
|
||||||
};
|
};
|
||||||
if (params.ownLegacyDeviceIds.length === 0) {
|
if (params.ownLegacyDeviceIds.length === 0) {
|
||||||
console.info('[crypto-migration] no legacy device-ids to consider — skipping');
|
console.debug('[crypto-migration] no legacy device-ids to consider — skipping');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
.not('recipient_device_id', 'is', null);
|
.not('recipient_device_id', 'is', null);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const rows = (rowsRaw ?? []) as LegacyRow[];
|
const rows = (rowsRaw ?? []) as LegacyRow[];
|
||||||
console.info('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
console.debug('[crypto-migration] legacy rows visible to me: ' + rows.length);
|
||||||
if (rows.length === 0) return result;
|
if (rows.length === 0) return result;
|
||||||
|
|
||||||
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean)));
|
||||||
@@ -136,13 +136,26 @@ export async function migrateOwnLegacyBundles(params: MigrateParams): Promise<Mi
|
|||||||
result.migratedConversations += 1;
|
result.migratedConversations += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(
|
// If anything was actually migrated this run, leave it as console.info
|
||||||
'[crypto-migration] result:',
|
// so it's visible in default consoles. If we only re-failed on already-
|
||||||
'attempted=' + result.attempted,
|
// unrecoverable rows (no local stronghold key), demote to debug — the
|
||||||
'migrated=' + result.migratedConversations,
|
// migration is idempotent but the noisy "noKey=N" line scared the user
|
||||||
'noKey=' + result.noStrongholdKey,
|
// who thought migration was already done.
|
||||||
'decryptFail=' + result.decryptFailed,
|
if (result.migratedConversations > 0 || result.decryptFailed > 0 || result.rpcFailed > 0) {
|
||||||
'rpcFail=' + result.rpcFailed,
|
console.info(
|
||||||
);
|
'[crypto-migration] result:',
|
||||||
|
'attempted=' + result.attempted,
|
||||||
|
'migrated=' + result.migratedConversations,
|
||||||
|
'noKey=' + result.noStrongholdKey,
|
||||||
|
'decryptFail=' + result.decryptFailed,
|
||||||
|
'rpcFail=' + result.rpcFailed,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
'[crypto-migration] result (all unrecoverable, expected on stale clients):',
|
||||||
|
'attempted=' + result.attempted,
|
||||||
|
'noKey=' + result.noStrongholdKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user