diff --git a/CHANGELOG.md b/CHANGELOG.md index dcc1b4e..a9291e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,19 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] ### 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`. - 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 - 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 diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 5ade494..c11cae9 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -17,7 +17,7 @@ import { Pencil, Copy, } from 'lucide-react'; -import { AuthStatus, User, UserTheme } from '../../lib/types'; +import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; import { ServerBackupsCard } from './ServerBackupsCard'; import { saveBlob } from '../../lib/download'; @@ -47,6 +47,48 @@ interface UserSettingsViewProps { 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 = ({ currentUser, activeTheme, @@ -211,6 +253,9 @@ export const UserSettingsView: React.FC = ({ const [alarmChannels, setAlarmChannels] = useState( Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] ); + const [notificationSettings, setNotificationSettings] = useState(() => + buildNotificationSettings(currentUser) + ); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || ''); @@ -226,6 +271,7 @@ export const UserSettingsView: React.FC = ({ setEmail(currentUser.email || ''); setAvatarUrl(currentUser.avatarUrl || ''); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); + setNotificationSettings(buildNotificationSettings(currentUser)); setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || ''); @@ -238,6 +284,19 @@ export const UserSettingsView: React.FC = ({ ); }; + const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => { + setNotificationSettings((prev) => ({ + ...prev, + events: { + ...prev.events, + [eventKey]: { + ...prev.events[eventKey], + [channel]: value, + }, + }, + })); + }; + // --- Handlers: Avatar --- const handleAvatarFileSelected = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -324,14 +383,23 @@ export const UserSettingsView: React.FC = ({ setAlarmMsg(null); setSavingAlarms(true); try { + const nextAlarmChannels = [ + ...(notificationSettings.events.due.email ? ['email'] : []), + ...(notificationSettings.events.due.discord ? ['discord'] : []), + ...(alarmChannels.includes('webhook') ? ['webhook'] : []), + ]; const updated = await api.updateSettings({ - alarmChannels, + alarmChannels: nextAlarmChannels, alarmEmail: alarmEmail.trim(), discordWebhookUrl: discordWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(), notifyBeforeMinutes: Number(notifyBeforeMinutes), + notificationSettings, + }); + onUserUpdated({ + ...updated, + notificationSettings: updated.notificationSettings ?? notificationSettings, }); - onUserUpdated(updated); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); } catch (err: any) { setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); @@ -1019,6 +1087,176 @@ export const UserSettingsView: React.FC = ({ )}
+ {/* Global Pause & Own Watched Toggles */} +
+
+
+

Alle Benachrichtigungen pausieren

+

+ Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen. +

+
+ setNotificationSettings((prev) => ({ ...prev, paused: v }))} + title="Alle Benachrichtigungen pausieren" + /> +
+
+
+

+ Auch eigene Änderungen an beobachteten Aufgaben melden +

+

+ Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt. +

