Merge branch 'feat/gemini-workspace' into dev

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
sandrodach committed 2026-10-30 09:54:30 +01:00
commit 4f5da9507f
18 files changed
+1446 -217

No files matched your search

+8 -1
View File
@@ -7,12 +7,19 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
### Added ### Added
- Benachrichtigungen pro Ereignis und Kanal: fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles. - Benachrichtigungen pro Ereignis und Kanal (Kontoeinstellungen → Alarme): fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles.
- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`. - Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`.
- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht. - Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht.
### Changed
- Notizkopf aufgeräumt: Live-Status, Freigabe-Pille und Online-Avatare stehen oben in der Tab-Leiste neben „Gespeichert“; im Notizkopf gibt es Knöpfe für Rückgängig (`Strg+Z`) und Wiederholen (`Strg+Y`), „Teilen“ direkt sichtbar und ein Drei-Punkte-Menü für Anheften, Zusammenführen, Markdown-Export und Löschen.
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
### Fixed ### Fixed
- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb. - In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb.
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
## [0.7.0] - 2026-10-30 ## [0.7.0] - 2026-10-30
@@ -17,7 +17,7 @@ import {
Pencil, Pencil,
Copy, Copy,
} from 'lucide-react'; } from 'lucide-react';
import { AuthStatus, User, UserTheme } from '../../lib/types'; import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel'; import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard'; import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download'; import { saveBlob } from '../../lib/download';
@@ -47,6 +47,48 @@ interface UserSettingsViewProps {
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks'; type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
export function buildNotificationSettings(user: User): NotificationSettings {
const hasLegacy = Array.isArray(user.alarmChannels);
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
const s = user.notificationSettings;
return {
paused: Boolean(s?.paused),
includeOwnWatched: Boolean(s?.includeOwnWatched),
events: {
due: {
email: s?.events?.due?.email ?? dueEmailDefault,
discord: s?.events?.due?.discord ?? dueDiscordDefault,
},
shared: {
email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false,
},
taskDone: {
email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false,
},
watched: {
email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true,
},
},
dailySummary: {
enabled: s?.dailySummary?.enabled ?? false,
time: s?.dailySummary?.time || '07:00',
email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false,
},
};
}
const NOTIFICATION_EVENT_ROWS: { key: keyof NotificationEvents; label: string }[] = [
{ key: 'due', label: 'Fällige Aufgaben (sofort)' },
{ key: 'shared', label: 'Freigabe erhalten' },
{ key: 'taskDone', label: 'Geteilte Aufgabe erledigt' },
{ key: 'watched', label: 'Beobachtete Aufgaben' },
];
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser, currentUser,
activeTheme, activeTheme,
@@ -211,6 +253,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [alarmChannels, setAlarmChannels] = useState<string[]>( const [alarmChannels, setAlarmChannels] = useState<string[]>(
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
); );
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
buildNotificationSettings(currentUser)
);
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || ''); const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
@@ -226,6 +271,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setEmail(currentUser.email || ''); setEmail(currentUser.email || '');
setAvatarUrl(currentUser.avatarUrl || ''); setAvatarUrl(currentUser.avatarUrl || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setNotificationSettings(buildNotificationSettings(currentUser));
setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
setCustomWebhookUrl(currentUser.customWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || '');
@@ -238,6 +284,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
); );
}; };
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => {
setNotificationSettings((prev) => ({
...prev,
events: {
...prev.events,
[eventKey]: {
...prev.events[eventKey],
[channel]: value,
},
},
}));
};
// --- Handlers: Avatar --- // --- Handlers: Avatar ---
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -324,14 +383,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
setAlarmMsg(null); setAlarmMsg(null);
setSavingAlarms(true); setSavingAlarms(true);
try { try {
const nextAlarmChannels = [
...(notificationSettings.events.due.email ? ['email'] : []),
...(notificationSettings.events.due.discord ? ['discord'] : []),
...(alarmChannels.includes('webhook') ? ['webhook'] : []),
];
const updated = await api.updateSettings({ const updated = await api.updateSettings({
alarmChannels, alarmChannels: nextAlarmChannels,
alarmEmail: alarmEmail.trim(), alarmEmail: alarmEmail.trim(),
discordWebhookUrl: discordWebhookUrl.trim(), discordWebhookUrl: discordWebhookUrl.trim(),
customWebhookUrl: customWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes), notifyBeforeMinutes: Number(notifyBeforeMinutes),
notificationSettings,
});
onUserUpdated({
...updated,
notificationSettings: updated.notificationSettings ?? notificationSettings,
}); });
onUserUpdated(updated);
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
} catch (err: any) { } catch (err: any) {
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
@@ -1019,6 +1087,176 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
)} )}
<form onSubmit={handleSaveAlarms} className="space-y-4"> <form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Global Pause & Own Watched Toggles */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-main">Alle Benachrichtigungen pausieren</p>
<p className="text-[11px] text-muted">
Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.paused}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, paused: v }))}
title="Alle Benachrichtigungen pausieren"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-3 border-t border-border">
<div>
<p className="font-semibold text-main">
Auch eigene Änderungen an beobachteten Aufgaben melden
</p>
<p className="text-[11px] text-muted">
Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.includeOwnWatched}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))}
title="Auch eigene Änderungen an beobachteten Aufgaben melden"
/>
</div>
</div>
{/* Routing Matrix: Events x (E-Mail / Discord) */}
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
</div>
<div className="divide-y divide-border">
{NOTIFICATION_EVENT_ROWS.map((row) => {
const routing = notificationSettings.events[row.key];
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
onChange={(v) => setEventChannel(row.key, 'discord', v)}
title={
hasDiscordWebhook
? `${row.label} – Discord`
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-faint pt-1">
Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben)
</p>
{!hasDiscordWebhook && (
<p className="text-[11px] text-status-warn">
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
</p>
)}
</div>
{/* Daily Summary */}
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-bold text-main">Tageszusammenfassung</p>
<p className="text-[11px] text-muted">
Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht
</p>
</div>
<ToggleSwitch
checked={notificationSettings.dailySummary.enabled}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, enabled: v },
}))
}
title="Tageszusammenfassung"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-main font-medium">
<span>Uhrzeit:</span>
<input
type="time"
aria-label="Uhrzeit der Tageszusammenfassung"
value={notificationSettings.dailySummary.time}
onChange={(e) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, time: e.target.value },
}))
}
className="h-11 sm:h-8 px-2.5 input-field font-mono"
/>
</label>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">E-Mail</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.email}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, email: v },
}))
}
title="Tageszusammenfassung – E-Mail"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Discord</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.discord}
disabled={!hasDiscordWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, discord: v },
}))
}
title={
hasDiscordWebhook
? 'Tageszusammenfassung – Discord'
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
</div>
</div>
</div>
</>
);
})()}
{/* Lead Time Selector */} {/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div> </div>
{/* Channel 1: E-Mail */} {/* Channel 1: E-Mail */}
<div <div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<Mail className="w-4 h-4 text-accent" /> <Mail className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block">Ziel: E-Mail</span> <span className="font-bold text-main block">Ziel: E-Mail</span>
@@ -1064,7 +1292,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz. Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -1092,19 +1320,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div> </div>
{/* Channel 2: Discord Webhook */} {/* Channel 2: Discord Webhook */}
<div <div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none"> <div className="flex items-center gap-2.5">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<MessageSquare className="w-4 h-4 text-accent" /> <MessageSquare className="w-4 h-4 text-accent" />
<div> <div>
<span className="font-bold text-main block">Ziel: Discord Webhook</span> <span className="font-bold text-main block">Ziel: Discord Webhook</span>
@@ -1113,7 +1331,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Discord-Kanal. Discord-Kanal.
</span> </span>
</div> </div>
</label> </div>
<button <button
type="button" type="button"
@@ -0,0 +1,106 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserSettingsView } from '../UserSettingsView';
import { api } from '../../../lib/api';
import { User } from '../../../lib/types';
vi.mock('../../../lib/api', async () => {
const actual = await vi.importActual<typeof import('../../../lib/api')>('../../../lib/api');
return {
...actual,
api: {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
});
const baseUser: User = {
id: 'u1',
email: 'anna@example.com',
displayName: 'Anna',
role: 'member',
isActive: true,
alarmChannels: ['email'],
createdAt: '2026-10-30T00:00:00Z',
};
describe('UserSettingsView notification matrix', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('derives events.due from alarmChannels, disables Discord without webhook, and saves the exact notificationSettings object', async () => {
const onUserUpdated = vi.fn();
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={onUserUpdated}
onBack={() => {}}
/>
);
// Open "Alarme" section
fireEvent.click(screen.getByText('Alarme'));
// Without Discord webhook, Discord switches are disabled and hint is visible
const discordSwitches = screen.getAllByTitle('Bitte zuerst unten eine Discord Webhook URL eintragen');
expect(discordSwitches.length).toBe(5);
expect((discordSwitches[0] as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText(/Discord-Schalter sind deaktiviert/)).toBeTruthy();
// Enter a Discord Webhook URL to enable the Discord column
fireEvent.change(screen.getByPlaceholderText(/https:\/\/discord\.com\/api\/webhooks/), {
target: { value: 'https://discord.com/api/webhooks/123/abc' },
});
// Derive check: alarmChannels=['email'] -> due.email=true, due.discord=false
expect(screen.getByTitle('Fällige Aufgaben (sofort) – E-Mail').getAttribute('aria-checked')).toBe('true');
expect(screen.getByTitle('Fällige Aufgaben (sofort) – Discord').getAttribute('aria-checked')).toBe('false');
// Toggle settings in the matrix, daily summary, and global switches
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Discord'));
fireEvent.click(screen.getByTitle('Geteilte Aufgabe erledigt – E-Mail'));
fireEvent.click(screen.getByTitle('Beobachtete Aufgaben – Discord'));
fireEvent.click(screen.getByTitle('Alle Benachrichtigungen pausieren'));
fireEvent.click(screen.getByTitle('Auch eigene Änderungen an beobachteten Aufgaben melden'));
fireEvent.click(screen.getByTitle('Tageszusammenfassung'));
fireEvent.change(screen.getByLabelText('Uhrzeit der Tageszusammenfassung'), {
target: { value: '08:30' },
});
fireEvent.click(screen.getByTitle('Tageszusammenfassung – Discord'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
discordWebhookUrl: 'https://discord.com/api/webhooks/123/abc',
notificationSettings: {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true },
shared: { email: true, discord: false },
taskDone: { email: true, discord: false },
watched: { email: true, discord: false },
},
dailySummary: {
enabled: true,
time: '08:30',
email: true,
discord: true,
},
},
})
);
});
});
@@ -15,11 +15,13 @@ import {
Lock, Lock,
UserPlus, UserPlus,
Repeat, Repeat,
Eye,
EyeOff,
} from 'lucide-react'; } from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar'; import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete'; import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown'; import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due'; import { addDays, joinDue, splitDue } from '../../lib/due';
@@ -90,13 +92,32 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
const [newSubtaskTitle, setNewSubtaskTitle] = useState(''); const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState(''); const [newTagDraft, setNewTagDraft] = useState('');
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
const [watchBusy, setWatchBusy] = useState(false);
useEffect(() => { useEffect(() => {
setTitleDraft(task.content); setTitleDraft(task.content);
setNotesDraft(task.notes || ''); setNotesDraft(task.notes || '');
setIsEditingTitle(false); setIsEditingTitle(false);
setIsEditingNotes(false); setIsEditingNotes(false);
}, [task.id]); setIsWatching(Boolean(task.isWatching));
}, [task.id, task.isWatching]);
const handleToggleWatch = async () => {
if (watchBusy) return;
setWatchBusy(true);
try {
const res = isWatching ? await api.unwatchTask(task.id) : await api.watchTask(task.id);
const next = Boolean(res.watching);
setIsWatching(next);
task.isWatching = next;
triggerHaptic(10);
} catch {
// ignore
} finally {
setWatchBusy(false);
}
};
// Handle escape key to close // Handle escape key to close
useEffect(() => { useEffect(() => {
@@ -226,6 +247,27 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleToggleWatch}
disabled={watchBusy}
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
isWatching ? 'border-accent text-accent' : ''
}`}
>
{isWatching ? (
<>
<EyeOff className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span>Nicht mehr beobachten</span>
</>
) : (
<>
<Eye className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>Beobachten</span>
</>
)}
</button>
{(task.convertedType === 'document_block' || task.docId) ? ( {(task.convertedType === 'document_block' || task.docId) ? (
<button <button
type="button" type="button"
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TaskDetailModal } from '../TaskDetailModal'; import { TaskDetailModal } from '../TaskDetailModal';
import { api } from '../../../lib/api';
import { Thought } from '../../../lib/types'; import { Thought } from '../../../lib/types';
const task: Thought = { const task: Thought = {
@@ -67,4 +68,20 @@ describe('TaskDetailModal', () => {
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy(); expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true); expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
}); });
it('toggles watching a task via POST and DELETE /tasks/{id}/watch', async () => {
const watchSpy = vi.spyOn(api, 'watchTask').mockResolvedValue({ watching: true });
const unwatchSpy = vi.spyOn(api, 'unwatchTask').mockResolvedValue({ watching: false });
render(<TaskDetailModal {...props({ task: { ...task, isWatching: false } })} />);
const btn = screen.getByRole('button', { name: 'Beobachten' });
fireEvent.click(btn);
await waitFor(() => expect(watchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Nicht mehr beobachten' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Nicht mehr beobachten' }));
await waitFor(() => expect(unwatchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Beobachten' })).toBeTruthy();
});
}); });
@@ -9,16 +9,24 @@ interface DictationBlockProps {
onCancel: () => void; onCancel: () => void;
} }
function formatDuration(sec: number): string {
const m = String(Math.floor(sec / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${m}:${s}`;
}
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one // What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
// button with the live wave and a translucent "Stopp" on top. A tap ends it; the text lands in the // button with live text growing above the wave, mm:ss timer and a translucent "Stopp" on top. A tap ends
// bullet as plain text. // it; the text lands in the bullet as plain text.
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => { export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
const [interim, setInterim] = useState(''); const [interim, setInterim] = useState('');
const [elapsedSec, setElapsedSec] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const doneRef = useRef(false); const doneRef = useRef(false);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const voice = useVoiceInput({ const voice = useVoiceInput({
live: true,
continuous: true, continuous: true,
onText: (text, final) => { onText: (text, final) => {
if (!final) { if (!final) {
@@ -45,11 +53,17 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
triggerHaptic([15, 60, 25]); triggerHaptic([15, 60, 25]);
void voice.start(); void voice.start();
buttonRef.current?.focus(); buttonRef.current?.focus();
const started = Date.now();
const timer = window.setInterval(() => {
setElapsedSec(Math.floor((Date.now() - started) / 1000));
}, 500);
return () => window.clearInterval(timer);
// start once when the bullet turns into the dictation // start once when the bullet turns into the dictation
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const transcribing = voice.state === 'transcribing'; const transcribing = voice.state === 'transcribing';
const chunkBusy = voice.pendingChunks > 0 || transcribing;
return ( return (
<button <button
@@ -71,13 +85,20 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
}} }}
disabled={transcribing || !!error} disabled={transcribing || !!error}
aria-label="Diktat beenden" aria-label="Diktat beenden"
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 rounded-card border border-accent bg-accent-dim overflow-hidden flex items-center justify-center cursor-pointer disabled:cursor-default focus:outline-none" className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-3 py-2 rounded-card border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
> >
{/* Live wave across the whole bullet */} {(interim || chunkBusy) && !error && (
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-3 inset-y-2 justify-between h-auto text-accent opacity-60" /> <div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
{interim && !error && ( {interim}
<span className="absolute inset-x-3 bottom-1 text-[11px] text-main truncate text-left pointer-events-none">{interim}</span> {chunkBusy && <span className="text-muted animate-pulse">{interim ? ' …' : '…'}</span>}
</div>
)} )}
<div className="relative w-full min-h-[32px] flex items-center justify-between gap-2">
{/* Live wave across the bottom bar */}
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-0 inset-y-0 justify-between h-auto text-accent opacity-60" />
<span className="relative z-10 px-2 py-0.5 rounded-pill bg-panel/70 text-muted text-[11px] font-mono tabular-nums backdrop-blur-sm">
{formatDuration(elapsedSec)}
</span>
{/* Floating stop label on top */} {/* Floating stop label on top */}
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm"> <span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
{error ? ( {error ? (
@@ -92,6 +113,7 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
</> </>
)} )}
</span> </span>
</div>
</button> </button>
); );
}; };
@@ -18,16 +18,26 @@ import {
CloudOff, CloudOff,
AlertTriangle, AlertTriangle,
GitMerge, GitMerge,
Undo2,
Redo2,
MoreHorizontal,
} from 'lucide-react'; } from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types'; import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { Presence } from '../../lib/docSync/transport';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export interface DocHeaderStatus {
showLive: boolean;
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
otherPeople: Presence[];
avatarsById: Map<string, string>;
}
export interface DocumentEditorProps { export interface DocumentEditorProps {
docId: string; docId: string;
currentUser?: User | null; currentUser?: User | null;
@@ -45,6 +55,7 @@ export interface DocumentEditorProps {
onBackMobile?: () => void; onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void; onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
/** Opens the dialog to merge this note into another one (own notes only) */ /** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void; onRequestMerge?: (doc: Document) => void;
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */ /** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
@@ -196,6 +207,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile, onBackMobile,
onOpenTask, onOpenTask,
onSaveStatusChange, onSaveStatusChange,
onHeaderStatusChange,
onRequestMerge, onRequestMerge,
onDocCreated, onDocCreated,
onDocRemoved, onDocRemoved,
@@ -209,6 +221,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false); const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null); const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server // Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null); const engineRef = useRef<DocSyncEngine | null>(null);
@@ -384,6 +398,48 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {}); .catch(() => {});
}, [otherPeople.length]); }, [otherPeople.length]);
useEffect(() => {
onHeaderStatusChange?.({
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
channel: engineState?.channel || 'ws',
otherPeople,
avatarsById,
});
}, [
engineState?.shared,
engineState?.sessions,
engineState?.channel,
otherPeople,
avatarsById,
onHeaderStatusChange,
]);
useEffect(() => {
return () => {
onHeaderStatusChange?.(null);
};
}, [onHeaderStatusChange]);
useEffect(() => {
if (!showActionsMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
setShowActionsMenu(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowActionsMenu(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [showActionsMenu]);
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]); const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
@@ -715,7 +771,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<button <button
type="button" type="button"
onClick={onBackMobile} onClick={onBackMobile}
className="lg:hidden btn-icon" className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Zurück zur Seitenleiste" title="Zurück zur Seitenleiste"
> >
<ArrowLeft className="w-3.5 h-3.5" /> <ArrowLeft className="w-3.5 h-3.5" />
@@ -813,119 +869,129 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Right Action Buttons */} {/* Right Action Buttons */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{/* How changes of others arrive (only relevant for shared notes) */} {!inTrash && (
{engineState && (engineState.shared || engineState.sessions > 1) && ( <>
<span <button
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border" type="button"
title={ onClick={handleUndo}
engineState.channel === 'ws' disabled={!canEdit || !engineState?.canUndo}
? 'Live-Verbindung über WebSocket' className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
: engineState.channel === 'sse' title="Rückgängig (Strg+Z)"
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)' aria-label="Rückgängig (Strg+Z)"
: engineState.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
> >
<span <Undo2 className="w-3.5 h-3.5" />
className={`w-1.5 h-1.5 rounded-full ${ </button>
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok' <button
}`} type="button"
/> onClick={handleRedo}
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'} disabled={!canEdit || !engineState?.canRedo}
</span> className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Wiederholen (Strg+Y)"
aria-label="Wiederholen (Strg+Y)"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</>
)} )}
{/* Who else has this note open */} {doc && !inTrash && (
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
const ownerDisplayName = doc.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button <button
type="button" type="button"
onClick={() => onOpenShareModal(doc)} onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer" className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon"
title="Notiz teilen und Berechtigungen" title="Notiz teilen und Berechtigungen"
aria-label="Notiz teilen und Berechtigungen"
> >
<Users className="w-3.5 h-3.5" /> <Users className="w-3.5 h-3.5" />
</button> </button>
{onRequestMerge && doc.isOwner && !inTrash && ( )}
<div className="relative" ref={actionsMenuRef}>
<button <button
type="button" type="button"
onClick={() => onRequestMerge(doc)} onClick={() => setShowActionsMenu((prev) => !prev)}
className="btn-icon" aria-expanded={showActionsMenu}
title="Mit anderer Notiz zusammenführen…" aria-haspopup="menu"
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
> >
<GitMerge className="w-3.5 h-3.5" /> <MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showActionsMenu && (
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
>
{!inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
void handleTogglePin();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Pin
className={`w-3.5 h-3.5 shrink-0 ${
doc?.isPinned ? 'text-status-warn' : 'text-accent'
}`}
/>
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
</button> </button>
)} )}
</div>
);
})()}
{!inTrash && <button {onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button" type="button"
onClick={handleTogglePin} role="menuitem"
className={`btn-icon ${ onClick={() => {
doc?.isPinned setShowActionsMenu(false);
? 'text-status-warn bg-status-warn/10 border-status-warn/30' onRequestMerge(doc);
: '' }}
}`} className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
> >
<Pin className="w-3.5 h-3.5" /> <GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
</button>} <span>Mit anderer Notiz zusammenführen…</span>
</button>
)}
<button <button
type="button" type="button"
onClick={handleExportMarkdown} role="menuitem"
className="btn-icon" onClick={() => {
title="Als Markdown (.md) exportieren" setShowActionsMenu(false);
handleExportMarkdown();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button> </button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( {canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<>
<div className="my-1 border-t border-border" />
<button <button
type="button" type="button"
onClick={() => onDeleteDoc(docId)} role="menuitem"
className="btn-icon-danger" onClick={() => {
title="Notiz löschen" setShowActionsMenu(false);
onDeleteDoc(docId);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button> </button>
</>
)} )}
</div> </div>
)}
</div>
</div>
</div> </div>
{/* In the trash: read-only with a way back */} {/* In the trash: read-only with a way back */}
@@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock'; import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice'; import { currentVoiceConfig } from '../../lib/voice';
import { LinkPreviewCard } from './LinkPreviewCard'; import { LinkPreviewCard } from './LinkPreviewCard';
import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview'; import { linkCardUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { mediaContent, parseMediaContent } from '../../lib/media'; import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown'; import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
@@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
docId, docId,
block, block,
index, index,
isLast,
hasChildren, hasChildren,
isFocused, isFocused,
canEdit, canEdit,
@@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [slashSearch, setSlashSearch] = useState<string | null>(null); const [slashSearch, setSlashSearch] = useState<string | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done) // Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null); const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets with a plain web address (lib/linkPreview.ts) // Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
const previewsOn = useLinkPreviewsEnabled(); const previewsOn = useLinkPreviewsEnabled();
const previewUrl = const previewUrl =
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
? firstBareUrl(localContent) ? linkCardUrl(localContent)
: null; : null;
const [slashIndex, setSlashIndex] = useState(0); const [slashIndex, setSlashIndex] = useState(0);
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false); const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false); const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const q = slashSearch.toLowerCase(); const q = slashSearch.toLowerCase();
const cmdQuery = q.startsWith('code ') ? 'code' : q; const cmdQuery = q.startsWith('code ') ? 'code' : q;
// Media only in an otherwise empty bullet // Media only in an otherwise empty bullet
const emptyBullet = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
return SLASH_COMMANDS.filter( return SLASH_COMMANDS.filter(
(c) => (c) =>
(!c.media || emptyBullet) && (!c.media || emptyBullet) &&
@@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
); );
}, [slashSearch, localContent, onExtractBlock]); }, [slashSearch, localContent, onExtractBlock]);
useEffect(() => {
if (slashSearch === null) return;
slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
}, [slashIndex, slashSearch]);
const applySlashCommand = (cmd: SlashCommand) => { const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null); setSlashSearch(null);
const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i); const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') { if (cmd.type === 'link') {
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean;
setShowLinkModal(true); setShowLinkModal(true);
return; return;
} }
@@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
const langOverride = const langOverride =
cmd.type === 'code' && codeLangMatch cmd.type === 'code' && codeLangMatch
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
: undefined; : undefined;
setLocalContent(clean); setLocalContent(clean);
localContentRef.current = clean; localContentRef.current = clean;
@@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const handleInsertLink = (linkMarkdown: string) => { const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false); setShowLinkModal(false);
if (localContent.trim() === '') {
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) {
const bareUrl = webMatch[1];
setLocalContent(bareUrl);
localContentRef.current = bareUrl;
flushDebounce(bareUrl);
return;
}
}
const cursor = textareaRef.current?.selectionStart ?? localContent.length; const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor); const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor); const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
setLocalContent(newContent); setLocalContent(newContent);
localContentRef.current = newContent;
flushDebounce(newContent); flushDebounce(newContent);
}; };
@@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
// Check for slash command invocation // Check for slash command invocation
const slashMatch = !typeOverride ? val.match(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
if (slashMatch) { if (slashMatch) {
setSlashSearch(slashMatch[1]); setSlashSearch(slashMatch[2]);
setSlashIndex(0); setSlashIndex(0);
} else { } else {
setSlashSearch(null); setSlashSearch(null);
@@ -600,7 +617,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
if (!canEdit) return; if (!canEdit) return;
// Slash command navigation // Slash command navigation
if (slashSearch !== null && filteredSlashCommands.length > 0) { if (slashSearch !== null) {
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
return;
}
if (filteredSlashCommands.length > 0) {
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
@@ -619,10 +642,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
} }
return; return;
} }
if (e.key === 'Escape') {
e.preventDefault();
setSlashSearch(null);
return;
} }
} }
@@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
// 2. Enter (without Shift) -> If block is completely empty: // 2. Enter (without Shift) -> If block is completely empty:
// - If level > minLevel, outdent (Shift+Tab behaviour) // - If level > minLevel, outdent (Shift+Tab behaviour)
// - If already at minLevel and is the last block, do NOT create another empty block below
// - Otherwise split paragraph at cursor and create new block below // - Otherwise split paragraph at cursor and create new block below
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault(); e.preventDefault();
@@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onIndentBlockRef.current(block.id, -1); onIndentBlockRef.current(block.id, -1);
return; return;
} }
if (isLast) {
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
return;
}
} }
const start = e.currentTarget.selectionStart ?? localContent.length; const start = e.currentTarget.selectionStart ?? localContent.length;
@@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{filteredSlashCommands.map((cmd, i) => ( {filteredSlashCommands.map((cmd, i) => (
<button <button
key={cmd.type} key={cmd.type}
ref={(el) => {
slashItemRefs.current[i] = el;
}}
type="button" type="button"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
@@ -46,7 +46,8 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff'; import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext'; import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
import { PresenceAvatars } from './PresenceAvatars';
import { TrashView } from './TrashView'; import { TrashView } from './TrashView';
import { MergeDialog } from './MergeDialog'; import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
@@ -386,6 +387,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved'); const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null); const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
// Drag and Drop state // Drag and Drop state
@@ -1306,10 +1308,83 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</button> </button>
</div> </div>
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */} {/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
<div className="hidden sm:flex items-center gap-2"> <div className="hidden sm:flex items-center gap-2">
{subView === 'editor' && selectedDocId ? ( {subView === 'editor' && selectedDocId ? (
/* Save Status Pill */ <>
{/* Live connection pill */}
{docHeaderStatus?.showLive && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title={
docHeaderStatus.channel === 'ws'
? 'Live-Verbindung über WebSocket'
: docHeaderStatus.channel === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: docHeaderStatus.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
docHeaderStatus.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
}`}
/>
{docHeaderStatus.channel === 'longpoll'
? 'Live (Long-Polling)'
: docHeaderStatus.channel === 'interval'
? 'Abgleich alle 3 s'
: 'Live'}
</span>
)}
{/* Who else has this note open */}
{docHeaderStatus && (
<PresenceAvatars
people={docHeaderStatus.otherPeople}
avatars={docHeaderStatus.avatarsById}
/>
)}
{/* Share pill */}
{selectedDocObj &&
!selectedDocObj.isArchived &&
(() => {
const isOwner =
selectedDocObj.isOwner !== false &&
(selectedDocObj.userId === currentUser?.id ||
!selectedDocObj.userId ||
currentUser?.role === 'admin');
const sharesCount = selectedDocObj.sharedWith?.length || 0;
const isShared =
sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId);
const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer';
if (!isShared) return null;
return isOwner ? (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
);
})()}
{/* Save Status Pill */}
<div <div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved' docSaveStatus === 'saved'
@@ -1355,6 +1430,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</> </>
)} )}
</div> </div>
</>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -1645,6 +1721,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setDocSaveStatus(status); setDocSaveStatus(status);
setDocSaveError(err || null); setDocSaveError(err || null);
}} }}
onHeaderStatusChange={setDocHeaderStatus}
/> />
) : subView === 'tags' ? ( ) : subView === 'tags' ? (
/* ========================================================================= /* =========================================================================
@@ -0,0 +1,163 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DocumentEditor } from '../DocumentEditor';
import { api } from '../../../lib/api';
import { Document } from '../../../lib/types';
import { EngineState } from '../../../lib/docSync/engine';
let currentListener: ((state: EngineState) => void) | null = null;
const mockUndo = vi.fn(() => ({ ok: true }));
const mockRedo = vi.fn(() => ({ ok: true }));
const baseEngineState: EngineState = {
ready: true,
status: 'saved',
role: 'owner',
title: 'Test Notiz',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
pendingCount: 0,
shared: true,
sessions: 2,
presence: [{ userId: 'u2', name: 'Bob' }],
lockedByOthers: [],
conflicts: [],
canUndo: false,
canRedo: false,
channel: 'ws',
};
vi.mock('../../../lib/docSync/engine', () => {
return {
DocSyncEngine: class {
subscribe(fn: (state: EngineState) => void) {
currentListener = fn;
fn(baseEngineState);
return () => {
currentListener = null;
};
}
start() {
return Promise.resolve();
}
stop() {}
getState() {
return baseEngineState;
}
setTaskLinks() {}
focusBlock() {}
applyFlat() {}
setTitle() {}
undo() {
return mockUndo();
}
redo() {
return mockRedo();
}
},
};
});
vi.mock('../../../lib/api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../lib/api')>();
return {
...actual,
api: {
...actual.api,
getDocument: vi.fn(),
getUsersDirectory: vi.fn(() => Promise.resolve([])),
getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })),
},
};
});
const makeDoc = (overrides: Partial<Document> = {}): Document => ({
id: 'doc_1',
userId: 'u1',
title: 'Test Notiz',
icon: '📝',
folderPath: '',
content: '',
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
tags: [],
isPinned: false,
isArchived: false,
isOwner: true,
createdAt: '2026-10-30T08:00:00Z',
updatedAt: '2026-10-30T08:00:00Z',
...overrides,
});
describe('DocumentEditor header actions & status callback (Teil D)', () => {
beforeEach(() => {
vi.clearAllMocks();
const doc = makeDoc();
vi.mocked(api.getDocument).mockResolvedValue(doc);
});
it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => {
const doc = makeDoc({ isOwner: true });
const onHeaderStatusChange = vi.fn();
const onRequestMerge = vi.fn();
const onDeleteDoc = vi.fn();
const onOpenShareModal = vi.fn();
render(
<DocumentEditor
docId="doc_1"
initialDoc={doc}
availableFolders={[]}
onDocMetadataChange={vi.fn()}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={vi.fn()}
onRequestMerge={onRequestMerge}
onHeaderStatusChange={onHeaderStatusChange}
/>
);
await waitFor(() => {
expect(onHeaderStatusChange).toHaveBeenCalledWith(
expect.objectContaining({
showLive: true,
channel: 'ws',
})
);
});
// Undo / Redo buttons disabled initially
const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement;
const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement;
expect(undoBtn.disabled).toBe(true);
expect(redoBtn.disabled).toBe(true);
// Enable Undo & Redo via engine state update
currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true });
await waitFor(() => {
expect(undoBtn.disabled).toBe(false);
expect(redoBtn.disabled).toBe(false);
});
fireEvent.click(undoBtn);
expect(mockUndo).toHaveBeenCalledTimes(1);
fireEvent.click(redoBtn);
expect(mockRedo).toHaveBeenCalledTimes(1);
// Share button is directly visible
const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen');
fireEvent.click(shareBtn);
expect(onOpenShareModal).toHaveBeenCalledWith(doc);
// Three-dots menu opens on click, shows secondary actions, and closes on Escape
const moreBtn = screen.getByTitle('Weitere Aktionen');
fireEvent.click(moreBtn);
expect(screen.getByText('Notiz anheften')).toBeTruthy();
expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy();
expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy();
expect(screen.getByText('Notiz löschen')).toBeTruthy();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByText('Notiz anheften')).toBeNull();
});
});
@@ -118,3 +118,140 @@ describe('conflict copies', () => {
]); ]);
}); });
}); });
describe('slash command menu and outliner fixes', () => {
it('does not open the slash menu for URLs like https://a.de/b/ but opens for Text /co and closes on Escape or Backspace', () => {
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, queryByText } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
expect(queryByText('Befehl auswählen')).toBeNull();
fireEvent.change(ta, { target: { value: 'Text /co' } });
expect(queryByText('Befehl auswählen')).toBeTruthy();
expect(queryByText('/code')).toBeTruthy();
// Removing the slash closes the menu
fireEvent.change(ta, { target: { value: 'Text ' } });
expect(queryByText('Befehl auswählen')).toBeNull();
// Even when no command matches, Escape closes the menu
fireEvent.change(ta, { target: { value: '/zzzznotfound' } });
expect(queryByText('Kein Befehl gefunden')).toBeTruthy();
fireEvent.keyDown(ta, { key: 'Escape' });
expect(queryByText('Befehl auswählen')).toBeNull();
});
it('scrolls the active slash command button into view on ArrowDown', () => {
const scrollSpy = vi.fn();
HTMLElement.prototype.scrollIntoView = scrollSpy;
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container } = renderBlock(block);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/' } });
scrollSpy.mockClear();
fireEvent.keyDown(ta, { key: 'ArrowDown' });
expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
});
it('creates a new block on Enter at the last empty top-level bullet, after outdenting if indented', () => {
const onIndent = vi.fn();
const onCreateBelow = vi.fn();
const indented = { id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock;
const { container, rerender } = render(
<OutlinerBlock
docId="d"
block={indented}
index={0}
isLast
maxAllowedLevel={2}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onIndent).toHaveBeenCalledWith('b1', -1);
expect(onCreateBelow).not.toHaveBeenCalled();
rerender(
<OutlinerBlock
docId="d"
block={{ ...indented, level: 0 }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused
canEdit
onUpdateBlock={() => {}}
onCreateBlockBelow={onCreateBelow}
onDeleteBlock={() => {}}
onIndentBlock={onIndent}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
fireEvent.keyDown(ta, { key: 'Enter' });
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
});
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
const updates: DocumentBlock[] = [];
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
const { container, getByText, getByRole, getByPlaceholderText, rerender } = renderBlock(block, {
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
});
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
ta.focus();
fireEvent.change(ta, { target: { value: '/link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('https://example.com/test');
// Non-empty bullet keeps [Text](url)
rerender(
<OutlinerBlock
docId="d"
block={{ ...block, content: 'Siehe' }}
index={0}
isLast
maxAllowedLevel={1}
hasChildren={false}
isFocused={false}
canEdit
onUpdateBlock={(u: DocumentBlock) => updates.push(u)}
onCreateBlockBelow={() => {}}
onDeleteBlock={() => {}}
onIndentBlock={() => {}}
onNavigateFocus={() => {}}
registerTextareaRef={() => {}}
/>
);
ta.focus();
fireEvent.change(ta, { target: { value: 'Siehe /link' } });
fireEvent.keyDown(ta, { key: 'Enter' });
fireEvent.click(getByText('Web-Link'));
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/doku' } });
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
});
});
+11 -9
View File
@@ -1,14 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview'; import { linkCardUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => { describe('link preview helpers', () => {
it('finds the first plain https address', () => { it('returns the URL only when the entire trimmed content is a single https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle'); expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull();
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull(); expect(linkCardUrl('https://a.de https://b.de')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull(); expect(linkCardUrl('siehe (https://x.de/p).')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull(); expect(linkCardUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(linkCardUrl('http://unsicher.de')).toBeNull();
expect(linkCardUrl('nur Text')).toBeNull();
}); });
it('hides a preview by turning the address into a named link', () => { it('hides a preview by turning the address into a named link', () => {
@@ -16,6 +18,6 @@ describe('link preview helpers', () => {
'Lesen: [Käsespätzle Rezept](https://koch.de/x)' 'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
); );
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)'); expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull(); expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
}); });
}); });
@@ -130,4 +130,121 @@ describe('useVoiceInput', () => {
await waitFor(() => expect(none.result.current.mode).toBe('unsupported')); await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
expect(none.result.current.available).toBe(false); expect(none.result.current.available).toBe(false);
}); });
it('browser mode with live=true: restarts on pause (onend), keeps base text, rate-limits restarts to 300ms, and sends final=true only on stop()', async () => {
const instances: any[] = [];
class FakeSpeechRecognition {
lang = '';
interimResults = false;
continuous = false;
onresult: ((e: any) => void) | null = null;
onerror: ((e: any) => void) | null = null;
onend: (() => void) | null = null;
constructor() {
instances.push(this);
}
start() {}
stop() {
this.onend?.();
}
abort() {}
}
(window as any).SpeechRecognition = FakeSpeechRecognition;
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('browser'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
expect(instances).toHaveLength(1);
// First utterance before a pause
act(() => {
instances[0].onresult?.({ results: [[{ transcript: 'Erster Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz.', false);
// Browser ends recognition on speech pause -> immediate restart (#2)
act(() => {
instances[0].onend?.();
});
expect(instances).toHaveLength(2);
expect(onText).not.toHaveBeenCalledWith(expect.anything(), true);
// Another immediate onend within <300ms waits until 300ms have passed
act(() => {
instances[1].onend?.();
});
expect(instances).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(300);
});
expect(instances).toHaveLength(3);
// Second utterance appends to base text
act(() => {
instances[2].onresult?.({ results: [[{ transcript: 'Zweiter Satz.' }]] });
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', false);
// Explicit stop() finishes with final=true
await act(async () => {
await result.current.stop();
});
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', true);
expect(result.current.state).toBe('idle');
});
it('server mode with live=true: cuts segments onto new MediaRecorders on the same stream, orders out-of-order responses by chunk index, and releases stream at the end', async () => {
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
const resolvers: Array<(text: string) => void> = [];
transcribe.mockImplementation(
() =>
new Promise<string>((resolve) => {
resolvers.push(resolve);
})
);
const onText = vi.fn();
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
await waitFor(() => expect(result.current.mode).toBe('server'));
vi.useFakeTimers();
await act(async () => {
await result.current.start();
});
// Advance 8s -> segment 0 is cut and sent to transcribe, stream is NOT released yet
await act(async () => {
await vi.advanceTimersByTimeAsync(8000);
});
expect(transcribe).toHaveBeenCalledTimes(1);
expect(stopTrack).not.toHaveBeenCalled();
// Advance 4s and stop -> segment 1 (final) is sent to transcribe, stream IS released now
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await result.current.stop();
});
expect(transcribe).toHaveBeenCalledTimes(2);
expect(stopTrack).toHaveBeenCalledTimes(1);
// Resolve chunk 1 BEFORE chunk 0 to verify index-based ordering
await act(async () => {
resolvers[1]('zweiter Teil');
});
expect(onText).toHaveBeenLastCalledWith('zweiter Teil', false);
// Now resolve chunk 0 -> both chunks are done, ordered "Erster Teil zweiter Teil" with final=true
await act(async () => {
resolvers[0]('Erster Teil');
});
expect(onText).toHaveBeenLastCalledWith('Erster Teil zweiter Teil', true);
expect(result.current.state).toBe('idle');
});
}); });
+14
View File
@@ -35,6 +35,7 @@ import {
TagSummary, TagSummary,
User, User,
UserInvite, UserInvite,
NotificationSettings,
VoiceAdminStatus, VoiceAdminStatus,
MergeResult, MergeResult,
WSMessage, WSMessage,
@@ -292,6 +293,7 @@ export const api = {
discordWebhookUrl?: string; discordWebhookUrl?: string;
customWebhookUrl?: string; customWebhookUrl?: string;
notifyBeforeMinutes?: number; notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
}): Promise<User> { }): Promise<User> {
return request<User>('/api/v1/auth/settings', { return request<User>('/api/v1/auth/settings', {
method: 'PATCH', method: 'PATCH',
@@ -410,6 +412,18 @@ export const api = {
return ensureArray(res); return ensureArray(res);
}, },
async watchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'POST',
});
},
async unwatchTask(taskId: string): Promise<{ watching: boolean }> {
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
method: 'DELETE',
});
},
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> { async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
return request<{ success: boolean }>(url, { return request<{ success: boolean }>(url, {
+5 -5
View File
@@ -16,12 +16,12 @@ export interface LinkPreview {
ok: boolean; ok: boolean;
} }
// First plain https address in a text (not inside [text](url), not right after "(" or "<") // Entire bullet content (trimmed) is a single plain https address
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/; const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/;
export function firstBareUrl(content: string): string | null { export function linkCardUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || ''); const trimmed = (content || '').trim();
return m ? m[2] : null; return CARD_URL_RE.test(trimmed) ? trimmed : null;
} }
/** Replaces the address by a named link, which shows no preview. */ /** Replaces the address by a named link, which shows no preview. */
+28
View File
@@ -46,6 +46,32 @@ export interface VoiceAdminStatus {
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
export type SharingMode = 'private' | 'organizations' | 'open'; export type SharingMode = 'private' | 'organizations' | 'open';
export interface NotificationChannelRouting {
email: boolean;
discord: boolean;
}
export interface NotificationEvents {
due: NotificationChannelRouting;
shared: NotificationChannelRouting;
taskDone: NotificationChannelRouting;
watched: NotificationChannelRouting;
}
export interface DailySummarySettings {
enabled: boolean;
time: string;
email: boolean;
discord: boolean;
}
export interface NotificationSettings {
paused: boolean;
includeOwnWatched: boolean;
events: NotificationEvents;
dailySummary: DailySummarySettings;
}
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
@@ -59,6 +85,7 @@ export interface User {
discordWebhookUrl?: string; discordWebhookUrl?: string;
customWebhookUrl?: string; customWebhookUrl?: string;
notifyBeforeMinutes?: number; notifyBeforeMinutes?: number;
notificationSettings?: NotificationSettings;
organizationId?: string; organizationId?: string;
createdAt: string; createdAt: string;
} }
@@ -113,6 +140,7 @@ export interface Thought {
subtasks?: SubTask[]; subtasks?: SubTask[];
assignees?: string[]; // user IDs, only people with access to the task list assignees?: string[]; // user IDs, only people with access to the task list
recurrence?: Recurrence; recurrence?: Recurrence;
isWatching?: boolean;
alarmSent?: boolean; alarmSent?: boolean;
sortOrder?: number; sortOrder?: number;
createdAt: string; createdAt: string;
+208 -43
View File
@@ -10,28 +10,54 @@ export interface VoiceInputOptions {
onError?: (message: string) => void; onError?: (message: string) => void;
/** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */ /** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */
continuous?: boolean; continuous?: boolean;
/** Live dictation mode (/diktat): auto-restart browser speech recognition on pause, and chunk server recordings every ~8s or ~0.8s of silence. */
live?: boolean;
} }
const LIVE_CHUNK_MS = 8000;
const LIVE_SILENCE_MS = 800;
const LIVE_TICK_MS = 200;
const LIVE_SPEECH_LEVEL = 0.02;
const MIN_RESTART_INTERVAL_MS = 300;
/** /**
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text * Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
* is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or * is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or
* "unsupported" means: do not show a microphone. * "unsupported" means: do not show a microphone.
*/ */
export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) { export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) {
const [mode, setMode] = useState<EffectiveVoiceMode | null>(null); const [mode, setMode] = useState<EffectiveVoiceMode | null>(null);
const [state, setState] = useState<VoiceState>('idle'); const [state, setState] = useState<VoiceState>('idle');
const [pendingChunks, setPendingChunks] = useState(0);
const maxSecondsRef = useRef(600); const maxSecondsRef = useRef(600);
const recorderRef = useRef<VoiceRecorder | null>(null); const recorderRef = useRef<VoiceRecorder | null>(null);
const recognitionRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
const timerRef = useRef<number | null>(null); const timerRef = useRef<number | null>(null);
const segmentTimerRef = useRef<number | null>(null);
const restartTimerRef = useRef<number | null>(null);
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
const stoppedRef = useRef(false);
const transcriptRef = useRef(''); const transcriptRef = useRef('');
const baseTranscriptRef = useRef('');
const lastRestartAtRef = useRef(0);
const chunkIndexRef = useRef(0);
const chunkResultsRef = useRef<Map<number, string>>(new Map());
const inFlightChunksRef = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const onTextRef = useRef(onText); const onTextRef = useRef(onText);
onTextRef.current = onText; onTextRef.current = onText;
const onErrorRef = useRef(onError); const onErrorRef = useRef(onError);
onErrorRef.current = onError; onErrorRef.current = onError;
const clearAllTimers = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
timerRef.current = null;
if (segmentTimerRef.current !== null) window.clearInterval(segmentTimerRef.current);
segmentTimerRef.current = null;
if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
};
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
void loadVoiceConfig().then((cfg) => { void loadVoiceConfig().then((cfg) => {
@@ -41,7 +67,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
}); });
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
if (timerRef.current !== null) window.clearTimeout(timerRef.current); clearAllTimers();
recorderRef.current?.cancel(); recorderRef.current?.cancel();
try { try {
recognitionRef.current?.abort?.(); recognitionRef.current?.abort?.();
@@ -54,17 +80,65 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
const setStateSafe = (s: VoiceState) => { const setStateSafe = (s: VoiceState) => {
if (mountedRef.current) setState(s); if (mountedRef.current) setState(s);
}; };
const setPendingChunksSafe = (n: number) => {
if (mountedRef.current) setPendingChunks(n);
};
const fail = (message: string) => { const fail = (message: string) => {
setStateSafe('idle'); setStateSafe('idle');
setPendingChunksSafe(0);
onErrorRef.current?.(message); onErrorRef.current?.(message);
}; };
const clearTimer = () => {
if (timerRef.current !== null) window.clearTimeout(timerRef.current); const joinedChunkText = () =>
timerRef.current = null; [...chunkResultsRef.current.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, t]) => t.trim())
.filter(Boolean)
.join(' ');
const processSegmentPromise = (idx: number, blobPromise: Promise<Blob | null>) => {
inFlightChunksRef.current += 1;
setPendingChunksSafe(inFlightChunksRef.current);
void blobPromise
.then(async (blob) => {
if (cancelledRef.current) return;
if (!blob || blob.size === 0) {
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
setPendingChunksSafe(inFlightChunksRef.current);
if (stoppedRef.current && inFlightChunksRef.current === 0) {
setStateSafe('idle');
onTextRef.current(joinedChunkText(), true);
}
return;
}
const text = await api.transcribe(blob);
if (cancelledRef.current) return;
chunkResultsRef.current.set(idx, text || '');
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
setPendingChunksSafe(inFlightChunksRef.current);
const combined = joinedChunkText();
transcriptRef.current = combined;
if (stoppedRef.current && inFlightChunksRef.current === 0) {
setStateSafe('idle');
onTextRef.current(combined, true);
} else {
onTextRef.current(combined, false);
}
})
.catch((err: any) => {
if (cancelledRef.current) return;
cancelledRef.current = true;
inFlightChunksRef.current = 0;
clearAllTimers();
recorderRef.current?.cancel();
recorderRef.current = null;
fail(err?.message || 'Spracherkennung fehlgeschlagen');
});
}; };
const stop = useCallback(async () => { const stop = useCallback(async () => {
clearTimer(); stoppedRef.current = true;
clearAllTimers();
if (recognitionRef.current) { if (recognitionRef.current) {
try { try {
recognitionRef.current.stop(); recognitionRef.current.stop();
@@ -74,8 +148,20 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
return; return;
} }
const rec = recorderRef.current; const rec = recorderRef.current;
if (!rec) return; if (!rec) {
if (live && state === 'listening') {
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
}
return;
}
recorderRef.current = null; recorderRef.current = null;
if (live) {
const idx = chunkIndexRef.current++;
setStateSafe('transcribing');
processSegmentPromise(idx, rec.stop());
return;
}
const blob = await rec.stop(); const blob = await rec.stop();
if (cancelledRef.current) return; if (cancelledRef.current) return;
if (!blob) { if (!blob) {
@@ -93,11 +179,15 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
} }
// fail only touches refs and state setters // fail only touches refs and state setters
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [live, state]);
const startServer = async () => { const startServer = async () => {
const rec = new VoiceRecorder(); const rec = new VoiceRecorder();
recorderRef.current = rec; recorderRef.current = rec;
chunkIndexRef.current = 0;
chunkResultsRef.current = new Map();
inFlightChunksRef.current = 0;
setPendingChunksSafe(0);
setStateSafe('listening'); setStateSafe('listening');
try { try {
await rec.start(); await rec.start();
@@ -111,6 +201,104 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
return; return;
} }
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000); timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
if (live) {
let segmentMs = 0;
let silentMs = 0;
let hadSpeech = false;
segmentTimerRef.current = window.setInterval(() => {
if (cancelledRef.current || stoppedRef.current || recorderRef.current !== rec) return;
const lvl = rec.level();
if (lvl !== null) {
if (lvl >= LIVE_SPEECH_LEVEL) {
hadSpeech = true;
silentMs = 0;
} else if (hadSpeech) {
silentMs += LIVE_TICK_MS;
}
}
segmentMs += LIVE_TICK_MS;
if (segmentMs >= LIVE_CHUNK_MS || (hadSpeech && silentMs >= LIVE_SILENCE_MS)) {
segmentMs = 0;
silentMs = 0;
hadSpeech = false;
const idx = chunkIndexRef.current++;
processSegmentPromise(idx, rec.cutSegment());
}
}, LIVE_TICK_MS);
}
};
const spawnBrowserRecognition = (Recognition: any, isRestart: boolean) => {
const recognition = new Recognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = continuous || live;
recognition.onresult = (event: any) => {
let sessionText = '';
for (let i = 0; i < event.results.length; i++) sessionText += event.results[i][0].transcript;
if (live && baseTranscriptRef.current) {
const base = baseTranscriptRef.current.trimEnd();
const next = sessionText.trimStart();
transcriptRef.current = next ? `${base} ${next}` : base;
} else {
transcriptRef.current = sessionText;
}
onTextRef.current(transcriptRef.current, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
clearAllTimers();
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
recognitionRef.current = null;
if (cancelledRef.current) {
clearAllTimers();
return;
}
if (!live || stoppedRef.current) {
clearAllTimers();
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
return;
}
baseTranscriptRef.current = transcriptRef.current;
const now = Date.now();
const elapsed = now - lastRestartAtRef.current;
const doRestart = () => {
if (cancelledRef.current || stoppedRef.current) return;
lastRestartAtRef.current = Date.now();
spawnBrowserRecognition(Recognition, true);
};
if (lastRestartAtRef.current === 0 || elapsed >= MIN_RESTART_INTERVAL_MS) {
doRestart();
} else {
restartTimerRef.current = window.setTimeout(() => {
restartTimerRef.current = null;
doRestart();
}, MIN_RESTART_INTERVAL_MS - elapsed);
}
};
recognitionRef.current = recognition;
if (!isRestart) setStateSafe('listening');
try {
recognition.start();
if (!isRestart) {
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
}
} catch {
recognitionRef.current = null;
if (!isRestart) {
fail('Spracheingabe konnte nicht gestartet werden');
} else {
clearAllTimers();
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
}
}
}; };
const startBrowser = () => { const startBrowser = () => {
@@ -119,55 +307,31 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
fail('Spracheingabe wird von diesem Browser nicht unterstützt'); fail('Spracheingabe wird von diesem Browser nicht unterstützt');
return; return;
} }
const recognition = new Recognition(); baseTranscriptRef.current = '';
recognition.lang = 'de-DE'; lastRestartAtRef.current = 0;
recognition.interimResults = true; spawnBrowserRecognition(Recognition, false);
recognition.continuous = continuous;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) combined += event.results[i][0].transcript;
transcriptRef.current = combined;
onTextRef.current(combined, false);
};
recognition.onerror = (event: any) => {
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
cancelledRef.current = true;
recognitionRef.current = null;
fail('Mikrofon nicht freigegeben');
}
};
recognition.onend = () => {
clearTimer();
recognitionRef.current = null;
if (cancelledRef.current) return;
setStateSafe('idle');
onTextRef.current(transcriptRef.current, true);
};
recognitionRef.current = recognition;
setStateSafe('listening');
try {
recognition.start();
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
} catch {
recognitionRef.current = null;
fail('Spracheingabe konnte nicht gestartet werden');
}
}; };
const start = useCallback(async () => { const start = useCallback(async () => {
if (recorderRef.current || recognitionRef.current) return; if (recorderRef.current || recognitionRef.current) return;
cancelledRef.current = false; cancelledRef.current = false;
stoppedRef.current = false;
transcriptRef.current = ''; transcriptRef.current = '';
baseTranscriptRef.current = '';
lastRestartAtRef.current = 0;
const current = mode ?? currentVoiceConfig().mode; const current = mode ?? currentVoiceConfig().mode;
if (current === 'server') await startServer(); if (current === 'server') await startServer();
else if (current === 'browser') startBrowser(); else if (current === 'browser') startBrowser();
else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt');
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode, continuous]); }, [mode, continuous, live]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
cancelledRef.current = true; cancelledRef.current = true;
clearTimer(); stoppedRef.current = true;
inFlightChunksRef.current = 0;
setPendingChunksSafe(0);
clearAllTimers();
recorderRef.current?.cancel(); recorderRef.current?.cancel();
recorderRef.current = null; recorderRef.current = null;
try { try {
@@ -191,6 +355,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu
mode, mode,
available: mode === 'server' || mode === 'browser', available: mode === 'server' || mode === 'browser',
state, state,
pendingChunks,
start, start,
stop, stop,
cancel, cancel,
+44 -13
View File
@@ -86,19 +86,7 @@ export class VoiceRecorder {
this.stream = await navigator.mediaDevices.getUserMedia({ this.stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
}); });
const type = recordingType(); this.startSegmentRecorder();
this.recorder = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
this.chunks = [];
this.recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.chunks.push(e.data);
};
this.recorder.onstop = () => {
const blob = this.chunks.length ? new Blob(this.chunks, { type: this.recorder?.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder.start(1000);
try { try {
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext; const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
this.audioCtx = new Ctx(); this.audioCtx = new Ctx();
@@ -113,6 +101,25 @@ export class VoiceRecorder {
} }
} }
private startSegmentRecorder(): void {
if (!this.stream) return;
const type = recordingType();
const rec = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
const chunks: Blob[] = [];
rec.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
rec.onstop = () => {
const blob = chunks.length ? new Blob(chunks, { type: rec.mimeType || type || 'audio/webm' }) : null;
this.release();
this.done?.(blob);
this.done = null;
};
this.recorder = rec;
this.chunks = chunks;
rec.start(1000);
}
/** Input level 0..1 (roughly loudness), or null when unknown. */ /** Input level 0..1 (roughly loudness), or null when unknown. */
level(): number | null { level(): number | null {
if (!this.analyser || !this.samples) return null; if (!this.analyser || !this.samples) return null;
@@ -137,6 +144,30 @@ export class VoiceRecorder {
}); });
} }
/**
* Stops the current MediaRecorder segment and immediately starts a new one on the same MediaStream.
* Resolves with the finished segment's audio Blob while recording continues seamlessly.
*/
cutSegment(): Promise<Blob | null> {
if (!this.stream || !this.recorder || this.recorder.state === 'inactive') {
return Promise.resolve(null);
}
const oldRecorder = this.recorder;
const oldChunks = this.chunks;
this.startSegmentRecorder();
return new Promise((resolve) => {
oldRecorder.onstop = () => {
const blob = oldChunks.length
? new Blob(oldChunks, { type: oldRecorder.mimeType || 'audio/webm' })
: null;
resolve(blob);
};
oldRecorder.stop();
});
}
cancel(): void { cancel(): void {
this.done = null; this.done = null;
if (this.recorder && this.recorder.state !== 'inactive') { if (this.recorder && this.recorder.state !== 'inactive') {