refactor(collab): remove live document collaboration and enforce strictly read-only sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
aea05e868a
commit
04fa774d10
16 files changed
+179
-3556
No files matched your search
@@ -46,7 +46,9 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
|
||||||
|
allowRoleSelection ? 'editor' : 'viewer'
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -63,7 +65,8 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onAddShare(shareType, targetId, selectedPermission);
|
const permToUse = allowRoleSelection ? selectedPermission : 'viewer';
|
||||||
|
await onAddShare(shareType, targetId, permToUse);
|
||||||
setSelectedTeamId('');
|
setSelectedTeamId('');
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -196,7 +199,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{allowRoleSelection && (
|
{allowRoleSelection ? (
|
||||||
<select
|
<select
|
||||||
value={selectedPermission}
|
value={selectedPermission}
|
||||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||||
@@ -206,6 +209,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
</select>
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||||
|
<Lock className="w-3 h-3 text-faint" />
|
||||||
|
Nur Lesen
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -220,7 +228,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{allowRoleSelection && (
|
{allowRoleSelection ? (
|
||||||
<select
|
<select
|
||||||
value={selectedPermission}
|
value={selectedPermission}
|
||||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||||
@@ -230,6 +238,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
</select>
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||||
|
<Lock className="w-3 h-3 text-faint" />
|
||||||
|
Nur Lesen
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -17,10 +17,8 @@ import {
|
|||||||
Maximize2,
|
Maximize2,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
FileText,
|
FileText,
|
||||||
Lock,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
|
||||||
import { LinkModal } from './LinkModal';
|
import { LinkModal } from './LinkModal';
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
@@ -48,11 +46,8 @@ export interface OutlinerBlockProps {
|
|||||||
hasChildren: boolean;
|
hasChildren: boolean;
|
||||||
isFocused: boolean;
|
isFocused: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
activeRemoteCursors?: RemoteCursor[];
|
|
||||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
|
||||||
levelOffset?: number;
|
levelOffset?: number;
|
||||||
onZoomBlock?: (blockId: string) => void;
|
onZoomBlock?: (blockId: string) => void;
|
||||||
onFocusBlock: (blockId: string) => void;
|
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string) => void;
|
||||||
@@ -66,23 +61,17 @@ export interface OutlinerBlockProps {
|
|||||||
isDragTarget?: boolean;
|
isDragTarget?: boolean;
|
||||||
isTargeted?: boolean;
|
isTargeted?: boolean;
|
||||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||||
isLockedByOther?: boolean;
|
|
||||||
lockedByName?: string;
|
|
||||||
lockedByColor?: string;
|
|
||||||
onBlurBlock?: (blockId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||||
block,
|
block,
|
||||||
index,
|
index,
|
||||||
|
maxAllowedLevel,
|
||||||
hasChildren,
|
hasChildren,
|
||||||
isFocused,
|
isFocused,
|
||||||
canEdit,
|
canEdit,
|
||||||
activeRemoteCursors = [],
|
|
||||||
sendCursorMoved,
|
|
||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
onZoomBlock,
|
onZoomBlock,
|
||||||
onFocusBlock,
|
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onCreateBlockBelow,
|
onCreateBlockBelow,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
@@ -96,10 +85,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isDragTarget,
|
isDragTarget,
|
||||||
isTargeted,
|
isTargeted,
|
||||||
registerTextareaRef,
|
registerTextareaRef,
|
||||||
isLockedByOther,
|
|
||||||
lockedByName,
|
|
||||||
lockedByColor,
|
|
||||||
onBlurBlock,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||||
@@ -116,10 +101,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
|
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
|
||||||
const onUpdateBlockRef = useRef(onUpdateBlock);
|
const onUpdateBlockRef = useRef(onUpdateBlock);
|
||||||
onUpdateBlockRef.current = onUpdateBlock;
|
onUpdateBlockRef.current = onUpdateBlock;
|
||||||
const onFocusBlockRef = useRef(onFocusBlock);
|
|
||||||
onFocusBlockRef.current = onFocusBlock;
|
|
||||||
const onBlurBlockRef = useRef(onBlurBlock);
|
|
||||||
onBlurBlockRef.current = onBlurBlock;
|
|
||||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||||
@@ -128,8 +109,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onIndentBlockRef.current = onIndentBlock;
|
onIndentBlockRef.current = onIndentBlock;
|
||||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||||
onNavigateFocusRef.current = onNavigateFocus;
|
onNavigateFocusRef.current = onNavigateFocus;
|
||||||
const sendCursorMovedRef = useRef(sendCursorMoved);
|
|
||||||
sendCursorMovedRef.current = sendCursorMoved;
|
|
||||||
|
|
||||||
// Track if this input currently has actual focus in an active browser window
|
// Track if this input currently has actual focus in an active browser window
|
||||||
const isFocusedRef = useRef<boolean>(false);
|
const isFocusedRef = useRef<boolean>(false);
|
||||||
@@ -177,7 +156,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
flushDebounce(localContentRef.current);
|
flushDebounce(localContentRef.current);
|
||||||
}
|
}
|
||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
sendCursorMovedRef.current?.(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWindowFocus = () => {
|
const handleWindowFocus = () => {
|
||||||
@@ -290,7 +268,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLocalContent(val);
|
setLocalContent(val);
|
||||||
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
|
||||||
|
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
@@ -313,16 +290,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
const handleFocus = () => {
|
||||||
if (isLockedByOther) return;
|
|
||||||
isFocusedRef.current = true;
|
isFocusedRef.current = true;
|
||||||
onFocusBlockRef.current(block.id);
|
|
||||||
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
if (isLockedByOther) return;
|
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
@@ -330,27 +303,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||||
flushDebounce(localContentRef.current);
|
flushDebounce(localContentRef.current);
|
||||||
}
|
}
|
||||||
sendCursorMovedRef.current?.(null);
|
|
||||||
onBlurBlockRef.current?.(block.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
|
||||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
|
||||||
const target = e.currentTarget;
|
|
||||||
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (!canEdit || isLockedByOther) return;
|
if (!canEdit) return;
|
||||||
|
|
||||||
// Slash command navigation
|
// Slash command navigation
|
||||||
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
||||||
@@ -460,50 +416,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
? 'border-t-2 border-accent bg-accent/10'
|
? 'border-t-2 border-accent bg-accent/10'
|
||||||
: isTargeted
|
: isTargeted
|
||||||
? 'bg-accent/15 ring-2 ring-accent'
|
? 'bg-accent/15 ring-2 ring-accent'
|
||||||
: isLockedByOther
|
|
||||||
? 'bg-panel-elevated/80 border border-border/60 shadow-xs'
|
|
||||||
: isFocused
|
: isFocused
|
||||||
? 'bg-panel-elevated'
|
? 'bg-panel-elevated'
|
||||||
: 'hover:bg-panel'
|
: 'hover:bg-panel'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||||
...(isLockedByOther && lockedByColor
|
|
||||||
? { borderLeft: `3px solid ${lockedByColor}` }
|
|
||||||
: primaryRemoteCursor
|
|
||||||
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
|
||||||
: {}),
|
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||||
onDrop={() => onDropBlock?.(index)}
|
onDrop={() => onDropBlock?.(index)}
|
||||||
>
|
>
|
||||||
{/* Right-aligned Lock Indicator (Zero vertical height impact, keeps bullet & line aligned) */}
|
|
||||||
{isLockedByOther && (
|
|
||||||
<div
|
|
||||||
className="absolute right-2 top-1.5 z-10 flex items-center gap-1 px-2 py-0.5 rounded-pill text-[10px] font-semibold text-white shadow-xs select-none pointer-events-none"
|
|
||||||
style={{ backgroundColor: lockedByColor || '#64748b' }}
|
|
||||||
title={`Wird gerade bearbeitet von ${lockedByName || 'anderer Nutzer'}`}
|
|
||||||
>
|
|
||||||
<Lock className="w-2.5 h-2.5 shrink-0" />
|
|
||||||
<span className="truncate max-w-[120px]">{lockedByName || 'Gesperrt'}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Remote Cursors Floating Name Badges on Block */}
|
|
||||||
{activeRemoteCursors.length > 0 && (
|
|
||||||
<div className="absolute -top-2.5 right-2 z-20 flex items-center gap-1 select-none pointer-events-none">
|
|
||||||
{activeRemoteCursors.map((c) => (
|
|
||||||
<span
|
|
||||||
key={c.clientId}
|
|
||||||
className="text-[9px] font-bold px-1.5 py-0.5 rounded-btn text-white shadow-sm flex items-center gap-0.5"
|
|
||||||
style={{ backgroundColor: c.userColor }}
|
|
||||||
>
|
|
||||||
{c.userName}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Indentation Guide Line */}
|
{/* Indentation Guide Line */}
|
||||||
{visualLevel > 0 &&
|
{visualLevel > 0 &&
|
||||||
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
||||||
@@ -516,7 +438,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
||||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||||
{canEdit && !isLockedByOther && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable
|
draggable
|
||||||
@@ -550,7 +472,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
{block.type === 'todo' ? (
|
{block.type === 'todo' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canEdit || isLockedByOther}
|
disabled={!canEdit}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
flushDebounce(localContent, { checked: !block.checked })
|
flushDebounce(localContent, { checked: !block.checked })
|
||||||
}
|
}
|
||||||
@@ -608,17 +530,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit || isLockedByOther}
|
readOnly={!canEdit}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={`w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed ${
|
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||||
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : block.type === 'callout' ? (
|
) : block.type === 'callout' ? (
|
||||||
@@ -630,17 +547,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit || isLockedByOther}
|
readOnly={!canEdit}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={`w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic ${
|
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||||
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -652,12 +564,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={localContent}
|
value={localContent}
|
||||||
readOnly={!canEdit || isLockedByOther}
|
readOnly={!canEdit}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
@@ -666,8 +575,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
||||||
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
|
|
||||||
} ${
|
|
||||||
block.type === 'h1'
|
block.type === 'h1'
|
||||||
? 'text-lg font-bold text-main tracking-tight'
|
? 'text-lg font-bold text-main tracking-tight'
|
||||||
: block.type === 'h2'
|
: block.type === 'h2'
|
||||||
@@ -679,45 +586,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
: 'text-sm text-main'
|
: 'text-sm text-main'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Remote Caret & Floating Name Tag Overlay */}
|
|
||||||
{primaryRemoteCursor &&
|
|
||||||
primaryRemoteCursor.selectionStart !== undefined &&
|
|
||||||
primaryRemoteCursor.selectionStart !== null && (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`pointer-events-none absolute inset-0 overflow-hidden leading-relaxed whitespace-pre-wrap break-words select-none z-10 ${
|
|
||||||
block.type === 'h1'
|
|
||||||
? 'text-lg font-bold tracking-tight'
|
|
||||||
: block.type === 'h2'
|
|
||||||
? 'text-base font-semibold'
|
|
||||||
: block.type === 'h3'
|
|
||||||
? 'text-sm font-semibold'
|
|
||||||
: block.type === 'todo' && block.checked
|
|
||||||
? 'text-sm text-faint line-through'
|
|
||||||
: 'text-sm'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="opacity-0">
|
|
||||||
{localContent.slice(
|
|
||||||
0,
|
|
||||||
Math.min(primaryRemoteCursor.selectionStart, localContent.length)
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="relative inline-block align-baseline">
|
|
||||||
<span
|
|
||||||
className="inline-block w-[2px] h-[1.15em] -mb-0.5 animate-pulse"
|
|
||||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="absolute -top-3.5 left-0 text-[8px] font-bold px-1 py-0 rounded-btn text-white whitespace-nowrap shadow-sm"
|
|
||||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
|
||||||
>
|
|
||||||
{primaryRemoteCursor.userName}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { DocumentBlock, BlockLockInfo } from '../../lib/types';
|
import { DocumentBlock } from '../../lib/types';
|
||||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock } from './OutlinerBlock';
|
||||||
|
|
||||||
export interface OutlinerTreeProps {
|
export interface OutlinerTreeProps {
|
||||||
@@ -10,8 +9,6 @@ export interface OutlinerTreeProps {
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
levelOffset?: number;
|
levelOffset?: number;
|
||||||
onZoomBlock?: (blockId: string) => void;
|
onZoomBlock?: (blockId: string) => void;
|
||||||
remoteCursors?: Record<string, RemoteCursor>;
|
|
||||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||||
@@ -20,10 +17,6 @@ export interface OutlinerTreeProps {
|
|||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
initialFocusedBlockId?: string | null;
|
initialFocusedBlockId?: string | null;
|
||||||
activeBlockLocks?: Record<string, BlockLockInfo>;
|
|
||||||
onFocusBlock?: (blockId: string) => void;
|
|
||||||
onBlurBlock?: (blockId: string) => void;
|
|
||||||
currentUserId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||||
@@ -32,8 +25,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
canEdit,
|
canEdit,
|
||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
onZoomBlock,
|
onZoomBlock,
|
||||||
remoteCursors,
|
|
||||||
sendCursorMoved,
|
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onUpdateBlocks,
|
onUpdateBlocks,
|
||||||
onCreateBlock,
|
onCreateBlock,
|
||||||
@@ -42,10 +33,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
activeBlockLocks,
|
|
||||||
onFocusBlock,
|
|
||||||
onBlurBlock,
|
|
||||||
currentUserId,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||||
@@ -371,24 +358,9 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
return () => window.removeEventListener('dragend', clearDrag);
|
return () => window.removeEventListener('dragend', clearDrag);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cursorsByBlock = useMemo(() => {
|
|
||||||
const map: Record<string, RemoteCursor[]> = {};
|
|
||||||
if (!remoteCursors) return map;
|
|
||||||
for (const c of Object.values(remoteCursors)) {
|
|
||||||
if (c.blockId) {
|
|
||||||
if (!map[c.blockId]) map[c.blockId] = [];
|
|
||||||
map[c.blockId].push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [remoteCursors]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => {
|
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => {
|
||||||
const lockInfo = activeBlockLocks?.[block.id];
|
|
||||||
const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OutlinerBlock
|
<OutlinerBlock
|
||||||
key={block.id}
|
key={block.id}
|
||||||
@@ -400,15 +372,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
levelOffset={levelOffset}
|
levelOffset={levelOffset}
|
||||||
onZoomBlock={onZoomBlock}
|
onZoomBlock={onZoomBlock}
|
||||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
|
||||||
sendCursorMoved={sendCursorMoved}
|
|
||||||
onFocusBlock={(bId) => {
|
|
||||||
setFocusedBlockId(bId);
|
|
||||||
onFocusBlock?.(bId);
|
|
||||||
}}
|
|
||||||
onBlurBlock={(bId) => {
|
|
||||||
onBlurBlock?.(bId);
|
|
||||||
}}
|
|
||||||
onUpdateBlock={onUpdateBlock}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
@@ -422,9 +385,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||||
isTargeted={highlightedBlockId === block.id}
|
isTargeted={highlightedBlockId === block.id}
|
||||||
registerTextareaRef={registerTextareaRef}
|
registerTextareaRef={registerTextareaRef}
|
||||||
isLockedByOther={isLockedByOther}
|
|
||||||
lockedByName={lockInfo?.userName}
|
|
||||||
lockedByColor={lockInfo?.userColor}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -48,10 +48,9 @@ import {
|
|||||||
getRealtimeTransport,
|
getRealtimeTransport,
|
||||||
RealtimeTransport,
|
RealtimeTransport,
|
||||||
} from '../../lib/api';
|
} from '../../lib/api';
|
||||||
import { DocSyncStatus } from '../../lib/useDocumentSync';
|
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor';
|
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -328,13 +327,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
// Realtime connection status for top header indicator (identical to SharedListsView)
|
// Realtime connection status for top header indicator (identical to SharedListsView)
|
||||||
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
||||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
||||||
const [activeDocSyncStatus, setActiveDocSyncStatus] = useState<DocSyncStatus>('connected');
|
|
||||||
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 [docLiveState, setDocLiveState] = useState<DocLiveState>('idle');
|
|
||||||
const [docLiveInitiator, setDocLiveInitiator] = useState<string | null>(null);
|
|
||||||
const [showWsBlockedModal, setShowWsBlockedModal] = useState<boolean>(false);
|
|
||||||
const liveToggleRef = useRef<(() => void) | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = onRealtimeStatus((connected, transport) => {
|
const unsub = onRealtimeStatus((connected, transport) => {
|
||||||
@@ -1168,136 +1162,52 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{subView === 'editor' && selectedDocId ? (
|
{subView === 'editor' && selectedDocId ? (
|
||||||
<>
|
/* Save Status Pill */
|
||||||
{/* 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'
|
? 'bg-canvas border-border text-muted'
|
||||||
? 'bg-canvas border-border text-muted'
|
: docSaveStatus === 'saving'
|
||||||
: docSaveStatus === 'saving'
|
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
||||||
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
: docSaveStatus === 'unsaved'
|
||||||
: docSaveStatus === 'unsaved'
|
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
||||||
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
||||||
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
}`}
|
||||||
}`}
|
title={
|
||||||
title={
|
docSaveStatus === 'saved'
|
||||||
docSaveStatus === 'saved'
|
? 'Alle Änderungen per HTTPS gesichert'
|
||||||
? 'Alle Änderungen per HTTPS gesichert'
|
: docSaveStatus === 'saving'
|
||||||
: docSaveStatus === 'saving'
|
? 'Änderungen werden per HTTPS gespeichert…'
|
||||||
? 'Änderungen werden per HTTPS gespeichert…'
|
: docSaveStatus === 'unsaved'
|
||||||
: docSaveStatus === 'unsaved'
|
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
|
||||||
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
|
: docSaveError || 'Fehler beim Speichern'
|
||||||
: docSaveError || 'Fehler beim Speichern'
|
}
|
||||||
}
|
>
|
||||||
>
|
{docSaveStatus === 'saved' && (
|
||||||
{docSaveStatus === 'saved' && (
|
<>
|
||||||
<>
|
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
<span>Gespeichert</span>
|
||||||
<span>Gespeichert</span>
|
</>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docSaveStatus === 'saving' && (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
|
||||||
<span>Speichert…</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docSaveStatus === 'unsaved' && (
|
|
||||||
<>
|
|
||||||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
|
||||||
<span>Ungespeichert</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docSaveStatus === 'error' && (
|
|
||||||
<>
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
|
||||||
<span>Fehler</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
|
|
||||||
{docLiveState === 'locked_by_other' ? (
|
|
||||||
<div className="inline-flex items-center gap-1.5">
|
|
||||||
<div
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
|
|
||||||
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
|
|
||||||
>
|
|
||||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
|
||||||
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
|
|
||||||
</div>
|
|
||||||
{(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => liveToggleRef.current?.()}
|
|
||||||
className="px-2 py-1 rounded-btn text-xs font-semibold bg-status-warn/20 border border-status-warn/40 text-status-warn hover:bg-status-warn/30 transition-colors cursor-pointer"
|
|
||||||
title="Als Notiz-Besitzer Live-Sitzung beenden und Dokument freigeben"
|
|
||||||
>
|
|
||||||
Freigeben
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : docLiveState === 'blocked' ? (
|
|
||||||
<div className="inline-flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowWsBlockedModal(true)}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
|
|
||||||
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
|
|
||||||
<span>Live blockiert</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => liveToggleRef.current?.()}
|
|
||||||
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
|
|
||||||
title="Live-Sitzung beenden"
|
|
||||||
>
|
|
||||||
Beenden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : docLiveState === 'connecting' ? (
|
|
||||||
<div className="inline-flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
|
|
||||||
>
|
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
|
|
||||||
<span>Verbinde…</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => liveToggleRef.current?.()}
|
|
||||||
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
|
|
||||||
title="Verbindungsaufbau abbrechen"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : docLiveState === 'active' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => liveToggleRef.current?.()}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-semibold bg-emerald-500/15 border border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/25 transition-colors cursor-pointer"
|
|
||||||
title="Live-Zusammenarbeit beenden (Tastendruck / WebSocket)"
|
|
||||||
>
|
|
||||||
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse shrink-0" />
|
|
||||||
<span>Live aktiv</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => liveToggleRef.current?.()}
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-main hover:border-accent transition-colors cursor-pointer"
|
|
||||||
title="Live-Zusammenarbeit aktivieren (WebSockets)"
|
|
||||||
>
|
|
||||||
<Radio className="w-3.5 h-3.5 text-muted hover:text-accent shrink-0" />
|
|
||||||
<span>Live</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</>
|
{docSaveStatus === 'saving' && (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||||
|
<span>Speichert…</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docSaveStatus === 'unsaved' && (
|
||||||
|
<>
|
||||||
|
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||||
|
<span>Ungespeichert</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{docSaveStatus === 'error' && (
|
||||||
|
<>
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||||
|
<span>Fehler</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Standalone Realtime Status Indicator for Todos / Hashtags */
|
/* Standalone Realtime Status Indicator for Todos / Hashtags */
|
||||||
(() => {
|
(() => {
|
||||||
@@ -1568,18 +1478,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onSelectTag={handleSelectTag}
|
onSelectTag={handleSelectTag}
|
||||||
onWikiLinkClick={handleWikiLinkClick}
|
onWikiLinkClick={handleWikiLinkClick}
|
||||||
onBackMobile={() => setMobileShowDetail(false)}
|
onBackMobile={() => setMobileShowDetail(false)}
|
||||||
onSyncStatusChange={setActiveDocSyncStatus}
|
|
||||||
onSaveStatusChange={(status, err) => {
|
onSaveStatusChange={(status, err) => {
|
||||||
setDocSaveStatus(status);
|
setDocSaveStatus(status);
|
||||||
setDocSaveError(err || null);
|
setDocSaveError(err || null);
|
||||||
}}
|
}}
|
||||||
onLiveStateChange={(state, initiator) => {
|
|
||||||
setDocLiveState(state);
|
|
||||||
setDocLiveInitiator(initiator || null);
|
|
||||||
}}
|
|
||||||
registerLiveToggle={(handler) => {
|
|
||||||
liveToggleRef.current = handler;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : subView === 'tags' ? (
|
) : subView === 'tags' ? (
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
@@ -2535,14 +2437,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
shares={unifiedShares}
|
shares={unifiedShares}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
collaborators={collaborators}
|
collaborators={collaborators}
|
||||||
|
allowRoleSelection={!shareModalDoc}
|
||||||
onAddShare={async (targetType, targetId, permission) => {
|
onAddShare={async (targetType, targetId, permission) => {
|
||||||
const perm = permission === 'editor' ? 'write' : 'read';
|
const perm = (!shareModalDoc && permission === 'editor') ? 'write' : 'read';
|
||||||
if (shareModalDoc) {
|
if (shareModalDoc) {
|
||||||
await api.createDocShare(
|
await api.createDocShare(
|
||||||
shareModalDoc.id,
|
shareModalDoc.id,
|
||||||
targetType === 'team'
|
targetType === 'team'
|
||||||
? { teamId: targetId, permission: perm }
|
? { teamId: targetId, permission: 'read' }
|
||||||
: { userId: targetId, permission: perm }
|
: { userId: targetId, permission: 'read' }
|
||||||
);
|
);
|
||||||
const updatedShares = await api.getDocShares(shareModalDoc.id);
|
const updatedShares = await api.getDocShares(shareModalDoc.id);
|
||||||
setActiveDocShares(updatedShares);
|
setActiveDocShares(updatedShares);
|
||||||
@@ -2569,60 +2472,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* WebSocket Blocked Modal */}
|
|
||||||
{showWsBlockedModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
|
|
||||||
<div className="w-full max-w-md bg-canvas border border-border rounded-card shadow-modal p-6 space-y-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-status-err/15 flex items-center justify-center shrink-0">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-status-err" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-sm font-bold text-main">
|
|
||||||
WebSockets in diesem Netzwerk blockiert
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-muted mt-1 leading-relaxed">
|
|
||||||
Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (<code className="text-accent bg-panel px-1 py-0.5 rounded">ws://</code> / <code className="text-accent bg-panel px-1 py-0.5 rounded">wss://</code>).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-surface-subtle border border-border rounded-btn p-3 text-xs space-y-2 text-main">
|
|
||||||
<div className="flex items-center gap-2 text-status-ok font-semibold">
|
|
||||||
<Check className="w-4 h-4 shrink-0" />
|
|
||||||
<span>Kein Datenverlust: Auto-Save per HTTPS ist aktiv!</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted leading-relaxed">
|
|
||||||
Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert.
|
|
||||||
</p>
|
|
||||||
<p className="text-muted text-[11px] leading-relaxed">
|
|
||||||
Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
liveToggleRef.current?.();
|
|
||||||
setShowWsBlockedModal(false);
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 rounded-btn transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Live-Sitzung beenden
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowWsBlockedModal(false)}
|
|
||||||
className="btn-action px-4 py-1.5 text-xs font-semibold bg-accent text-on-accent hover:opacity-90 transition-opacity cursor-pointer"
|
|
||||||
>
|
|
||||||
Verstanden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import {
|
|||||||
Document,
|
Document,
|
||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
DocShare,
|
DocShare,
|
||||||
DocLockInfo,
|
|
||||||
BlockLockInfo,
|
|
||||||
LiveSessionInfo,
|
|
||||||
DocLocksResponse,
|
|
||||||
KanbanColumn,
|
KanbanColumn,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListShare,
|
ListShare,
|
||||||
@@ -641,46 +637,6 @@ export const api = {
|
|||||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getDocLocks(docId: string): Promise<DocLocksResponse> {
|
|
||||||
const res = await request<DocLocksResponse>(`/api/v1/documents/${docId}/locks`);
|
|
||||||
return {
|
|
||||||
liveSession: res.liveSession || null,
|
|
||||||
blockLocks: ensureArray(res.blockLocks),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise<BlockLockInfo> {
|
|
||||||
console.log(`[MindSift Lock] Lock anfordern für Block "${blockId}" in Notiz "${docId}"...`);
|
|
||||||
const res = await request<any>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ userColor, ttlSeconds, ttl: ttlSeconds }),
|
|
||||||
});
|
|
||||||
console.log(`[MindSift Lock] Lock erfolgreich erhalten für "${blockId}":`, res?.lock || res);
|
|
||||||
return res?.lock || res;
|
|
||||||
},
|
|
||||||
|
|
||||||
async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> {
|
|
||||||
console.log(`[MindSift Lock] Lock freigeben für Block "${blockId}" in Notiz "${docId}"...`);
|
|
||||||
const res = await request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
console.log(`[MindSift Lock] Lock freigegeben für "${blockId}"`);
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
|
|
||||||
async startLiveSession(docId: string): Promise<LiveSessionInfo> {
|
|
||||||
const res = await request<any>(`/api/v1/documents/${docId}/live/start`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
return res?.session || res;
|
|
||||||
},
|
|
||||||
|
|
||||||
async endLiveSession(docId: string): Promise<{ status: string }> {
|
|
||||||
return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
||||||
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -200,35 +200,6 @@ export interface DocShare {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocLockInfo {
|
|
||||||
docId: string;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlockLockInfo {
|
|
||||||
docId: string;
|
|
||||||
blockId: string;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
userColor?: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LiveSessionInfo {
|
|
||||||
docId: string;
|
|
||||||
initiatorId: string;
|
|
||||||
initiatorName: string;
|
|
||||||
startedAt: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocLocksResponse {
|
|
||||||
liveSession?: LiveSessionInfo | null;
|
|
||||||
blockLocks: BlockLockInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@@ -1,507 +0,0 @@
|
|||||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|
||||||
import { DocumentBlock } from './types';
|
|
||||||
import { getToken, api } from './api';
|
|
||||||
|
|
||||||
export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked';
|
|
||||||
|
|
||||||
export interface RemoteCursor {
|
|
||||||
clientId: string;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
userColor: string;
|
|
||||||
blockId: string | null;
|
|
||||||
selectionStart?: number;
|
|
||||||
selectionEnd?: number;
|
|
||||||
updatedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CURSOR_COLORS = [
|
|
||||||
'#3b82f6', // blue
|
|
||||||
'#10b981', // emerald
|
|
||||||
'#f59e0b', // amber
|
|
||||||
'#ec4899', // pink
|
|
||||||
'#8b5cf6', // purple
|
|
||||||
'#06b6d4', // cyan
|
|
||||||
'#f97316', // orange
|
|
||||||
'#14b8a6', // teal
|
|
||||||
'#6366f1', // indigo
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getUserColor(idOrName: string): string {
|
|
||||||
if (!idOrName) return CURSOR_COLORS[0];
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < idOrName.length; i++) {
|
|
||||||
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocSyncEvent {
|
|
||||||
type:
|
|
||||||
| 'doc_state'
|
|
||||||
| 'block_created'
|
|
||||||
| 'block_updated'
|
|
||||||
| 'block_deleted'
|
|
||||||
| 'block_moved'
|
|
||||||
| 'title_updated'
|
|
||||||
| 'blocks_replaced'
|
|
||||||
| 'cursor_moved'
|
|
||||||
| 'client_left';
|
|
||||||
docId?: string;
|
|
||||||
clientId?: string;
|
|
||||||
userId?: string;
|
|
||||||
userName?: string;
|
|
||||||
userColor?: string;
|
|
||||||
selectionStart?: number;
|
|
||||||
selectionEnd?: number;
|
|
||||||
activeCursors?: RemoteCursor[];
|
|
||||||
title?: string;
|
|
||||||
block?: DocumentBlock;
|
|
||||||
blockId?: string;
|
|
||||||
afterBlockId?: string;
|
|
||||||
newIndex?: number;
|
|
||||||
content?: string;
|
|
||||||
level?: number;
|
|
||||||
checked?: boolean;
|
|
||||||
collapsed?: boolean;
|
|
||||||
blockType?: string;
|
|
||||||
blocks?: DocumentBlock[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDocumentSync(
|
|
||||||
docId: string | null,
|
|
||||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
|
||||||
canEdit: boolean = true,
|
|
||||||
currentUser?: { id: string; email?: string; displayName?: string } | null,
|
|
||||||
enabled: boolean = false
|
|
||||||
) {
|
|
||||||
const [status, setStatus] = useState<DocSyncStatus>(enabled ? 'connecting' : 'idle');
|
|
||||||
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
|
||||||
const clientIdRef = useRef<string>(
|
|
||||||
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
|
||||||
);
|
|
||||||
const onRemoteEventRef = useRef(onRemoteEvent);
|
|
||||||
onRemoteEventRef.current = onRemoteEvent;
|
|
||||||
|
|
||||||
const docIdRef = useRef(docId);
|
|
||||||
docIdRef.current = docId;
|
|
||||||
const canEditRef = useRef(canEdit);
|
|
||||||
canEditRef.current = canEdit;
|
|
||||||
const currentUserRef = useRef(currentUser);
|
|
||||||
currentUserRef.current = currentUser;
|
|
||||||
|
|
||||||
const reconnectTimerRef = useRef<number | null>(null);
|
|
||||||
const fallbackTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const localUserName =
|
|
||||||
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
|
|
||||||
const localUserColor = useMemo(
|
|
||||||
() => getUserColor(currentUser?.id || clientIdRef.current),
|
|
||||||
[currentUser?.id]
|
|
||||||
);
|
|
||||||
const localUserNameRef = useRef(localUserName);
|
|
||||||
localUserNameRef.current = localUserName;
|
|
||||||
const localUserColorRef = useRef(localUserColor);
|
|
||||||
localUserColorRef.current = localUserColor;
|
|
||||||
|
|
||||||
const pendingMessagesQueueRef = useRef<string[]>([]);
|
|
||||||
|
|
||||||
const lastCursorSendRef = useRef<number>(0);
|
|
||||||
const pendingCursorTimerRef = useRef<number | null>(null);
|
|
||||||
const pendingCursorPayloadRef = useRef<{
|
|
||||||
blockId: string | null;
|
|
||||||
selectionStart?: number;
|
|
||||||
selectionEnd?: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Clean up stale remote cursors (> 30s)
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
setRemoteCursors((prev) => {
|
|
||||||
let hasStale = false;
|
|
||||||
const next: Record<string, RemoteCursor> = {};
|
|
||||||
for (const [key, cursor] of Object.entries(prev)) {
|
|
||||||
if (now - cursor.updatedAt < 30000) {
|
|
||||||
next[key] = cursor;
|
|
||||||
} else {
|
|
||||||
hasStale = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasStale ? next : prev;
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!docId || !enabled) {
|
|
||||||
setStatus('idle');
|
|
||||||
setRemoteCursors({});
|
|
||||||
pendingMessagesQueueRef.current = [];
|
|
||||||
if (wsRef.current) {
|
|
||||||
try {
|
|
||||||
wsRef.current.close();
|
|
||||||
} catch {}
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingMessagesQueueRef.current = [];
|
|
||||||
let isDisposed = false;
|
|
||||||
let retryCount = 0;
|
|
||||||
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
|
||||||
clientIdRef.current = currentClientId;
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
if (isDisposed) return;
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true') {
|
|
||||||
console.info('[DocSync] WebSockets im Firmennetzwerk blockiert (Cache). Live-Transport übersprungen.');
|
|
||||||
setStatus('blocked');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = getToken();
|
|
||||||
if (!token) {
|
|
||||||
setStatus('offline');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
|
||||||
docId
|
|
||||||
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
|
|
||||||
currentClientId
|
|
||||||
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
|
|
||||||
localUserColor
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
|
||||||
|
|
||||||
let ws: WebSocket;
|
|
||||||
try {
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
} catch {
|
|
||||||
setStatus('blocked');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
const handshakeTimer = window.setTimeout(() => {
|
|
||||||
if (ws.readyState !== WebSocket.OPEN) {
|
|
||||||
console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).');
|
|
||||||
setStatus('blocked');
|
|
||||||
try {
|
|
||||||
ws.close();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}, 4500);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
window.clearTimeout(handshakeTimer);
|
|
||||||
if (isDisposed) {
|
|
||||||
ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
retryCount = 0;
|
|
||||||
setStatus('connected');
|
|
||||||
|
|
||||||
// Flush any queued messages buffered while connecting or reconnecting
|
|
||||||
if (pendingMessagesQueueRef.current.length > 0) {
|
|
||||||
const queue = [...pendingMessagesQueueRef.current];
|
|
||||||
pendingMessagesQueueRef.current = [];
|
|
||||||
for (const msg of queue) {
|
|
||||||
try {
|
|
||||||
ws.send(msg);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[DocSync] Failed to flush queued message:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
if (isDisposed) return;
|
|
||||||
try {
|
|
||||||
const parsed: DocSyncEvent = JSON.parse(event.data);
|
|
||||||
if (!parsed || !parsed.type) return;
|
|
||||||
|
|
||||||
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
|
||||||
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.type === 'doc_state') {
|
|
||||||
if (Array.isArray(parsed.activeCursors)) {
|
|
||||||
const initialCursors: Record<string, RemoteCursor> = {};
|
|
||||||
for (const c of parsed.activeCursors) {
|
|
||||||
if (c.clientId && c.clientId !== currentClientId) {
|
|
||||||
initialCursors[c.clientId] = {
|
|
||||||
...c,
|
|
||||||
updatedAt: c.updatedAt || Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRemoteCursors(initialCursors);
|
|
||||||
}
|
|
||||||
} else if (parsed.type === 'cursor_moved') {
|
|
||||||
if (parsed.clientId && parsed.clientId !== currentClientId) {
|
|
||||||
setRemoteCursors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[parsed.clientId!]: {
|
|
||||||
clientId: parsed.clientId!,
|
|
||||||
userId: parsed.userId || '',
|
|
||||||
userName: parsed.userName || 'Anonym',
|
|
||||||
userColor: parsed.userColor || '#3b82f6',
|
|
||||||
blockId:
|
|
||||||
parsed.blockId !== undefined
|
|
||||||
? parsed.blockId
|
|
||||||
: prev[parsed.clientId!]?.blockId ?? null,
|
|
||||||
selectionStart: parsed.selectionStart,
|
|
||||||
selectionEnd: parsed.selectionEnd,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} else if (parsed.type === 'client_left') {
|
|
||||||
if (parsed.clientId) {
|
|
||||||
setRemoteCursors((prev) => {
|
|
||||||
if (!prev[parsed.clientId!]) return prev;
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[parsed.clientId!];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onRemoteEventRef.current(parsed);
|
|
||||||
} catch {
|
|
||||||
// Ignore malformed frames
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
// Handled in onclose
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
window.clearTimeout(handshakeTimer);
|
|
||||||
if (isDisposed) return;
|
|
||||||
wsRef.current = null;
|
|
||||||
retryCount += 1;
|
|
||||||
if (retryCount >= 2) {
|
|
||||||
setStatus('blocked');
|
|
||||||
} else {
|
|
||||||
setStatus('reconnecting');
|
|
||||||
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000);
|
|
||||||
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isDisposed = true;
|
|
||||||
if (reconnectTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(reconnectTimerRef.current);
|
|
||||||
reconnectTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (fallbackTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(fallbackTimerRef.current);
|
|
||||||
fallbackTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (pendingCursorTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(pendingCursorTimerRef.current);
|
|
||||||
pendingCursorTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (wsRef.current) {
|
|
||||||
try {
|
|
||||||
wsRef.current.onopen = null;
|
|
||||||
wsRef.current.onmessage = null;
|
|
||||||
wsRef.current.onerror = null;
|
|
||||||
wsRef.current.onclose = null;
|
|
||||||
wsRef.current.close();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [docId, enabled, localUserName, localUserColor]);
|
|
||||||
|
|
||||||
const executeSendCursor = useCallback(
|
|
||||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
|
||||||
const ws = wsRef.current;
|
|
||||||
const activeDocId = docIdRef.current;
|
|
||||||
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
const payload: DocSyncEvent = {
|
|
||||||
type: 'cursor_moved',
|
|
||||||
docId: activeDocId,
|
|
||||||
clientId: clientIdRef.current,
|
|
||||||
userId: currentUserRef.current?.id,
|
|
||||||
userName: localUserNameRef.current,
|
|
||||||
userColor: localUserColorRef.current,
|
|
||||||
blockId: blockId || undefined,
|
|
||||||
selectionStart,
|
|
||||||
selectionEnd,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
ws.send(JSON.stringify(payload));
|
|
||||||
} catch {
|
|
||||||
// Ephemeral cursor, ignore send failure
|
|
||||||
}
|
|
||||||
lastCursorSendRef.current = Date.now();
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendCursorMoved = useCallback(
|
|
||||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
|
||||||
if (!docIdRef.current) return;
|
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = now - lastCursorSendRef.current;
|
|
||||||
|
|
||||||
// When leaving focus / blur (blockId === null), send immediately
|
|
||||||
if (blockId === null) {
|
|
||||||
if (pendingCursorTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(pendingCursorTimerRef.current);
|
|
||||||
pendingCursorTimerRef.current = null;
|
|
||||||
}
|
|
||||||
pendingCursorPayloadRef.current = null;
|
|
||||||
executeSendCursor(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Max once per 100ms
|
|
||||||
if (elapsed >= 100) {
|
|
||||||
if (pendingCursorTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(pendingCursorTimerRef.current);
|
|
||||||
pendingCursorTimerRef.current = null;
|
|
||||||
}
|
|
||||||
executeSendCursor(blockId, selectionStart, selectionEnd);
|
|
||||||
} else {
|
|
||||||
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
|
|
||||||
if (pendingCursorTimerRef.current === null) {
|
|
||||||
pendingCursorTimerRef.current = window.setTimeout(() => {
|
|
||||||
pendingCursorTimerRef.current = null;
|
|
||||||
if (pendingCursorPayloadRef.current) {
|
|
||||||
executeSendCursor(
|
|
||||||
pendingCursorPayloadRef.current.blockId,
|
|
||||||
pendingCursorPayloadRef.current.selectionStart,
|
|
||||||
pendingCursorPayloadRef.current.selectionEnd
|
|
||||||
);
|
|
||||||
pendingCursorPayloadRef.current = null;
|
|
||||||
}
|
|
||||||
}, 100 - elapsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[executeSendCursor]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendRawEvent = useCallback(
|
|
||||||
(evt: DocSyncEvent) => {
|
|
||||||
const activeDocId = docIdRef.current;
|
|
||||||
const activeCanEdit = canEditRef.current;
|
|
||||||
if (!activeDocId || !activeCanEdit) return;
|
|
||||||
|
|
||||||
const ws = wsRef.current;
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
|
|
||||||
const payload: DocSyncEvent = {
|
|
||||||
...evt,
|
|
||||||
docId: activeDocId,
|
|
||||||
clientId: clientIdRef.current,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
ws.send(JSON.stringify(payload));
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[DocSync] Failed to send WebSocket message:', err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendBlockCreated = useCallback(
|
|
||||||
(block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => {
|
|
||||||
sendRawEvent({
|
|
||||||
type: 'block_created',
|
|
||||||
block,
|
|
||||||
afterBlockId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendRawEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendBlockUpdated = useCallback(
|
|
||||||
(block: DocumentBlock) => {
|
|
||||||
sendRawEvent({
|
|
||||||
type: 'block_updated',
|
|
||||||
blockId: block.id,
|
|
||||||
block,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendRawEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendBlockDeleted = useCallback(
|
|
||||||
(blockId: string, _allBlocks?: DocumentBlock[]) => {
|
|
||||||
sendRawEvent({
|
|
||||||
type: 'block_deleted',
|
|
||||||
blockId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendRawEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendBlockMoved = useCallback(
|
|
||||||
(blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => {
|
|
||||||
sendRawEvent({
|
|
||||||
type: 'block_moved',
|
|
||||||
blockId,
|
|
||||||
newIndex,
|
|
||||||
level,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendRawEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendTitleUpdated = useCallback(
|
|
||||||
(title: string) => {
|
|
||||||
sendRawEvent({
|
|
||||||
type: 'title_updated',
|
|
||||||
title,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendRawEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sendBlocksReplaced = useCallback(
|
|
||||||
(blocks: DocumentBlock[]) => {
|
|
||||||
sendRawEvent({
|
|
||||||
type: 'blocks_replaced',
|
|
||||||
blocks,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendRawEvent]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
clientId: clientIdRef.current,
|
|
||||||
remoteCursors,
|
|
||||||
sendCursorMoved,
|
|
||||||
sendBlockCreated,
|
|
||||||
sendBlockUpdated,
|
|
||||||
sendBlockDeleted,
|
|
||||||
sendBlockMoved,
|
|
||||||
sendTitleUpdated,
|
|
||||||
sendBlocksReplaced,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -31,9 +31,6 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||||
if wsHub != nil && store != nil {
|
|
||||||
wsHub.SetDocStore(store)
|
|
||||||
}
|
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
@@ -70,7 +67,6 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||||||
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
|
|
||||||
r.Get("/api/v1/events", s.handleSSE)
|
r.Get("/api/v1/events", s.handleSSE)
|
||||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||||
|
|
||||||
@@ -145,13 +141,6 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
||||||
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
||||||
|
|
||||||
// Document Locks & Live-Collaboration Sessions
|
|
||||||
r.Get("/api/v1/documents/{id}/locks", s.handleGetDocLocks)
|
|
||||||
r.Post("/api/v1/documents/{id}/blocks/{blockId}/lock", s.handleLockBlock)
|
|
||||||
r.Post("/api/v1/documents/{id}/blocks/{blockId}/unlock", s.handleUnlockBlock)
|
|
||||||
r.Post("/api/v1/documents/{id}/live/start", s.handleStartLiveSession)
|
|
||||||
r.Post("/api/v1/documents/{id}/live/end", s.handleEndLiveSession)
|
|
||||||
|
|
||||||
// Shared Lists & Realtime Collaboration
|
// Shared Lists & Realtime Collaboration
|
||||||
r.Get("/api/v1/lists", s.handleGetAllLists)
|
r.Get("/api/v1/lists", s.handleGetAllLists)
|
||||||
r.Post("/api/v1/lists", s.handleCreateList)
|
r.Post("/api/v1/lists", s.handleCreateList)
|
||||||
@@ -918,18 +907,6 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.wsHub != nil {
|
|
||||||
if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok {
|
|
||||||
if liveTitle != "" {
|
|
||||||
doc.Title = liveTitle
|
|
||||||
}
|
|
||||||
if len(liveBlocks) > 0 {
|
|
||||||
doc.Blocks = liveBlocks
|
|
||||||
doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks)
|
|
||||||
doc.Content = doc.MarkdownCache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, doc)
|
writeJSON(w, http.StatusOK, doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -937,19 +914,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
if s.wsHub != nil {
|
|
||||||
if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusLocked)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet",
|
|
||||||
"lockedBy": ls.InitiatorName,
|
|
||||||
"isLocked": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Icon *string `json:"icon"`
|
Icon *string `json:"icon"`
|
||||||
@@ -968,9 +932,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) {
|
|
||||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
|
||||||
}
|
|
||||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||||
}
|
}
|
||||||
@@ -981,19 +942,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
|||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
if s.wsHub != nil {
|
|
||||||
if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusLocked)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet",
|
|
||||||
"lockedBy": ls.InitiatorName,
|
|
||||||
"isLocked": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req domain.DocBlocksPatchRequest
|
var req domain.DocBlocksPatchRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
@@ -1005,9 +953,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.wsHub != nil {
|
|
||||||
s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
patchEvent := domain.DocBlocksPatchEvent{
|
patchEvent := domain.DocBlocksPatchEvent{
|
||||||
DocID: docID,
|
DocID: docID,
|
||||||
@@ -1032,9 +977,6 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|||||||
docID := chi.URLParam(r, "id")
|
docID := chi.URLParam(r, "id")
|
||||||
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
|
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
|
||||||
|
|
||||||
if s.wsHub != nil {
|
|
||||||
s.wsHub.CloseDocRoom(docID)
|
|
||||||
}
|
|
||||||
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
if err := s.store.DeleteDocument(userID, docID); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -1090,7 +1032,6 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, docID := range deletedDocIDs {
|
for _, docID := range deletedDocIDs {
|
||||||
s.wsHub.ReleaseDocLock(docID, userID, true)
|
|
||||||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||||||
}
|
}
|
||||||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
|
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
|
||||||
@@ -1421,202 +1362,6 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, users)
|
writeJSON(w, http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DOCUMENT LOCKS & LIVE COLLABORATION SESSIONS ---
|
|
||||||
|
|
||||||
func (s *Server) handleGetDocLocks(w http.ResponseWriter, r *http.Request) {
|
|
||||||
docID := chi.URLParam(r, "id")
|
|
||||||
var blockLocks []domain.BlockLockInfo
|
|
||||||
var liveSession *domain.LiveSessionInfo
|
|
||||||
if s.wsHub != nil {
|
|
||||||
blockLocks = s.wsHub.GetDocBlockLocks(docID)
|
|
||||||
liveSession = s.wsHub.GetLiveSession(docID)
|
|
||||||
}
|
|
||||||
if blockLocks == nil {
|
|
||||||
blockLocks = []domain.BlockLockInfo{}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, domain.DocLocksResponse{
|
|
||||||
DocID: docID,
|
|
||||||
BlockLocks: blockLocks,
|
|
||||||
LiveSession: liveSession,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleLockBlock(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := auth.GetUserID(r.Context())
|
|
||||||
docID := chi.URLParam(r, "id")
|
|
||||||
blockID := chi.URLParam(r, "blockId")
|
|
||||||
|
|
||||||
// If live session is active by someone else, reject
|
|
||||||
if s.wsHub != nil {
|
|
||||||
if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusLocked)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet",
|
|
||||||
"lockedBy": ls.InitiatorName,
|
|
||||||
"isLocked": true,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
user, _ := s.store.GetUserByID(userID)
|
|
||||||
userName := "Anonym"
|
|
||||||
userColor := "#3b82f6"
|
|
||||||
if user != nil {
|
|
||||||
if user.DisplayName != "" {
|
|
||||||
userName = user.DisplayName
|
|
||||||
} else {
|
|
||||||
userName = user.Username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
UserColor string `json:"userColor"`
|
|
||||||
TTLSeconds int `json:"ttlSeconds"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if req.UserColor != "" {
|
|
||||||
userColor = req.UserColor
|
|
||||||
}
|
|
||||||
ttl := 30 * time.Second
|
|
||||||
if req.TTLSeconds > 5 && req.TTLSeconds <= 120 {
|
|
||||||
ttl = time.Duration(req.TTLSeconds) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.wsHub != nil {
|
|
||||||
lockInfo, ok, existing := s.wsHub.AcquireBlockLock(docID, blockID, userID, userName, userColor, ttl)
|
|
||||||
if !ok && existing != nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "Block ist von anderem Benutzer gesperrt",
|
|
||||||
"lockedBy": existing.UserName,
|
|
||||||
"lock": existing,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_LOCKED", lockInfo, nil)
|
|
||||||
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
|
||||||
for _, uid := range users {
|
|
||||||
s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_LOCKED", lockInfo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"lock": lockInfo,
|
|
||||||
"docId": lockInfo.DocID,
|
|
||||||
"blockId": lockInfo.BlockID,
|
|
||||||
"userId": lockInfo.UserID,
|
|
||||||
"userName": lockInfo.UserName,
|
|
||||||
"userColor": lockInfo.UserColor,
|
|
||||||
"expiresAt": lockInfo.ExpiresAt,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleUnlockBlock(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := auth.GetUserID(r.Context())
|
|
||||||
docID := chi.URLParam(r, "id")
|
|
||||||
blockID := chi.URLParam(r, "blockId")
|
|
||||||
|
|
||||||
if s.wsHub != nil {
|
|
||||||
released := s.wsHub.ReleaseBlockLock(docID, blockID, userID)
|
|
||||||
if released {
|
|
||||||
evt := map[string]string{
|
|
||||||
"docId": docID,
|
|
||||||
"blockId": blockID,
|
|
||||||
"userId": userID,
|
|
||||||
}
|
|
||||||
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", evt, nil)
|
|
||||||
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
|
||||||
for _, uid := range users {
|
|
||||||
s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_UNLOCKED", evt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleStartLiveSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := auth.GetUserID(r.Context())
|
|
||||||
docID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
user, _ := s.store.GetUserByID(userID)
|
|
||||||
userName := "Anonym"
|
|
||||||
if user != nil {
|
|
||||||
if user.DisplayName != "" {
|
|
||||||
userName = user.DisplayName
|
|
||||||
} else {
|
|
||||||
userName = user.Username
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ttl := 60 * time.Second // 1 min lease with heartbeat while active
|
|
||||||
if s.wsHub != nil {
|
|
||||||
info, ok, existing := s.wsHub.StartLiveSession(docID, userID, userName, ttl)
|
|
||||||
if !ok && existing != nil {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusConflict)
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
|
||||||
"error": "Live-Sitzung bereits von anderem Benutzer aktiv",
|
|
||||||
"session": existing,
|
|
||||||
"initiator": existing.InitiatorName,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_STARTED", info, nil)
|
|
||||||
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
|
||||||
for _, uid := range users {
|
|
||||||
s.wsHub.BroadcastToUser(uid, "DOC_LIVE_STARTED", info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"session": info,
|
|
||||||
"docId": info.DocID,
|
|
||||||
"initiatorId": info.InitiatorID,
|
|
||||||
"initiatorName": info.InitiatorName,
|
|
||||||
"startedAt": info.StartedAt,
|
|
||||||
"expiresAt": info.ExpiresAt,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleEndLiveSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
userID := auth.GetUserID(r.Context())
|
|
||||||
docID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
doc, _ := s.store.GetDocumentByID(userID, docID)
|
|
||||||
isOwner := doc != nil && (doc.IsOwner || doc.Permission == "owner" || doc.UserID == userID)
|
|
||||||
|
|
||||||
if s.wsHub != nil {
|
|
||||||
if isOwner {
|
|
||||||
s.wsHub.ForceEndLiveSession(docID)
|
|
||||||
} else {
|
|
||||||
s.wsHub.EndLiveSession(docID, userID)
|
|
||||||
}
|
|
||||||
evt := map[string]string{
|
|
||||||
"docId": docID,
|
|
||||||
"userId": userID,
|
|
||||||
}
|
|
||||||
s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", evt, nil)
|
|
||||||
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
|
|
||||||
for _, uid := range users {
|
|
||||||
s.wsHub.BroadcastToUser(uid, "DOC_LIVE_ENDED", evt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- DOCUMENT REALTIME WEBSOCKET ROOM ---
|
|
||||||
|
|
||||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -1643,46 +1388,6 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/v1/ws/docs/{id}
|
|
||||||
func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Header.Get("Sec-Websocket-Key") != "" {
|
|
||||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
|
|
||||||
r.Header.Set("Connection", "Upgrade")
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
|
|
||||||
r.Header.Set("Upgrade", "websocket")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
docID := chi.URLParam(r, "id")
|
|
||||||
if docID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userID := s.authenticateUserFromReq(r)
|
|
||||||
if userID == "" {
|
|
||||||
userID = auth.GetUserID(r.Context())
|
|
||||||
}
|
|
||||||
if userID == "" {
|
|
||||||
userID = "anonymous"
|
|
||||||
}
|
|
||||||
|
|
||||||
canWrite := false
|
|
||||||
if userID != "anonymous" {
|
|
||||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
canWrite = doc.IsOwner || doc.Permission == "owner" || doc.Permission == "write"
|
|
||||||
}
|
|
||||||
|
|
||||||
clientID := r.URL.Query().Get("clientId")
|
|
||||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
|
||||||
room.ServeDocWS(w, r, userID, clientID, canWrite)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- REALTIME LISTS & SHARING ---
|
// --- REALTIME LISTS & SHARING ---
|
||||||
|
|
||||||
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+29
-163
@@ -841,93 +841,6 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dbPath := filepath.Join(tmpDir, "docroom_test.db")
|
|
||||||
|
|
||||||
store, err := storage.NewStore(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewStore failed: %v", err)
|
|
||||||
}
|
|
||||||
defer store.Close()
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
JWTSecret: "test-jwt-secret-key",
|
|
||||||
AllowRegistration: true,
|
|
||||||
}
|
|
||||||
hub := ws.NewHub()
|
|
||||||
srv := NewServer(cfg, store, hub, nil)
|
|
||||||
router := srv.Router()
|
|
||||||
|
|
||||||
user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateUser failed: %v", err)
|
|
||||||
}
|
|
||||||
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role, user.TokenVersion)
|
|
||||||
|
|
||||||
doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateDocument failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Mutate in-memory DocRoom before 1.5s flush fires
|
|
||||||
room := hub.GetOrCreateDocRoom(doc.ID)
|
|
||||||
newContent := "Live RAM Block #ramtag"
|
|
||||||
newLevel := 1
|
|
||||||
room.HandleEvent(ws.DocSyncEvent{
|
|
||||||
Type: "title_updated",
|
|
||||||
ClientID: "cli_1",
|
|
||||||
Title: "RAM Updated Title",
|
|
||||||
}, nil)
|
|
||||||
room.HandleEvent(ws.DocSyncEvent{
|
|
||||||
Type: "block_created",
|
|
||||||
ClientID: "cli_1",
|
|
||||||
AfterBlockID: doc.Blocks[0].ID,
|
|
||||||
Block: &domain.DocumentBlock{
|
|
||||||
ID: "blk_ram_2",
|
|
||||||
Type: "text",
|
|
||||||
Content: newContent,
|
|
||||||
Level: newLevel,
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
|
|
||||||
// 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var liveDoc domain.Document
|
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &liveDoc)
|
|
||||||
if liveDoc.Title != "RAM Updated Title" {
|
|
||||||
t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title)
|
|
||||||
}
|
|
||||||
if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent {
|
|
||||||
t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache
|
|
||||||
room.Flush()
|
|
||||||
|
|
||||||
persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetDocumentByID failed: %v", err)
|
|
||||||
}
|
|
||||||
if persistedDoc.Title != "RAM Updated Title" {
|
|
||||||
t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title)
|
|
||||||
}
|
|
||||||
if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") {
|
|
||||||
t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache)
|
|
||||||
}
|
|
||||||
tags, _ := store.GetWorkspaceTags(user.ID)
|
|
||||||
if len(tags) != 1 || tags[0].Tag != "ramtag" {
|
|
||||||
t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSSEGlobalEndpoint(t *testing.T) {
|
func TestSSEGlobalEndpoint(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||||
store, err := storage.NewStore(dbPath)
|
store, err := storage.NewStore(dbPath)
|
||||||
@@ -1295,94 +1208,54 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBlockLockingAndLiveSessionGuards(t *testing.T) {
|
func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||||
srv, store, _ := setupTestServer(t)
|
srv, store, _ := setupTestServer(t)
|
||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
|
|
||||||
userA, _ := store.CreateUser("user_a_lock@example.com", "hash", "User A", "member")
|
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
|
||||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
||||||
|
|
||||||
userB, _ := store.CreateUser("user_b_lock@example.com", "hash", "User B", "member")
|
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
|
||||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||||
|
|
||||||
// Create document owned by User A, shared with User B
|
// Create document owned by User A
|
||||||
blocks := []domain.DocumentBlock{
|
blocks := []domain.DocumentBlock{
|
||||||
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
|
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
|
||||||
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
|
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
|
||||||
}
|
}
|
||||||
doc, err := store.CreateDocument(userA.ID, "", "Lock Test Doc", "📝", "", blocks, nil, false)
|
doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateDocument failed: %v", err)
|
t.Fatalf("CreateDocument failed: %v", err)
|
||||||
}
|
}
|
||||||
_, _ = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write")
|
|
||||||
|
|
||||||
// 1. Initial locks should be empty
|
// Share with User B (forced read-only)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/locks", nil)
|
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read")
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
if err != nil {
|
||||||
|
t.Fatalf("CreateDocShare failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. User B can read document
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("get locks failed: %d", rec.Code)
|
t.Fatalf("User B should be able to read doc, got status: %d", rec.Code)
|
||||||
}
|
|
||||||
var locksResp domain.DocLocksResponse
|
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &locksResp)
|
|
||||||
if len(locksResp.BlockLocks) != 0 || locksResp.LiveSession != nil {
|
|
||||||
t.Fatalf("expected 0 locks initially, got %v", locksResp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. User A locks block b1
|
// 2. User B tries to update document via PUT -> must fail (not owner)
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`)))
|
newTitle := "Hacked Title"
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle})
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody))
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("user A lock b1 failed: %d, body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. User B tries to lock b1 -> must receive 409 Conflict
|
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`)))
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code == http.StatusOK {
|
||||||
t.Fatalf("expected 409 Conflict for user B on b1, got %d", rec.Code)
|
t.Fatalf("expected error for User B updating document, got 200 OK")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. User A unlocks b1
|
// 3. User B tries to patch document blocks -> must fail (not owner)
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("user A unlock b1 failed: %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. User B can now lock b1
|
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`)))
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("user B lock b1 should succeed now, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
// User B unlocks b1
|
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
// 6. User A starts a Live-Session on doc
|
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/start", nil)
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("user A start live session failed: %d, body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. User B tries to patch document blocks while User A's live session is active -> must receive 423 Locked!
|
|
||||||
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
|
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
|
||||||
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
|
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
|
||||||
})
|
})
|
||||||
@@ -1391,27 +1264,20 @@ func TestBlockLockingAndLiveSessionGuards(t *testing.T) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusLocked {
|
if rec.Code == http.StatusOK {
|
||||||
t.Fatalf("expected 423 Locked for User B while live session is active, got %d, body: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected error for User B patching document blocks, got 200 OK")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. User A ends the Live-Session
|
// 4. User A (owner) can successfully update document
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/end", nil)
|
ownerTitle := "Owner Updated Title"
|
||||||
|
ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle})
|
||||||
|
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody))
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("user A end live session failed: %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. User B can now edit again
|
|
||||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
|
|
||||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("User B edit should succeed after live session ended, got %d, body: %s", rec.Code, rec.Body.String())
|
t.Fatalf("owner should be able to update document, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,36 +337,6 @@ type DocShare struct {
|
|||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocLockInfo struct {
|
|
||||||
DocID string `json:"docId"`
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
UserName string `json:"userName"`
|
|
||||||
ExpiresAt string `json:"expiresAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BlockLockInfo struct {
|
|
||||||
DocID string `json:"docId"`
|
|
||||||
BlockID string `json:"blockId"`
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
UserName string `json:"userName"`
|
|
||||||
UserColor string `json:"userColor,omitempty"`
|
|
||||||
ExpiresAt string `json:"expiresAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LiveSessionInfo struct {
|
|
||||||
DocID string `json:"docId"`
|
|
||||||
InitiatorID string `json:"initiatorId"`
|
|
||||||
InitiatorName string `json:"initiatorName"`
|
|
||||||
StartedAt string `json:"startedAt"`
|
|
||||||
ExpiresAt string `json:"expiresAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DocLocksResponse struct {
|
|
||||||
DocID string `json:"docId"`
|
|
||||||
BlockLocks []BlockLockInfo `json:"blockLocks"`
|
|
||||||
LiveSession *LiveSessionInfo `json:"liveSession,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Document struct {
|
type Document struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
|
|||||||
+16
-14
@@ -414,6 +414,10 @@ func (s *Store) migrate() error {
|
|||||||
// Backfill username for existing accounts where username is empty
|
// Backfill username for existing accounts where username is empty
|
||||||
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
_, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
|
||||||
|
|
||||||
|
// Ensure all existing document shares are strictly read-only
|
||||||
|
_, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||||
|
_, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`)
|
||||||
|
|
||||||
// Default system settings (public registration disabled by default; invite or admin creation required)
|
// Default system settings (public registration disabled by default; invite or admin creation required)
|
||||||
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
|
||||||
('sharing_mode', 'teams_only'),
|
('sharing_mode', 'teams_only'),
|
||||||
@@ -1526,7 +1530,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if !doc.IsOwner && doc.Permission != "write" {
|
if !doc.IsOwner {
|
||||||
return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2274,7 +2278,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
|
|||||||
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
|
isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil
|
||||||
isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil
|
isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil
|
||||||
|
|
||||||
if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove {
|
if !doc.IsOwner && !isOnlyPinChange && !isOnlyFolderMove {
|
||||||
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2908,7 +2912,7 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do
|
|||||||
// Target is NOT inside a shared folder (personal / standalone)
|
// Target is NOT inside a shared folder (personal / standalone)
|
||||||
if doc.SharedFolderID != "" {
|
if doc.SharedFolderID != "" {
|
||||||
// Moving OUT of a shared folder into a personal folder
|
// Moving OUT of a shared folder into a personal folder
|
||||||
if !doc.IsOwner && doc.Permission != "write" {
|
if !doc.IsOwner {
|
||||||
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
|
return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben")
|
||||||
}
|
}
|
||||||
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
|
_, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`,
|
||||||
@@ -3564,8 +3568,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !doc.IsOwner && doc.Permission != "write" {
|
if !doc.IsOwner {
|
||||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanTeamID := strings.TrimSpace(teamID)
|
cleanTeamID := strings.TrimSpace(teamID)
|
||||||
@@ -3575,9 +3579,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri
|
|||||||
return nil, fmt.Errorf("team nicht gefunden")
|
return nil, fmt.Errorf("team nicht gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
if permission != "read" && permission != "write" {
|
// Document sharing is strictly read-only
|
||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
|
||||||
|
|
||||||
now := domain.NowISO()
|
now := domain.NowISO()
|
||||||
shareID := domain.NewID("share")
|
shareID := domain.NewID("share")
|
||||||
@@ -3609,20 +3612,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !doc.IsOwner && doc.Permission != "write" {
|
if !doc.IsOwner {
|
||||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten")
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||||
|
|
||||||
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
||||||
if strings.HasPrefix(cleanTarget, "team_") {
|
if strings.HasPrefix(cleanTarget, "team_") {
|
||||||
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, "read")
|
||||||
}
|
}
|
||||||
|
|
||||||
if permission != "read" && permission != "write" {
|
// Document sharing is strictly read-only
|
||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve target user by ID, username, or Email
|
// Resolve target user by ID, username, or Email
|
||||||
var targetUser *domain.User
|
var targetUser *domain.User
|
||||||
|
|||||||
@@ -173,16 +173,10 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to write permission
|
// user2 is a read-only collaborator -> editing must fail
|
||||||
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// user2 edits writeable doc -> must succeed
|
|
||||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove share
|
// Remove share
|
||||||
@@ -551,8 +545,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
|||||||
t.Fatalf("MoveDocument by userB failed: %v", err)
|
t.Fatalf("MoveDocument by userB failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3)
|
// User B is a read-only collaborator -> PatchDocumentBlocks must fail
|
||||||
// Simultaneously, User A edits b1 and adds b4 using a slightly stale order
|
|
||||||
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||||
Upserted: []domain.DocumentBlock{
|
Upserted: []domain.DocumentBlock{
|
||||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
|
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
|
||||||
@@ -560,8 +553,8 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
|||||||
},
|
},
|
||||||
BlockOrder: []string{"b1", "b2", "b3"},
|
BlockOrder: []string{"b1", "b2", "b3"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("PatchDocumentBlocks by userB failed: %v", err)
|
t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// User A's client only knew about b1, b2 when sending its patch (adds b4)
|
// User A's client only knew about b1, b2 when sending its patch (adds b4)
|
||||||
@@ -576,9 +569,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
|||||||
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
|
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved
|
// Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
|
||||||
if len(patchedDocA.Blocks) != 4 {
|
if len(patchedDocA.Blocks) != 3 {
|
||||||
t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
||||||
}
|
}
|
||||||
blockContent := make(map[string]string)
|
blockContent := make(map[string]string)
|
||||||
for _, b := range patchedDocA.Blocks {
|
for _, b := range patchedDocA.Blocks {
|
||||||
@@ -587,12 +580,6 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
|||||||
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
||||||
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
|
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
|
||||||
}
|
}
|
||||||
if blockContent["b2"] != "Zeile 2 von B bearbeitet" {
|
|
||||||
t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"])
|
|
||||||
}
|
|
||||||
if blockContent["b3"] != "Zeile 3 neu von B" {
|
|
||||||
t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"])
|
|
||||||
}
|
|
||||||
if blockContent["b4"] != "Zeile 4 neu von A" {
|
if blockContent["b4"] != "Zeile 4 neu von A" {
|
||||||
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
|
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,736 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CursorPresence represents an active user's ephemeral cursor position in a document room.
|
|
||||||
type CursorPresence struct {
|
|
||||||
ClientID string `json:"clientId"`
|
|
||||||
UserID string `json:"userId"`
|
|
||||||
UserName string `json:"userName"`
|
|
||||||
UserColor string `json:"userColor"`
|
|
||||||
BlockID string `json:"blockId,omitempty"`
|
|
||||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
|
||||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocWSClient represents an active WebSocket connection to a specific document room.
|
|
||||||
type DocWSClient struct {
|
|
||||||
hub *Hub
|
|
||||||
room *DocRoom
|
|
||||||
conn *websocket.Conn
|
|
||||||
userID string
|
|
||||||
clientID string
|
|
||||||
canWrite bool
|
|
||||||
send chan []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
|
|
||||||
type DocSyncEvent struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
DocID string `json:"docId,omitempty"`
|
|
||||||
ClientID string `json:"clientId,omitempty"`
|
|
||||||
UserID string `json:"userId,omitempty"`
|
|
||||||
UserName string `json:"userName,omitempty"`
|
|
||||||
UserColor string `json:"userColor,omitempty"`
|
|
||||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
|
||||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
|
||||||
ActiveCursors []CursorPresence `json:"activeCursors,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Block *domain.DocumentBlock `json:"block,omitempty"`
|
|
||||||
BlockID string `json:"blockId,omitempty"`
|
|
||||||
AfterBlockID string `json:"afterBlockId,omitempty"`
|
|
||||||
NewIndex *int `json:"newIndex,omitempty"`
|
|
||||||
Content *string `json:"content,omitempty"`
|
|
||||||
Level *int `json:"level,omitempty"`
|
|
||||||
Checked *bool `json:"checked,omitempty"`
|
|
||||||
Collapsed *bool `json:"collapsed,omitempty"`
|
|
||||||
BlockType *string `json:"blockType,omitempty"`
|
|
||||||
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
|
|
||||||
type DocRoom struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
docID string
|
|
||||||
hub *Hub
|
|
||||||
title string
|
|
||||||
blocks []domain.DocumentBlock
|
|
||||||
loaded bool
|
|
||||||
wsClients map[*DocWSClient]bool
|
|
||||||
activeCursors map[string]*CursorPresence
|
|
||||||
debounceTimer *time.Timer
|
|
||||||
dirty bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite.
|
|
||||||
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
|
|
||||||
h.mu.Lock()
|
|
||||||
if h.docRooms == nil {
|
|
||||||
h.docRooms = make(map[string]*DocRoom)
|
|
||||||
}
|
|
||||||
room, exists := h.docRooms[docID]
|
|
||||||
if !exists {
|
|
||||||
room = &DocRoom{
|
|
||||||
docID: docID,
|
|
||||||
hub: h,
|
|
||||||
wsClients: make(map[*DocWSClient]bool),
|
|
||||||
activeCursors: make(map[string]*CursorPresence),
|
|
||||||
}
|
|
||||||
h.docRooms[docID] = room
|
|
||||||
}
|
|
||||||
store := h.docStore
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
room.mu.Lock()
|
|
||||||
if !room.loaded && store != nil {
|
|
||||||
title, blocks, err := store.LoadDocumentState(docID)
|
|
||||||
if err == nil {
|
|
||||||
room.title = title
|
|
||||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
|
||||||
room.loaded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
room.mu.Unlock()
|
|
||||||
|
|
||||||
return room
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM.
|
|
||||||
func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) {
|
|
||||||
h.mu.RLock()
|
|
||||||
room, exists := h.docRooms[docID]
|
|
||||||
h.mu.RUnlock()
|
|
||||||
if !exists || room == nil {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
room.mu.Lock()
|
|
||||||
defer room.mu.Unlock()
|
|
||||||
if !room.loaded {
|
|
||||||
return "", nil, false
|
|
||||||
}
|
|
||||||
blocksCopy := make([]domain.DocumentBlock, len(room.blocks))
|
|
||||||
copy(blocksCopy, room.blocks)
|
|
||||||
return room.title, blocksCopy, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH).
|
|
||||||
func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) {
|
|
||||||
h.mu.RLock()
|
|
||||||
room, exists := h.docRooms[docID]
|
|
||||||
h.mu.RUnlock()
|
|
||||||
if !exists || room == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
room.mu.Lock()
|
|
||||||
if title != "" {
|
|
||||||
room.title = title
|
|
||||||
}
|
|
||||||
if blocks != nil {
|
|
||||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
|
||||||
}
|
|
||||||
room.loaded = true
|
|
||||||
room.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseDocRoom removes a document room from memory when a document is deleted.
|
|
||||||
func (h *Hub) CloseDocRoom(docID string) {
|
|
||||||
h.mu.Lock()
|
|
||||||
room, exists := h.docRooms[docID]
|
|
||||||
if exists {
|
|
||||||
delete(h.docRooms, docID)
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
if exists && room != nil {
|
|
||||||
room.mu.Lock()
|
|
||||||
if room.debounceTimer != nil {
|
|
||||||
room.debounceTimer.Stop()
|
|
||||||
}
|
|
||||||
room.dirty = false
|
|
||||||
for c := range room.wsClients {
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}
|
|
||||||
room.wsClients = make(map[*DocWSClient]bool)
|
|
||||||
room.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
|
|
||||||
if len(blocks) == 0 {
|
|
||||||
return []domain.DocumentBlock{
|
|
||||||
{
|
|
||||||
ID: docID + "-b0",
|
|
||||||
DocumentID: docID,
|
|
||||||
Type: "text",
|
|
||||||
Content: "",
|
|
||||||
Level: 0,
|
|
||||||
Position: 1000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]domain.DocumentBlock, len(blocks))
|
|
||||||
for i, b := range blocks {
|
|
||||||
if b.ID == "" {
|
|
||||||
b.ID = domain.NewID("blk")
|
|
||||||
}
|
|
||||||
b.DocumentID = docID
|
|
||||||
if b.Type == "" {
|
|
||||||
b.Type = "text"
|
|
||||||
}
|
|
||||||
if b.Level < 0 {
|
|
||||||
b.Level = 0
|
|
||||||
}
|
|
||||||
b.Position = float64((i + 1) * 1000)
|
|
||||||
out[i] = b
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks.
|
|
||||||
func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
|
||||||
copy(blocksCopy, r.blocks)
|
|
||||||
return r.title, blocksCopy
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEvent applies a granular block or title mutation to the in-memory room,
|
|
||||||
// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection),
|
|
||||||
// and resets the 1.5-second inactivity debounce timer for SQLite persistence.
|
|
||||||
func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
|
||||||
r.mu.Lock()
|
|
||||||
|
|
||||||
evt.DocID = r.docID
|
|
||||||
if sender != nil {
|
|
||||||
if evt.ClientID == "" {
|
|
||||||
evt.ClientID = sender.clientID
|
|
||||||
}
|
|
||||||
evt.UserID = sender.userID
|
|
||||||
}
|
|
||||||
|
|
||||||
mutated := false
|
|
||||||
|
|
||||||
switch evt.Type {
|
|
||||||
case "cursor_moved":
|
|
||||||
if evt.ClientID != "" {
|
|
||||||
cp := &CursorPresence{
|
|
||||||
ClientID: evt.ClientID,
|
|
||||||
UserID: evt.UserID,
|
|
||||||
UserName: evt.UserName,
|
|
||||||
UserColor: evt.UserColor,
|
|
||||||
BlockID: evt.BlockID,
|
|
||||||
SelectionStart: evt.SelectionStart,
|
|
||||||
SelectionEnd: evt.SelectionEnd,
|
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
if r.activeCursors == nil {
|
|
||||||
r.activeCursors = make(map[string]*CursorPresence)
|
|
||||||
}
|
|
||||||
r.activeCursors[evt.ClientID] = cp
|
|
||||||
|
|
||||||
msgBytes, err := json.Marshal(evt)
|
|
||||||
if err == nil {
|
|
||||||
for client := range r.wsClients {
|
|
||||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case client.send <- msgBytes:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
return
|
|
||||||
|
|
||||||
case "title_updated":
|
|
||||||
if evt.Title != "" || r.title != evt.Title {
|
|
||||||
r.title = evt.Title
|
|
||||||
mutated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "block_created":
|
|
||||||
if evt.Block != nil {
|
|
||||||
newBlock := *evt.Block
|
|
||||||
if newBlock.ID == "" {
|
|
||||||
newBlock.ID = domain.NewID("blk")
|
|
||||||
}
|
|
||||||
newBlock.DocumentID = r.docID
|
|
||||||
if newBlock.Type == "" {
|
|
||||||
newBlock.Type = "text"
|
|
||||||
}
|
|
||||||
if newBlock.Level < 0 {
|
|
||||||
newBlock.Level = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avoid duplicate ID if already created
|
|
||||||
existsIdx := -1
|
|
||||||
for i, b := range r.blocks {
|
|
||||||
if b.ID == newBlock.ID {
|
|
||||||
existsIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if existsIdx >= 0 {
|
|
||||||
r.blocks[existsIdx] = newBlock
|
|
||||||
} else {
|
|
||||||
insertIdx := len(r.blocks)
|
|
||||||
if evt.AfterBlockID != "" {
|
|
||||||
for i, b := range r.blocks {
|
|
||||||
if b.ID == evt.AfterBlockID {
|
|
||||||
insertIdx = i + 1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) {
|
|
||||||
insertIdx = *evt.NewIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1)
|
|
||||||
updated = append(updated, r.blocks[:insertIdx]...)
|
|
||||||
updated = append(updated, newBlock)
|
|
||||||
updated = append(updated, r.blocks[insertIdx:]...)
|
|
||||||
r.blocks = normalizeRoomBlocks(r.docID, updated)
|
|
||||||
}
|
|
||||||
evt.Block = &newBlock
|
|
||||||
mutated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "block_updated":
|
|
||||||
targetID := evt.BlockID
|
|
||||||
if targetID == "" && evt.Block != nil {
|
|
||||||
targetID = evt.Block.ID
|
|
||||||
}
|
|
||||||
if targetID != "" {
|
|
||||||
found := false
|
|
||||||
for i := range r.blocks {
|
|
||||||
if r.blocks[i].ID == targetID {
|
|
||||||
found = true
|
|
||||||
if evt.Block != nil {
|
|
||||||
r.blocks[i].Content = evt.Block.Content
|
|
||||||
r.blocks[i].Level = evt.Block.Level
|
|
||||||
if evt.Block.Type != "" {
|
|
||||||
r.blocks[i].Type = evt.Block.Type
|
|
||||||
}
|
|
||||||
r.blocks[i].Checked = evt.Block.Checked
|
|
||||||
r.blocks[i].Collapsed = evt.Block.Collapsed
|
|
||||||
r.blocks[i].Lang = evt.Block.Lang
|
|
||||||
} else {
|
|
||||||
if evt.Content != nil {
|
|
||||||
r.blocks[i].Content = *evt.Content
|
|
||||||
}
|
|
||||||
if evt.Level != nil {
|
|
||||||
lvl := *evt.Level
|
|
||||||
if lvl < 0 {
|
|
||||||
lvl = 0
|
|
||||||
}
|
|
||||||
r.blocks[i].Level = lvl
|
|
||||||
}
|
|
||||||
if evt.Checked != nil {
|
|
||||||
r.blocks[i].Checked = *evt.Checked
|
|
||||||
}
|
|
||||||
if evt.Collapsed != nil {
|
|
||||||
r.blocks[i].Collapsed = *evt.Collapsed
|
|
||||||
}
|
|
||||||
if evt.BlockType != nil && *evt.BlockType != "" {
|
|
||||||
r.blocks[i].Type = *evt.BlockType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bCopy := r.blocks[i]
|
|
||||||
evt.Block = &bCopy
|
|
||||||
evt.BlockID = targetID
|
|
||||||
mutated = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found && evt.Block != nil {
|
|
||||||
newBlock := *evt.Block
|
|
||||||
newBlock.DocumentID = r.docID
|
|
||||||
if newBlock.Type == "" {
|
|
||||||
newBlock.Type = "text"
|
|
||||||
}
|
|
||||||
if newBlock.Level < 0 {
|
|
||||||
newBlock.Level = 0
|
|
||||||
}
|
|
||||||
r.blocks = append(r.blocks, newBlock)
|
|
||||||
r.blocks = normalizeRoomBlocks(r.docID, r.blocks)
|
|
||||||
bCopy := newBlock
|
|
||||||
evt.Block = &bCopy
|
|
||||||
evt.BlockID = targetID
|
|
||||||
mutated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "block_deleted":
|
|
||||||
if evt.BlockID != "" {
|
|
||||||
filtered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
|
||||||
for _, b := range r.blocks {
|
|
||||||
if b.ID != evt.BlockID {
|
|
||||||
filtered = append(filtered, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.blocks = normalizeRoomBlocks(r.docID, filtered)
|
|
||||||
mutated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
case "block_moved":
|
|
||||||
if evt.BlockID != "" && evt.NewIndex != nil {
|
|
||||||
fromIdx := -1
|
|
||||||
for i, b := range r.blocks {
|
|
||||||
if b.ID == evt.BlockID {
|
|
||||||
fromIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fromIdx >= 0 {
|
|
||||||
moving := r.blocks[fromIdx]
|
|
||||||
if evt.Level != nil {
|
|
||||||
lvl := *evt.Level
|
|
||||||
if lvl < 0 {
|
|
||||||
lvl = 0
|
|
||||||
}
|
|
||||||
moving.Level = lvl
|
|
||||||
}
|
|
||||||
without := make([]domain.DocumentBlock, 0, len(r.blocks)-1)
|
|
||||||
without = append(without, r.blocks[:fromIdx]...)
|
|
||||||
without = append(without, r.blocks[fromIdx+1:]...)
|
|
||||||
|
|
||||||
toIdx := *evt.NewIndex
|
|
||||||
if toIdx < 0 {
|
|
||||||
toIdx = 0
|
|
||||||
}
|
|
||||||
if toIdx > len(without) {
|
|
||||||
toIdx = len(without)
|
|
||||||
}
|
|
||||||
|
|
||||||
reordered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
|
||||||
reordered = append(reordered, without[:toIdx]...)
|
|
||||||
reordered = append(reordered, moving)
|
|
||||||
reordered = append(reordered, without[toIdx:]...)
|
|
||||||
r.blocks = normalizeRoomBlocks(r.docID, reordered)
|
|
||||||
mutated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "blocks_replaced":
|
|
||||||
if evt.Blocks != nil {
|
|
||||||
r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks)
|
|
||||||
evt.Blocks = r.blocks
|
|
||||||
mutated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !mutated {
|
|
||||||
r.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.dirty = true
|
|
||||||
r.loaded = true
|
|
||||||
|
|
||||||
msgBytes, err := json.Marshal(evt)
|
|
||||||
if err == nil {
|
|
||||||
for client := range r.wsClients {
|
|
||||||
// Strict Echo Protection: never send back to the originating connection or clientId
|
|
||||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case client.send <- msgBytes:
|
|
||||||
default:
|
|
||||||
slog.Warn("Doc room WS client send buffer full, dropping message",
|
|
||||||
"doc_id", r.docID,
|
|
||||||
"client_id", client.clientID,
|
|
||||||
"user_id", client.userID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.scheduleDebouncedFlushLocked()
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *DocRoom) scheduleDebouncedFlushLocked() {
|
|
||||||
if r.debounceTimer != nil {
|
|
||||||
r.debounceTimer.Stop()
|
|
||||||
}
|
|
||||||
r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() {
|
|
||||||
r.Flush()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite.
|
|
||||||
func (r *DocRoom) Flush() {
|
|
||||||
r.mu.Lock()
|
|
||||||
if !r.dirty {
|
|
||||||
r.mu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.debounceTimer != nil {
|
|
||||||
r.debounceTimer.Stop()
|
|
||||||
r.debounceTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
docID := r.docID
|
|
||||||
title := r.title
|
|
||||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
|
||||||
copy(blocksCopy, r.blocks)
|
|
||||||
r.dirty = false
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
tagsCount := 0
|
|
||||||
for _, b := range blocksCopy {
|
|
||||||
tagsCount += len(domain.ExtractTags(b.Content))
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
r.hub.mu.RLock()
|
|
||||||
store := r.hub.docStore
|
|
||||||
r.hub.mu.RUnlock()
|
|
||||||
|
|
||||||
if store != nil {
|
|
||||||
if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil {
|
|
||||||
slog.Error("SQLite document flush failed",
|
|
||||||
"doc_id", docID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
durationMs := float64(time.Since(start).Microseconds()) / 1000.0
|
|
||||||
slog.Debug("SQLite document flushed",
|
|
||||||
"doc_id", docID,
|
|
||||||
"blocks_count", len(blocksCopy),
|
|
||||||
"tags_count", tagsCount,
|
|
||||||
"duration_ms", durationMs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
|
|
||||||
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) {
|
|
||||||
conn, err := upgrader.Upgrade(w, req, nil)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if clientID == "" {
|
|
||||||
clientID = req.URL.Query().Get("clientId")
|
|
||||||
}
|
|
||||||
if clientID == "" {
|
|
||||||
clientID = domain.NewID("cli")
|
|
||||||
}
|
|
||||||
|
|
||||||
userName := req.URL.Query().Get("userName")
|
|
||||||
userColor := req.URL.Query().Get("userColor")
|
|
||||||
|
|
||||||
client := &DocWSClient{
|
|
||||||
hub: r.hub,
|
|
||||||
room: r,
|
|
||||||
conn: conn,
|
|
||||||
userID: userID,
|
|
||||||
clientID: clientID,
|
|
||||||
canWrite: canWrite,
|
|
||||||
send: make(chan []byte, 256),
|
|
||||||
}
|
|
||||||
|
|
||||||
r.mu.Lock()
|
|
||||||
r.wsClients[client] = true
|
|
||||||
activeClients := len(r.wsClients)
|
|
||||||
if r.activeCursors == nil {
|
|
||||||
r.activeCursors = make(map[string]*CursorPresence)
|
|
||||||
}
|
|
||||||
if userName != "" {
|
|
||||||
r.activeCursors[clientID] = &CursorPresence{
|
|
||||||
ClientID: clientID,
|
|
||||||
UserID: userID,
|
|
||||||
UserName: userName,
|
|
||||||
UserColor: userColor,
|
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
title := r.title
|
|
||||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
|
||||||
copy(blocksCopy, r.blocks)
|
|
||||||
|
|
||||||
activeList := make([]CursorPresence, 0, len(r.activeCursors))
|
|
||||||
for _, cp := range r.activeCursors {
|
|
||||||
if cp != nil {
|
|
||||||
activeList = append(activeList, *cp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
slog.Debug("WS client joined doc room",
|
|
||||||
"doc_id", r.docID,
|
|
||||||
"client_id", clientID,
|
|
||||||
"user_id", userID,
|
|
||||||
"active_clients", activeClients,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Immediately send live in-memory state and current cursors to the newly connected client
|
|
||||||
initEvt := DocSyncEvent{
|
|
||||||
Type: "doc_state",
|
|
||||||
DocID: r.docID,
|
|
||||||
ClientID: clientID,
|
|
||||||
Title: title,
|
|
||||||
Blocks: blocksCopy,
|
|
||||||
ActiveCursors: activeList,
|
|
||||||
}
|
|
||||||
if initBytes, err := json.Marshal(initEvt); err == nil {
|
|
||||||
client.send <- initBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
// If userName was provided, broadcast initial presence to existing clients in room
|
|
||||||
if userName != "" {
|
|
||||||
joinEvt := DocSyncEvent{
|
|
||||||
Type: "cursor_moved",
|
|
||||||
DocID: r.docID,
|
|
||||||
ClientID: clientID,
|
|
||||||
UserID: userID,
|
|
||||||
UserName: userName,
|
|
||||||
UserColor: userColor,
|
|
||||||
}
|
|
||||||
if joinBytes, err := json.Marshal(joinEvt); err == nil {
|
|
||||||
r.mu.Lock()
|
|
||||||
for c := range r.wsClients {
|
|
||||||
if c != client && c.clientID != clientID {
|
|
||||||
select {
|
|
||||||
case c.send <- joinBytes:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go client.writePump()
|
|
||||||
go client.readPump()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DocWSClient) readPump() {
|
|
||||||
defer func() {
|
|
||||||
c.room.mu.Lock()
|
|
||||||
delete(c.room.wsClients, c)
|
|
||||||
delete(c.room.activeCursors, c.clientID)
|
|
||||||
remaining := len(c.room.wsClients)
|
|
||||||
|
|
||||||
// Broadcast client_left to all remaining clients in room
|
|
||||||
leftEvt := DocSyncEvent{
|
|
||||||
Type: "client_left",
|
|
||||||
DocID: c.room.docID,
|
|
||||||
ClientID: c.clientID,
|
|
||||||
UserID: c.userID,
|
|
||||||
}
|
|
||||||
if leftBytes, err := json.Marshal(leftEvt); err == nil {
|
|
||||||
for client := range c.room.wsClients {
|
|
||||||
if client != c && client.clientID != c.clientID {
|
|
||||||
select {
|
|
||||||
case client.send <- leftBytes:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.room.mu.Unlock()
|
|
||||||
|
|
||||||
slog.Debug("WS client left doc room",
|
|
||||||
"doc_id", c.room.docID,
|
|
||||||
"client_id", c.clientID,
|
|
||||||
"user_id", c.userID,
|
|
||||||
"active_clients", remaining,
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = c.conn.Close()
|
|
||||||
|
|
||||||
// Flush immediately when the last client leaves the document room
|
|
||||||
if remaining == 0 {
|
|
||||||
c.room.Flush()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
c.conn.SetReadLimit(4 * 1024 * 1024)
|
|
||||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
||||||
c.conn.SetPongHandler(func(string) error {
|
|
||||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
for {
|
|
||||||
_, message, err := c.conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
var evt DocSyncEvent
|
|
||||||
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
|
||||||
targetBlockID := evt.BlockID
|
|
||||||
if targetBlockID == "" && evt.Block != nil {
|
|
||||||
targetBlockID = evt.Block.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Debug("WS doc event received",
|
|
||||||
"type", evt.Type,
|
|
||||||
"doc_id", c.room.docID,
|
|
||||||
"block_id", targetBlockID,
|
|
||||||
"sender_client_id", c.clientID,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Read-only clients can still move their cursor and send presence updates
|
|
||||||
if !c.canWrite && evt.Type != "cursor_moved" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c.room.HandleEvent(evt, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *DocWSClient) writePump() {
|
|
||||||
ticker := time.NewTicker(25 * time.Second)
|
|
||||||
defer func() {
|
|
||||||
ticker.Stop()
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case message, ok := <-c.send:
|
|
||||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
if !ok {
|
|
||||||
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case <-ticker.C:
|
|
||||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
|
||||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) {
|
|
||||||
hub := NewHub()
|
|
||||||
room := hub.GetOrCreateDocRoom("doc_test_presence")
|
|
||||||
|
|
||||||
c1 := &DocWSClient{
|
|
||||||
hub: hub,
|
|
||||||
room: room,
|
|
||||||
userID: "user_1",
|
|
||||||
clientID: "cli_1",
|
|
||||||
canWrite: true,
|
|
||||||
send: make(chan []byte, 10),
|
|
||||||
}
|
|
||||||
c2 := &DocWSClient{
|
|
||||||
hub: hub,
|
|
||||||
room: room,
|
|
||||||
userID: "user_2",
|
|
||||||
clientID: "cli_2",
|
|
||||||
canWrite: false, // read-only viewer
|
|
||||||
send: make(chan []byte, 10),
|
|
||||||
}
|
|
||||||
|
|
||||||
room.mu.Lock()
|
|
||||||
room.wsClients[c1] = true
|
|
||||||
room.wsClients[c2] = true
|
|
||||||
room.mu.Unlock()
|
|
||||||
|
|
||||||
// 1. Send cursor_moved from c1
|
|
||||||
selStart := 5
|
|
||||||
selEnd := 5
|
|
||||||
room.HandleEvent(DocSyncEvent{
|
|
||||||
Type: "cursor_moved",
|
|
||||||
DocID: "doc_test_presence",
|
|
||||||
ClientID: "cli_1",
|
|
||||||
UserID: "user_1",
|
|
||||||
UserName: "Alice",
|
|
||||||
UserColor: "#3b82f6",
|
|
||||||
BlockID: "blk_123",
|
|
||||||
SelectionStart: &selStart,
|
|
||||||
SelectionEnd: &selEnd,
|
|
||||||
}, c1)
|
|
||||||
|
|
||||||
// c1 should NOT have received its own echo
|
|
||||||
select {
|
|
||||||
case <-c1.send:
|
|
||||||
t.Fatal("c1 received echo of its own cursor_moved event!")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// c2 SHOULD receive the event
|
|
||||||
select {
|
|
||||||
case msg := <-c2.send:
|
|
||||||
if len(msg) == 0 {
|
|
||||||
t.Fatal("empty message received by c2")
|
|
||||||
}
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("timed out waiting for cursor_moved event on c2")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Room should have registered c1's presence
|
|
||||||
room.mu.Lock()
|
|
||||||
cp, exists := room.activeCursors["cli_1"]
|
|
||||||
room.mu.Unlock()
|
|
||||||
|
|
||||||
if !exists || cp == nil {
|
|
||||||
t.Fatal("expected active cursor for cli_1 in room")
|
|
||||||
}
|
|
||||||
if cp.UserName != "Alice" || cp.BlockID != "blk_123" {
|
|
||||||
t.Fatalf("unexpected cursor data: %+v", cp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ephemeral cursor events must NOT mark the room dirty for disk flush
|
|
||||||
room.mu.Lock()
|
|
||||||
dirty := room.dirty
|
|
||||||
room.mu.Unlock()
|
|
||||||
if dirty {
|
|
||||||
t.Fatal("cursor_moved should not mark room dirty")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-507
@@ -84,37 +84,6 @@ func (c *SSEClient) trySend(data []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type docLockItem struct {
|
|
||||||
docID string
|
|
||||||
userID string
|
|
||||||
userName string
|
|
||||||
lockedAt time.Time
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type blockLockItem struct {
|
|
||||||
docID string
|
|
||||||
blockID string
|
|
||||||
userID string
|
|
||||||
userName string
|
|
||||||
userColor string
|
|
||||||
lockedAt time.Time
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type liveSessionItem struct {
|
|
||||||
docID string
|
|
||||||
initiatorID string
|
|
||||||
initiatorName string
|
|
||||||
startedAt time.Time
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type DocStore interface {
|
|
||||||
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
|
|
||||||
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type Hub struct {
|
type Hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
rooms map[string]map[*Client]bool
|
rooms map[string]map[*Client]bool
|
||||||
@@ -123,37 +92,18 @@ type Hub struct {
|
|||||||
sseClients map[*SSEClient]bool
|
sseClients map[*SSEClient]bool
|
||||||
sseUserRooms map[string]map[*SSEClient]bool
|
sseUserRooms map[string]map[*SSEClient]bool
|
||||||
sseRooms map[string]map[*SSEClient]bool
|
sseRooms map[string]map[*SSEClient]bool
|
||||||
locks map[string]*docLockItem
|
|
||||||
blockLocks map[string]*blockLockItem // key: docID + ":" + blockID
|
|
||||||
liveSessions map[string]*liveSessionItem // key: docID
|
|
||||||
docRooms map[string]*DocRoom
|
|
||||||
docStore DocStore
|
|
||||||
stopCleanup chan struct{}
|
|
||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
h := &Hub{
|
return &Hub{
|
||||||
rooms: make(map[string]map[*Client]bool),
|
rooms: make(map[string]map[*Client]bool),
|
||||||
userRooms: make(map[string]map[*Client]bool),
|
userRooms: make(map[string]map[*Client]bool),
|
||||||
allClients: make(map[*Client]bool),
|
allClients: make(map[*Client]bool),
|
||||||
sseClients: make(map[*SSEClient]bool),
|
sseClients: make(map[*SSEClient]bool),
|
||||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||||
locks: make(map[string]*docLockItem),
|
|
||||||
blockLocks: make(map[string]*blockLockItem),
|
|
||||||
liveSessions: make(map[string]*liveSessionItem),
|
|
||||||
docRooms: make(map[string]*DocRoom),
|
|
||||||
stopCleanup: make(chan struct{}),
|
|
||||||
}
|
}
|
||||||
go h.cleanupLocksLoop()
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) SetDocStore(store DocStore) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
h.docStore = store
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Register(c *Client) {
|
func (h *Hub) Register(c *Client) {
|
||||||
@@ -193,7 +143,6 @@ func (h *Hub) Unregister(c *Client) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var docRoomsLeft []string
|
|
||||||
if c.subscribedRooms != nil {
|
if c.subscribedRooms != nil {
|
||||||
for rID := range c.subscribedRooms {
|
for rID := range c.subscribedRooms {
|
||||||
if room, ok := h.rooms[rID]; ok {
|
if room, ok := h.rooms[rID]; ok {
|
||||||
@@ -202,52 +151,19 @@ func (h *Hub) Unregister(c *Client) {
|
|||||||
delete(h.rooms, rID)
|
delete(h.rooms, rID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(rID, "doc:") {
|
|
||||||
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userRemaining := 0
|
|
||||||
if c.userID != "" && c.userID != "anonymous" {
|
if c.userID != "" && c.userID != "anonymous" {
|
||||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||||
delete(uRoom, c)
|
delete(uRoom, c)
|
||||||
userRemaining += len(uRoom)
|
|
||||||
if len(uRoom) == 0 {
|
if len(uRoom) == 0 {
|
||||||
delete(h.userRooms, c.userID)
|
delete(h.userRooms, c.userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if sseRoom, ok := h.sseUserRooms[c.userID]; ok {
|
|
||||||
userRemaining += len(sseRoom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.closeSend()
|
c.closeSend()
|
||||||
|
|
||||||
// If this user has no other active client connections, release their document locks
|
|
||||||
var releasedDocIDs []string
|
|
||||||
if userRemaining == 0 && c.userID != "" {
|
|
||||||
now := time.Now()
|
|
||||||
for docID, l := range h.locks {
|
|
||||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
|
||||||
releasedDocIDs = append(releasedDocIDs, docID)
|
|
||||||
delete(h.locks, docID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// Broadcast unlock events for released locks
|
|
||||||
for _, docID := range releasedDocIDs {
|
|
||||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast awareness leave for doc rooms
|
|
||||||
for _, docID := range docRoomsLeft {
|
|
||||||
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
|
||||||
"docId": docID,
|
|
||||||
"userId": c.userID,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) RegisterSSE(c *SSEClient) {
|
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||||
@@ -294,7 +210,6 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var docRoomsLeft []string
|
|
||||||
if c.subscribedRooms != nil {
|
if c.subscribedRooms != nil {
|
||||||
for rID := range c.subscribedRooms {
|
for rID := range c.subscribedRooms {
|
||||||
if room, ok := h.sseRooms[rID]; ok {
|
if room, ok := h.sseRooms[rID]; ok {
|
||||||
@@ -303,48 +218,18 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
|
|||||||
delete(h.sseRooms, rID)
|
delete(h.sseRooms, rID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(rID, "doc:") {
|
|
||||||
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
userRemaining := 0
|
|
||||||
if c.userID != "" && c.userID != "anonymous" {
|
if c.userID != "" && c.userID != "anonymous" {
|
||||||
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||||
delete(uRoom, c)
|
delete(uRoom, c)
|
||||||
userRemaining += len(uRoom)
|
|
||||||
if len(uRoom) == 0 {
|
if len(uRoom) == 0 {
|
||||||
delete(h.sseUserRooms, c.userID)
|
delete(h.sseUserRooms, c.userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if wsRoom, ok := h.userRooms[c.userID]; ok {
|
|
||||||
userRemaining += len(wsRoom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
c.closeSend()
|
c.closeSend()
|
||||||
|
|
||||||
var releasedDocIDs []string
|
|
||||||
if userRemaining == 0 && c.userID != "" {
|
|
||||||
now := time.Now()
|
|
||||||
for docID, l := range h.locks {
|
|
||||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
|
||||||
releasedDocIDs = append(releasedDocIDs, docID)
|
|
||||||
delete(h.locks, docID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
for _, docID := range releasedDocIDs {
|
|
||||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, docID := range docRoomsLeft {
|
|
||||||
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
|
||||||
"docId": docID,
|
|
||||||
"userId": c.userID,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||||
@@ -386,354 +271,9 @@ func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document Locking Methods
|
|
||||||
|
|
||||||
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
|
||||||
existing, exists := h.locks[docID]
|
|
||||||
if exists && existing.expiresAt.After(now) {
|
|
||||||
if existing.userID != userID {
|
|
||||||
// Locked by another user
|
|
||||||
info := &domain.DocLockInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
UserID: existing.userID,
|
|
||||||
UserName: existing.userName,
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return nil, false, info
|
|
||||||
}
|
|
||||||
// Same user extending lock
|
|
||||||
existing.expiresAt = now.Add(ttl)
|
|
||||||
if userName != "" {
|
|
||||||
existing.userName = userName
|
|
||||||
}
|
|
||||||
info := &domain.DocLockInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
UserID: existing.userID,
|
|
||||||
UserName: existing.userName,
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return info, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create fresh lock
|
|
||||||
item := &docLockItem{
|
|
||||||
docID: docID,
|
|
||||||
userID: userID,
|
|
||||||
userName: userName,
|
|
||||||
lockedAt: now,
|
|
||||||
expiresAt: now.Add(ttl),
|
|
||||||
}
|
|
||||||
h.locks[docID] = item
|
|
||||||
info := &domain.DocLockInfo{
|
|
||||||
DocID: item.docID,
|
|
||||||
UserID: item.userID,
|
|
||||||
UserName: item.userName,
|
|
||||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return info, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
item, ok := h.locks[docID]
|
|
||||||
if !ok || item.userID != userID || item.expiresAt.Before(now) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
item.expiresAt = now.Add(ttl)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
item, ok := h.locks[docID]
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if item.userID == userID || isAdmin || userID == "admin" {
|
|
||||||
delete(h.locks, docID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
|
|
||||||
item, ok := h.locks[docID]
|
|
||||||
if !ok || item.expiresAt.Before(time.Now()) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &domain.DocLockInfo{
|
|
||||||
DocID: item.docID,
|
|
||||||
UserID: item.userID,
|
|
||||||
UserName: item.userName,
|
|
||||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block Locking Methods (Option B)
|
|
||||||
|
|
||||||
func (h *Hub) AcquireBlockLock(docID, blockID, userID, userName, userColor string, ttl time.Duration) (*domain.BlockLockInfo, bool, *domain.BlockLockInfo) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
key := docID + ":" + blockID
|
|
||||||
existing, exists := h.blockLocks[key]
|
|
||||||
if exists && existing.expiresAt.After(now) {
|
|
||||||
if existing.userID != userID {
|
|
||||||
info := &domain.BlockLockInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
BlockID: existing.blockID,
|
|
||||||
UserID: existing.userID,
|
|
||||||
UserName: existing.userName,
|
|
||||||
UserColor: existing.userColor,
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return nil, false, info
|
|
||||||
}
|
|
||||||
// Refresh lease
|
|
||||||
existing.expiresAt = now.Add(ttl)
|
|
||||||
if userName != "" {
|
|
||||||
existing.userName = userName
|
|
||||||
}
|
|
||||||
if userColor != "" {
|
|
||||||
existing.userColor = userColor
|
|
||||||
}
|
|
||||||
info := &domain.BlockLockInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
BlockID: existing.blockID,
|
|
||||||
UserID: existing.userID,
|
|
||||||
UserName: existing.userName,
|
|
||||||
UserColor: existing.userColor,
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return info, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
item := &blockLockItem{
|
|
||||||
docID: docID,
|
|
||||||
blockID: blockID,
|
|
||||||
userID: userID,
|
|
||||||
userName: userName,
|
|
||||||
userColor: userColor,
|
|
||||||
lockedAt: now,
|
|
||||||
expiresAt: now.Add(ttl),
|
|
||||||
}
|
|
||||||
h.blockLocks[key] = item
|
|
||||||
info := &domain.BlockLockInfo{
|
|
||||||
DocID: item.docID,
|
|
||||||
BlockID: item.blockID,
|
|
||||||
UserID: item.userID,
|
|
||||||
UserName: item.userName,
|
|
||||||
UserColor: item.userColor,
|
|
||||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return info, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) ReleaseBlockLock(docID, blockID, userID string) bool {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
key := docID + ":" + blockID
|
|
||||||
existing, exists := h.blockLocks[key]
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if userID != "" && existing.userID != userID && userID != "admin" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
delete(h.blockLocks, key)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) GetDocBlockLocks(docID string) []domain.BlockLockInfo {
|
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
var result []domain.BlockLockInfo
|
|
||||||
prefix := docID + ":"
|
|
||||||
for k, l := range h.blockLocks {
|
|
||||||
if strings.HasPrefix(k, prefix) && l.expiresAt.After(now) {
|
|
||||||
result = append(result, domain.BlockLockInfo{
|
|
||||||
DocID: l.docID,
|
|
||||||
BlockID: l.blockID,
|
|
||||||
UserID: l.userID,
|
|
||||||
UserName: l.userName,
|
|
||||||
UserColor: l.userColor,
|
|
||||||
ExpiresAt: l.expiresAt.Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live Session Methods (Opt-In Live Collaboration)
|
|
||||||
|
|
||||||
func (h *Hub) StartLiveSession(docID, userID, userName string, ttl time.Duration) (*domain.LiveSessionInfo, bool, *domain.LiveSessionInfo) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
existing, exists := h.liveSessions[docID]
|
|
||||||
if exists && existing.expiresAt.After(now) {
|
|
||||||
if existing.initiatorID != userID {
|
|
||||||
info := &domain.LiveSessionInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
InitiatorID: existing.initiatorID,
|
|
||||||
InitiatorName: existing.initiatorName,
|
|
||||||
StartedAt: existing.startedAt.Format(time.RFC3339),
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return nil, false, info
|
|
||||||
}
|
|
||||||
// Refresh lease
|
|
||||||
existing.expiresAt = now.Add(ttl)
|
|
||||||
info := &domain.LiveSessionInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
InitiatorID: existing.initiatorID,
|
|
||||||
InitiatorName: existing.initiatorName,
|
|
||||||
StartedAt: existing.startedAt.Format(time.RFC3339),
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return info, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
item := &liveSessionItem{
|
|
||||||
docID: docID,
|
|
||||||
initiatorID: userID,
|
|
||||||
initiatorName: userName,
|
|
||||||
startedAt: now,
|
|
||||||
expiresAt: now.Add(ttl),
|
|
||||||
}
|
|
||||||
h.liveSessions[docID] = item
|
|
||||||
info := &domain.LiveSessionInfo{
|
|
||||||
DocID: item.docID,
|
|
||||||
InitiatorID: item.initiatorID,
|
|
||||||
InitiatorName: item.initiatorName,
|
|
||||||
StartedAt: item.startedAt.Format(time.RFC3339),
|
|
||||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
return info, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) EndLiveSession(docID, userID string) bool {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
existing, exists := h.liveSessions[docID]
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if userID != "" && existing.initiatorID != userID && userID != "admin" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
delete(h.liveSessions, docID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) ForceEndLiveSession(docID string) bool {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := h.liveSessions[docID]; !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
delete(h.liveSessions, docID)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) GetLiveSession(docID string) *domain.LiveSessionInfo {
|
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
existing, exists := h.liveSessions[docID]
|
|
||||||
if !exists || existing.expiresAt.Before(now) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &domain.LiveSessionInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
InitiatorID: existing.initiatorID,
|
|
||||||
InitiatorName: existing.initiatorName,
|
|
||||||
StartedAt: existing.startedAt.Format(time.RFC3339),
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Hub) cleanupLocksLoop() {
|
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-h.stopCleanup:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
h.mu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
var expiredDocIDs []string
|
|
||||||
for docID, l := range h.locks {
|
|
||||||
if l.expiresAt.Before(now) {
|
|
||||||
expiredDocIDs = append(expiredDocIDs, docID)
|
|
||||||
delete(h.locks, docID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type expBlock struct {
|
|
||||||
docID string
|
|
||||||
blockID string
|
|
||||||
}
|
|
||||||
var expiredBlocks []expBlock
|
|
||||||
for k, bl := range h.blockLocks {
|
|
||||||
if bl.expiresAt.Before(now) {
|
|
||||||
expiredBlocks = append(expiredBlocks, expBlock{docID: bl.docID, blockID: bl.blockID})
|
|
||||||
delete(h.blockLocks, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var expiredLiveSessions []string
|
|
||||||
for docID, ls := range h.liveSessions {
|
|
||||||
if ls.expiresAt.Before(now) {
|
|
||||||
expiredLiveSessions = append(expiredLiveSessions, docID)
|
|
||||||
delete(h.liveSessions, docID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
for _, docID := range expiredDocIDs {
|
|
||||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
|
||||||
}
|
|
||||||
for _, b := range expiredBlocks {
|
|
||||||
h.BroadcastRoom("doc:"+b.docID, "DOC_BLOCK_UNLOCKED", map[string]string{
|
|
||||||
"docId": b.docID,
|
|
||||||
"blockId": b.blockID,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
for _, docID := range expiredLiveSessions {
|
|
||||||
h.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", map[string]string{
|
|
||||||
"docId": docID,
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close gracefully shuts down the Hub, stops background tickers, flushes dirty document rooms to DB,
|
|
||||||
// and immediately disconnects all WebSocket and SSE connections so HTTP shutdown completes in milliseconds.
|
|
||||||
func (h *Hub) Close() {
|
func (h *Hub) Close() {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if h.closed {
|
if h.closed {
|
||||||
@@ -742,19 +282,6 @@ func (h *Hub) Close() {
|
|||||||
}
|
}
|
||||||
h.closed = true
|
h.closed = true
|
||||||
|
|
||||||
if h.stopCleanup != nil {
|
|
||||||
select {
|
|
||||||
case <-h.stopCleanup:
|
|
||||||
default:
|
|
||||||
close(h.stopCleanup)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
docRooms := make([]*DocRoom, 0, len(h.docRooms))
|
|
||||||
for _, r := range h.docRooms {
|
|
||||||
docRooms = append(docRooms, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
allWsClients := make([]*Client, 0, len(h.allClients))
|
allWsClients := make([]*Client, 0, len(h.allClients))
|
||||||
for c := range h.allClients {
|
for c := range h.allClients {
|
||||||
allWsClients = append(allWsClients, c)
|
allWsClients = append(allWsClients, c)
|
||||||
@@ -766,28 +293,11 @@ func (h *Hub) Close() {
|
|||||||
}
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// 1. Flush any active dirty document rooms and close doc WS connections
|
|
||||||
for _, r := range docRooms {
|
|
||||||
r.Flush()
|
|
||||||
r.mu.Lock()
|
|
||||||
if r.debounceTimer != nil {
|
|
||||||
r.debounceTimer.Stop()
|
|
||||||
r.debounceTimer = nil
|
|
||||||
}
|
|
||||||
for c := range r.wsClients {
|
|
||||||
_ = c.conn.Close()
|
|
||||||
}
|
|
||||||
r.wsClients = make(map[*DocWSClient]bool)
|
|
||||||
r.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Close all WebSocket connections
|
|
||||||
for _, c := range allWsClients {
|
for _, c := range allWsClients {
|
||||||
_ = c.conn.Close()
|
_ = c.conn.Close()
|
||||||
c.closeSend()
|
c.closeSend()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Close all SSE client channels so ServeSSE select loop exits immediately
|
|
||||||
for _, c := range allSSEClients {
|
for _, c := range allSSEClients {
|
||||||
c.closeSend()
|
c.closeSend()
|
||||||
}
|
}
|
||||||
@@ -1116,21 +626,6 @@ func (c *Client) readPump() {
|
|||||||
c.hub.LeaveRoom(c, room)
|
c.hub.LeaveRoom(c, room)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if docID != "" {
|
|
||||||
switch incoming.Event {
|
|
||||||
case "LOCK_DOC":
|
|
||||||
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
|
|
||||||
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
|
||||||
}
|
|
||||||
case "HEARTBEAT_DOC":
|
|
||||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
|
||||||
case "UNLOCK_DOC":
|
|
||||||
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
|
||||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user