feat(realtime): implement SSE fallback, dual-transport hub & proxy resilience (resolves FLAW-03)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
80bb74b5b2
commit
6343d62bab
10 files changed
+1402
-240
No files matched your search
@@ -10,7 +10,7 @@ import {
|
||||
Hash,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
@@ -86,6 +86,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
||||
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||
const [newListTitle, setNewListTitle] = useState('');
|
||||
const [newListIcon, setNewListIcon] = useState('🛒');
|
||||
@@ -105,6 +106,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const realtimeConnRef = useRef<any>(null);
|
||||
|
||||
const loadLists = async () => {
|
||||
try {
|
||||
@@ -147,83 +149,79 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
useEffect(() => {
|
||||
if (!activeList?.id) return;
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
if (realtimeConnRef.current) {
|
||||
try {
|
||||
realtimeConnRef.current.close();
|
||||
} catch {}
|
||||
realtimeConnRef.current = null;
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
try {
|
||||
ws = new WebSocket(buildWebSocketURL(activeList.id));
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setWsConnected(true);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setWsConnected(false);
|
||||
};
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(evt.data);
|
||||
const msgListId = msg.listId || (msg as any).list_id;
|
||||
if (msg.event === 'ITEM_CREATED' && msgListId) {
|
||||
const newItem: ListItem = msg.payload;
|
||||
if (!newItem || !newItem.id) return;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
if (l.items.some((i) => i.id === newItem.id)) return l;
|
||||
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
|
||||
})
|
||||
);
|
||||
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
|
||||
const updatedItem: ListItem = msg.payload;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
return {
|
||||
...l,
|
||||
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
|
||||
};
|
||||
})
|
||||
);
|
||||
} else if (msg.event === 'ITEM_DELETED' && msgListId) {
|
||||
const deletedId = msg.payload?.itemId;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
|
||||
})
|
||||
);
|
||||
} else if (msg.event === 'LIST_CLEARED' && msgListId) {
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
return { ...l, items: l.items.filter((i) => !i.isCompleted) };
|
||||
})
|
||||
);
|
||||
} else if (
|
||||
msg.event === 'LIST_CREATED' ||
|
||||
msg.event === 'LIST_DELETED' ||
|
||||
msg.event === 'LIST_SHARED' ||
|
||||
msg.event === 'LIST_UNSHARED' ||
|
||||
msg.event === 'ITEM_REORDERED' ||
|
||||
msg.event === 'LIST_UPDATED'
|
||||
) {
|
||||
loadLists();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
const conn = createRealtimeConnection({
|
||||
listId: activeList.id,
|
||||
onStatusChange: (status) => {
|
||||
setWsConnected(status.connected);
|
||||
setRealtimeTransport(status.transport);
|
||||
},
|
||||
onMessage: (msg: WSMessage) => {
|
||||
const msgListId = msg.listId || (msg as any).list_id;
|
||||
if (msg.event === 'ITEM_CREATED' && msgListId) {
|
||||
const newItem: ListItem = msg.payload;
|
||||
if (!newItem || !newItem.id) return;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
if (l.items.some((i) => i.id === newItem.id)) return l;
|
||||
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
|
||||
})
|
||||
);
|
||||
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
|
||||
const updatedItem: ListItem = msg.payload;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
return {
|
||||
...l,
|
||||
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
|
||||
};
|
||||
})
|
||||
);
|
||||
} else if (msg.event === 'ITEM_DELETED' && msgListId) {
|
||||
const deletedId = msg.payload?.itemId;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
|
||||
})
|
||||
);
|
||||
} else if (msg.event === 'LIST_CLEARED' && msgListId) {
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.id !== msgListId) return l;
|
||||
return { ...l, items: l.items.filter((i) => !i.isCompleted) };
|
||||
})
|
||||
);
|
||||
} else if (
|
||||
msg.event === 'LIST_CREATED' ||
|
||||
msg.event === 'LIST_DELETED' ||
|
||||
msg.event === 'LIST_SHARED' ||
|
||||
msg.event === 'LIST_UNSHARED' ||
|
||||
msg.event === 'ITEM_REORDERED' ||
|
||||
msg.event === 'LIST_UPDATED'
|
||||
) {
|
||||
loadLists();
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
setWsConnected(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
realtimeConnRef.current = conn;
|
||||
|
||||
return () => {
|
||||
if (ws) ws.close();
|
||||
if (realtimeConnRef.current) {
|
||||
try {
|
||||
realtimeConnRef.current.close();
|
||||
} catch {}
|
||||
realtimeConnRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeList?.id]);
|
||||
|
||||
@@ -821,12 +819,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={`h-9 w-9 flex items-center justify-center rounded-btn border ${
|
||||
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
|
||||
wsConnected
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
? realtimeTransport === 'sse'
|
||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
: 'bg-canvas border-border text-muted'
|
||||
}`}
|
||||
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
||||
title={
|
||||
wsConnected
|
||||
? realtimeTransport === 'sse'
|
||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
||||
: 'Live synchronisiert (WebSocket online)'
|
||||
: 'Offline'
|
||||
}
|
||||
>
|
||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
@@ -927,12 +933,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
|
||||
{/* Live Sync Status */}
|
||||
<div
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border ${
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
||||
wsConnected
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
? realtimeTransport === 'sse'
|
||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||
: 'bg-canvas border-border text-muted'
|
||||
}`}
|
||||
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
||||
title={
|
||||
wsConnected
|
||||
? realtimeTransport === 'sse'
|
||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
||||
: 'Live synchronisiert (WebSocket online)'
|
||||
: 'Offline'
|
||||
}
|
||||
>
|
||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user