+
+ setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))} + title="Auch eigene Änderungen an beobachteten Aufgaben melden" + /> +
+
+ + {/* Routing Matrix: Events x (E-Mail / Discord) */} + {(() => { + const hasDiscordWebhook = Boolean(discordWebhookUrl.trim()); + return ( + <> +
+
+ Ereignis + + + E-Mail + + + + Discord + +
+ +
+ {NOTIFICATION_EVENT_ROWS.map((row) => { + const routing = notificationSettings.events[row.key]; + return ( +
+ {row.label} +
+ setEventChannel(row.key, 'email', v)} + title={`${row.label} – E-Mail`} + /> +
+
+ setEventChannel(row.key, 'discord', v)} + title={ + hasDiscordWebhook + ? `${row.label} – Discord` + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+ ); + })} +
+ +

+ Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben) +

+ {!hasDiscordWebhook && ( +

+ Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist. +

+ )} +
+ + {/* Daily Summary */} +
+
+
+

Tageszusammenfassung

+

+ Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht +

+
+ + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, enabled: v }, + })) + } + title="Tageszusammenfassung" + /> +
+ +
+ + +
+
+ E-Mail + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, email: v }, + })) + } + title="Tageszusammenfassung – E-Mail" + /> +
+
+ Discord + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, discord: v }, + })) + } + title={ + hasDiscordWebhook + ? 'Tageszusammenfassung – Discord' + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+
+
+ + ); + })()} + {/* Lead Time Selector */}
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC = ({
{/* Channel 1: E-Mail */} -
+
-
{/* Channel 2: Discord Webhook */} -
+
-
+ {(task.convertedType === 'document_block' || task.docId) ? ( ); }; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index e91efec..ef1d52d 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -18,16 +18,26 @@ import { CloudOff, AlertTriangle, GitMerge, + Undo2, + Redo2, + MoreHorizontal, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api } from '../../lib/api'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; +import { Presence } from '../../lib/docSync/transport'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; -import { PresenceAvatars } from './PresenceAvatars'; import { extractHashtags } from '../../lib/hashtags'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; +export interface DocHeaderStatus { + showLive: boolean; + channel: 'ws' | 'sse' | 'longpoll' | 'interval'; + otherPeople: Presence[]; + avatarsById: Map; +} + export interface DocumentEditorProps { docId: string; currentUser?: User | null; @@ -45,6 +55,7 @@ export interface DocumentEditorProps { onBackMobile?: () => void; onOpenTask?: (taskId: 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) */ onRequestMerge?: (doc: Document) => void; /** 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 = ({ onBackMobile, onOpenTask, onSaveStatusChange, + onHeaderStatusChange, onRequestMerge, onDocCreated, onDocRemoved, @@ -209,6 +221,8 @@ export const DocumentEditor: React.FC = ({ const [error, setError] = useState(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); + const [showActionsMenu, setShowActionsMenu] = useState(false); + const actionsMenuRef = useRef(null); // Sync engine (operation log): stores every change locally and syncs it with the server const engineRef = useRef(null); @@ -384,6 +398,48 @@ export const DocumentEditor: React.FC = ({ .catch(() => {}); }, [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 const [notice, setNotice] = useState(null); const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]); @@ -715,7 +771,7 @@ export const DocumentEditor: React.FC = ({ + + )} - {/* Who else has this note open */} - - {/* 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'; + {doc && !inTrash && ( + + )} - return ( -
- {isShared && ( - isOwner ? ( - - ) : ( - - ) +
+ + + {showActionsMenu && ( +
+ {!inTrash && ( + + )} + + {onRequestMerge && doc?.isOwner && !inTrash && ( + )} - {onRequestMerge && doc.isOwner && !inTrash && ( - + + {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( + <> +
+ + )}
- ); - })()} - - {!inTrash && } - - - - {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( - - )} + )} +
diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 08f29ef..03734fc 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -35,7 +35,7 @@ import { AudioBlock } from './AudioBlock'; import { DictationBlock } from './DictationBlock'; import { currentVoiceConfig } from '../../lib/voice'; 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 { renderInlineMarkdown } from '../../lib/markdown'; import { api } from '../../lib/api'; @@ -121,7 +121,6 @@ export const OutlinerBlock: React.FC = ({ docId, block, index, - isLast, hasChildren, isFocused, canEdit, @@ -160,13 +159,14 @@ export const OutlinerBlock: React.FC = ({ const [slashSearch, setSlashSearch] = useState(null); // Inline picker an empty bullet turns into after /image (local only, nothing synced until done) 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 previewUrl = previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet') - ? firstBareUrl(localContent) + ? linkCardUrl(localContent) : null; const [slashIndex, setSlashIndex] = useState(0); + const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]); const [showLinkModal, setShowLinkModal] = useState(false); const [showTodoMenu, setShowTodoMenu] = useState(false); @@ -362,7 +362,7 @@ export const OutlinerBlock: React.FC = ({ const q = slashSearch.toLowerCase(); const cmdQuery = q.startsWith('code ') ? 'code' : q; // 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( (c) => (!c.media || emptyBullet) && @@ -375,12 +375,18 @@ export const OutlinerBlock: React.FC = ({ ); }, [slashSearch, localContent, onExtractBlock]); + useEffect(() => { + if (slashSearch === null) return; + slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); + }, [slashIndex, slashSearch]); + const applySlashCommand = (cmd: SlashCommand) => { setSlashSearch(null); - const codeLangMatch = localContent.match(/\/code\s+([a-z0-9_+#.-]+)$/i); - const clean = localContent.replace(/\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); + const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i); + const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); if (cmd.type === 'link') { setLocalContent(clean); + localContentRef.current = clean; setShowLinkModal(true); return; } @@ -401,7 +407,7 @@ export const OutlinerBlock: React.FC = ({ } const langOverride = cmd.type === 'code' && codeLangMatch - ? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() + ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase() : undefined; setLocalContent(clean); localContentRef.current = clean; @@ -415,11 +421,22 @@ export const OutlinerBlock: React.FC = ({ const handleInsertLink = (linkMarkdown: string) => { 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 before = localContent.slice(0, cursor); const after = localContent.slice(cursor); const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); setLocalContent(newContent); + localContentRef.current = newContent; flushDebounce(newContent); }; @@ -475,9 +492,9 @@ export const OutlinerBlock: React.FC = ({ } // 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) { - setSlashSearch(slashMatch[1]); + setSlashSearch(slashMatch[2]); setSlashIndex(0); } else { setSlashSearch(null); @@ -600,30 +617,32 @@ export const OutlinerBlock: React.FC = ({ if (!canEdit) return; // Slash command navigation - if (slashSearch !== null && filteredSlashCommands.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); - return; - } - if (e.key === 'ArrowUp') { - e.preventDefault(); - setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); - return; - } - if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault(); - const selected = filteredSlashCommands[slashIndex]; - if (selected) { - applySlashCommand(selected); - } - return; - } + if (slashSearch !== null) { if (e.key === 'Escape') { e.preventDefault(); setSlashSearch(null); return; } + if (filteredSlashCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = filteredSlashCommands[slashIndex]; + if (selected) { + applySlashCommand(selected); + } + return; + } + } } // 1. Tab / Shift+Tab -> Strict Indent / Outdent @@ -637,7 +656,6 @@ export const OutlinerBlock: React.FC = ({ // 2. Enter (without Shift) -> If block is completely empty: // - 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 if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); @@ -666,10 +684,6 @@ export const OutlinerBlock: React.FC = ({ onIndentBlockRef.current(block.id, -1); return; } - if (isLast) { - // Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block - return; - } } const start = e.currentTarget.selectionStart ?? localContent.length; @@ -1258,6 +1272,9 @@ export const OutlinerBlock: React.FC = ({ {filteredSlashCommands.map((cmd, i) => (
- {/* 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) */}
{subView === 'editor' && selectedDocId ? ( - /* Save Status Pill */ -
- {docSaveStatus === 'saved' && ( - <> - - Gespeichert - + <> + {/* Live connection pill */} + {docHeaderStatus?.showLive && ( + + + {docHeaderStatus.channel === 'longpoll' + ? 'Live (Long-Polling)' + : docHeaderStatus.channel === 'interval' + ? 'Abgleich alle 3 s' + : 'Live'} + )} - {docSaveStatus === 'saving' && ( - <> - - Speichert… - + + {/* Who else has this note open */} + {docHeaderStatus && ( + )} - {docSaveStatus === 'unsaved' && ( - <> - - Ungespeichert - - )} - {docSaveStatus === 'error' && ( - <> - - Fehler - - )} -
+ + {/* 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 ? ( + + ) : ( + + ); + })()} + + {/* Save Status Pill */} +
+ {docSaveStatus === 'saved' && ( + <> + + Gespeichert + + )} + {docSaveStatus === 'saving' && ( + <> + + Speichert… + + )} + {docSaveStatus === 'unsaved' && ( + <> + + Ungespeichert + + )} + {docSaveStatus === 'error' && ( + <> + + Fehler + + )} +
+ ) : null}
@@ -1645,6 +1721,7 @@ export const WorkspaceView: React.FC = ({ setDocSaveStatus(status); setDocSaveError(err || null); }} + onHeaderStatusChange={setDocHeaderStatus} /> ) : subView === 'tags' ? ( /* ========================================================================= diff --git a/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx new file mode 100644 index 0000000..8466a4c --- /dev/null +++ b/frontend/src/features/workspace/__tests__/DocumentEditor.header.test.tsx @@ -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(); + return { + ...actual, + api: { + ...actual.api, + getDocument: vi.fn(), + getUsersDirectory: vi.fn(() => Promise.resolve([])), + getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })), + }, + }; +}); + +const makeDoc = (overrides: Partial = {}): 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( + + ); + + 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(); + }); +}); diff --git a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx index 9d0b468..c5b4d28 100644 --- a/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx +++ b/frontend/src/features/workspace/__tests__/OutlinerBlock.caret.test.tsx @@ -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( + {}} + 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( + {}} + 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( + 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)'); + }); +}); diff --git a/frontend/src/lib/__tests__/linkPreview.test.ts b/frontend/src/lib/__tests__/linkPreview.test.ts index b04208a..c266781 100644 --- a/frontend/src/lib/__tests__/linkPreview.test.ts +++ b/frontend/src/lib/__tests__/linkPreview.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { firstBareUrl, hidePreviewContent } from '../linkPreview'; +import { linkCardUrl, hidePreviewContent } from '../linkPreview'; describe('link preview helpers', () => { - it('finds the first plain https address', () => { - expect(firstBareUrl('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(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address - expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull(); - expect(firstBareUrl('http://unsicher.de')).toBeNull(); - expect(firstBareUrl('nur Text')).toBeNull(); + it('returns the URL only when the entire trimmed content is a single https address', () => { + expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1'); + expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/'); + expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull(); + expect(linkCardUrl('https://a.de https://b.de')).toBeNull(); + expect(linkCardUrl('siehe (https://x.de/p).')).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', () => { @@ -16,6 +18,6 @@ describe('link preview helpers', () => { '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(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(); }); }); diff --git a/frontend/src/lib/__tests__/useVoiceInput.test.tsx b/frontend/src/lib/__tests__/useVoiceInput.test.tsx index 76d9faa..615a000 100644 --- a/frontend/src/lib/__tests__/useVoiceInput.test.tsx +++ b/frontend/src/lib/__tests__/useVoiceInput.test.tsx @@ -130,4 +130,121 @@ describe('useVoiceInput', () => { await waitFor(() => expect(none.result.current.mode).toBe('unsupported')); 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((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'); + }); }); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d7a37ee..7c5b3fe 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -35,6 +35,7 @@ import { TagSummary, User, UserInvite, + NotificationSettings, VoiceAdminStatus, MergeResult, WSMessage, @@ -292,6 +293,7 @@ export const api = { discordWebhookUrl?: string; customWebhookUrl?: string; notifyBeforeMinutes?: number; + notificationSettings?: NotificationSettings; }): Promise { return request('/api/v1/auth/settings', { method: 'PATCH', @@ -410,6 +412,18 @@ export const api = { 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 }> { const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder'; return request<{ success: boolean }>(url, { diff --git a/frontend/src/lib/linkPreview.ts b/frontend/src/lib/linkPreview.ts index 64f9e15..acf3672 100644 --- a/frontend/src/lib/linkPreview.ts +++ b/frontend/src/lib/linkPreview.ts @@ -16,12 +16,12 @@ export interface LinkPreview { ok: boolean; } -// First plain https address in a text (not inside [text](url), not right after "(" or "<") -const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/; +// Entire bullet content (trimmed) is a single plain https address +const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/; -export function firstBareUrl(content: string): string | null { - const m = BARE_URL_RE.exec(content || ''); - return m ? m[2] : null; +export function linkCardUrl(content: string): string | null { + const trimmed = (content || '').trim(); + return CARD_URL_RE.test(trimmed) ? trimmed : null; } /** Replaces the address by a named link, which shows no preview. */ diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f67cbc4..7a428df 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -46,6 +46,32 @@ export interface VoiceAdminStatus { // Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md) 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 { id: string; email: string; @@ -59,6 +85,7 @@ export interface User { discordWebhookUrl?: string; customWebhookUrl?: string; notifyBeforeMinutes?: number; + notificationSettings?: NotificationSettings; organizationId?: string; createdAt: string; } @@ -113,6 +140,7 @@ export interface Thought { subtasks?: SubTask[]; assignees?: string[]; // user IDs, only people with access to the task list recurrence?: Recurrence; + isWatching?: boolean; alarmSent?: boolean; sortOrder?: number; createdAt: string; diff --git a/frontend/src/lib/useVoiceInput.ts b/frontend/src/lib/useVoiceInput.ts index b139a24..783d683 100644 --- a/frontend/src/lib/useVoiceInput.ts +++ b/frontend/src/lib/useVoiceInput.ts @@ -10,28 +10,54 @@ export interface VoiceInputOptions { onError?: (message: string) => void; /** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */ 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 * 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. */ -export function useVoiceInput({ onText, onError, continuous = false }: VoiceInputOptions) { +export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) { const [mode, setMode] = useState(null); const [state, setState] = useState('idle'); + const [pendingChunks, setPendingChunks] = useState(0); const maxSecondsRef = useRef(600); const recorderRef = useRef(null); const recognitionRef = useRef(null); const timerRef = useRef(null); + const segmentTimerRef = useRef(null); + const restartTimerRef = useRef(null); const cancelledRef = useRef(false); + const stoppedRef = useRef(false); const transcriptRef = useRef(''); + const baseTranscriptRef = useRef(''); + const lastRestartAtRef = useRef(0); + const chunkIndexRef = useRef(0); + const chunkResultsRef = useRef>(new Map()); + const inFlightChunksRef = useRef(0); const mountedRef = useRef(true); const onTextRef = useRef(onText); onTextRef.current = onText; const onErrorRef = useRef(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(() => { mountedRef.current = true; void loadVoiceConfig().then((cfg) => { @@ -41,7 +67,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu }); return () => { mountedRef.current = false; - if (timerRef.current !== null) window.clearTimeout(timerRef.current); + clearAllTimers(); recorderRef.current?.cancel(); try { recognitionRef.current?.abort?.(); @@ -54,17 +80,65 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu const setStateSafe = (s: VoiceState) => { if (mountedRef.current) setState(s); }; + const setPendingChunksSafe = (n: number) => { + if (mountedRef.current) setPendingChunks(n); + }; const fail = (message: string) => { setStateSafe('idle'); + setPendingChunksSafe(0); onErrorRef.current?.(message); }; - const clearTimer = () => { - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - timerRef.current = null; + + const joinedChunkText = () => + [...chunkResultsRef.current.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([, t]) => t.trim()) + .filter(Boolean) + .join(' '); + + const processSegmentPromise = (idx: number, blobPromise: Promise) => { + 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 () => { - clearTimer(); + stoppedRef.current = true; + clearAllTimers(); if (recognitionRef.current) { try { recognitionRef.current.stop(); @@ -74,8 +148,20 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu return; } const rec = recorderRef.current; - if (!rec) return; + if (!rec) { + if (live && state === 'listening') { + setStateSafe('idle'); + onTextRef.current(transcriptRef.current, true); + } + return; + } recorderRef.current = null; + if (live) { + const idx = chunkIndexRef.current++; + setStateSafe('transcribing'); + processSegmentPromise(idx, rec.stop()); + return; + } const blob = await rec.stop(); if (cancelledRef.current) return; if (!blob) { @@ -93,11 +179,15 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu } // fail only touches refs and state setters // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [live, state]); const startServer = async () => { const rec = new VoiceRecorder(); recorderRef.current = rec; + chunkIndexRef.current = 0; + chunkResultsRef.current = new Map(); + inFlightChunksRef.current = 0; + setPendingChunksSafe(0); setStateSafe('listening'); try { await rec.start(); @@ -111,6 +201,104 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu return; } 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 = () => { @@ -119,55 +307,31 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu fail('Spracheingabe wird von diesem Browser nicht unterstützt'); return; } - const recognition = new Recognition(); - recognition.lang = 'de-DE'; - recognition.interimResults = true; - 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'); - } + baseTranscriptRef.current = ''; + lastRestartAtRef.current = 0; + spawnBrowserRecognition(Recognition, false); }; const start = useCallback(async () => { if (recorderRef.current || recognitionRef.current) return; cancelledRef.current = false; + stoppedRef.current = false; transcriptRef.current = ''; + baseTranscriptRef.current = ''; + lastRestartAtRef.current = 0; const current = mode ?? currentVoiceConfig().mode; if (current === 'server') await startServer(); else if (current === 'browser') startBrowser(); else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt'); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mode, continuous]); + }, [mode, continuous, live]); const cancel = useCallback(() => { cancelledRef.current = true; - clearTimer(); + stoppedRef.current = true; + inFlightChunksRef.current = 0; + setPendingChunksSafe(0); + clearAllTimers(); recorderRef.current?.cancel(); recorderRef.current = null; try { @@ -191,6 +355,7 @@ export function useVoiceInput({ onText, onError, continuous = false }: VoiceInpu mode, available: mode === 'server' || mode === 'browser', state, + pendingChunks, start, stop, cancel, diff --git a/frontend/src/lib/voice.ts b/frontend/src/lib/voice.ts index 9c0bfbe..8e447f4 100644 --- a/frontend/src/lib/voice.ts +++ b/frontend/src/lib/voice.ts @@ -86,19 +86,7 @@ export class VoiceRecorder { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); - const type = recordingType(); - 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); + this.startSegmentRecorder(); try { const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext; 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. */ level(): number | 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 { + 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 { this.done = null; if (this.recorder && this.recorder.state !== 'inactive') {