diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index 84e9047..3963d60 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -16,6 +16,7 @@ interface ChatMessageProps { isOwnMessage: boolean; onReplyClick?: (message: NextcloudMessage) => void; onReactionToggled?: (messageId: number) => void; + reactionsOverride?: { reactions: Record; reactionsSelf: string[] }; } function hasFileParams(params?: Record): boolean { @@ -27,7 +28,7 @@ function hasPollParam(params?: Record): boolean { return params?.object?.type === 'talk-poll'; } -const ChatMessage: React.FC = ({ message, isOwnMessage, onReplyClick, onReactionToggled }) => { +const ChatMessage: React.FC = ({ message, isOwnMessage, onReplyClick, onReactionToggled, reactionsOverride }) => { const [hovered, setHovered] = useState(false); const longPressTimer = useRef | null>(null); const hideTimer = useRef | null>(null); @@ -79,8 +80,8 @@ const ChatMessage: React.FC = ({ message, isOwnMessage, onRepl const textBeforeFile = isFileMessage ? message.message.split('{file}')[0].trim() : null; - const reactions = message.reactions ?? {}; - const reactionsSelf = message.reactionsSelf ?? []; + const reactions = reactionsOverride?.reactions ?? message.reactions ?? {}; + const reactionsSelf = reactionsOverride?.reactionsSelf ?? message.reactionsSelf ?? []; const hasReactions = Object.keys(reactions).length > 0; return ( diff --git a/frontend/src/components/chat/ChatMessageView.tsx b/frontend/src/components/chat/ChatMessageView.tsx index c48dcd9..950d5c1 100644 --- a/frontend/src/components/chat/ChatMessageView.tsx +++ b/frontend/src/components/chat/ChatMessageView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import IconButton from '@mui/material/IconButton'; @@ -33,6 +33,25 @@ const ChatMessageView: React.FC = () => { const fileInputRef = useRef(null); const lastMsgIdRef = useRef(0); + // reactions override map: messageId → { reactions, reactionsSelf } + const [reactionsMap, setReactionsMap] = useState; reactionsSelf: string[] }>>(new Map()); + + const fetchReactions = useCallback(async (msgId: number) => { + if (!selectedRoomToken) return; + try { + const detailed = await nextcloudApi.getReactions(selectedRoomToken, msgId); + const counts: Record = {}; + const self: string[] = []; + for (const [emoji, actors] of Object.entries(detailed)) { + counts[emoji] = actors.length; + if (actors.some((a) => a.actorId === loginName)) self.push(emoji); + } + setReactionsMap((prev) => new Map(prev).set(msgId, { reactions: counts, reactionsSelf: self })); + } catch { + // ignore — reactions are best-effort + } + }, [selectedRoomToken, loginName]); + // Initial fetch + long-poll loop for near-instant message delivery useEffect(() => { if (!selectedRoomToken || !chatPanelOpen) return; @@ -43,6 +62,7 @@ const ChatMessageView: React.FC = () => { async function run() { setIsLoading(true); setMessages([]); + setReactionsMap(new Map()); lastMsgIdRef.current = 0; // Step 1: Initial fetch — Nextcloud returns newest-first, so reverse for chronological display @@ -50,7 +70,11 @@ const ChatMessageView: React.FC = () => { try { const initial = await nextcloudApi.getMessages(selectedRoomToken!, undefined, currentAbort.signal); if (cancelled) return; - setMessages([...initial].reverse()); + // Filter reaction system messages — these appear as chat events but should not be shown + const displayable = initial.filter( + (m) => m.systemMessage !== 'reaction' && m.systemMessage !== 'reaction_deleted', + ); + setMessages([...displayable].reverse()); lastMsgIdRef.current = initial.length > 0 ? Math.max(...initial.map(m => m.id)) : 0; setIsLoading(false); } catch { @@ -69,10 +93,29 @@ const ChatMessageView: React.FC = () => { ); if (cancelled) break; if (newMsgs.length > 0) { - // Long-poll returns ascending order — append directly - setMessages(prev => [...prev, ...newMsgs]); + // Separate reaction events from real messages + const reactionEvents = newMsgs.filter( + (m) => m.systemMessage === 'reaction' || m.systemMessage === 'reaction_deleted', + ); + const displayMsgs = newMsgs.filter( + (m) => m.systemMessage !== 'reaction' && m.systemMessage !== 'reaction_deleted', + ); + + // Advance cursor using ALL messages (including reaction events) lastMsgIdRef.current = Math.max(...newMsgs.map(m => m.id)); - queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + + // Refresh reaction counts for affected messages + const affectedIds = new Set(); + for (const rm of reactionEvents) { + const parentId = parseInt(rm.messageParameters?.message?.id ?? '0', 10); + if (parentId > 0) affectedIds.add(parentId); + } + affectedIds.forEach((id) => fetchReactions(id)); + + if (displayMsgs.length > 0) { + setMessages(prev => [...prev, ...displayMsgs]); + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + } } } catch (err: any) { if (cancelled) break; @@ -88,7 +131,7 @@ const ChatMessageView: React.FC = () => { cancelled = true; currentAbort?.abort(); }; - }, [selectedRoomToken, chatPanelOpen, queryClient]); + }, [selectedRoomToken, chatPanelOpen, queryClient, fetchReactions]); const room = rooms.find((r) => r.token === selectedRoomToken); @@ -128,8 +171,8 @@ const ChatMessageView: React.FC = () => { } }; - const handleReactionToggled = (_messageId: number) => { - // reactions are stored in message objects; for now just mark rooms as potentially changed + const handleReactionToggled = (messageId: number) => { + fetchReactions(messageId); }; const handleUploadFile = async (file: File) => { @@ -212,6 +255,7 @@ const ChatMessageView: React.FC = () => { isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName} onReplyClick={setReplyToMessage} onReactionToggled={handleReactionToggled} + reactionsOverride={reactionsMap.get(msg.id)} /> )) )} diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index 397f7f4..e8cd084 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -167,7 +167,9 @@ async function scrapeMembers(frame: Frame): Promise { const cells = Array.from(tr.querySelectorAll('td')); const val = (i: number) => { const a = cells[i]?.querySelector('a'); - return (a?.getAttribute('title') ?? cells[i]?.textContent ?? '').trim(); + const title = a?.getAttribute('title')?.trim(); + // Use title only if non-empty; otherwise fall back to textContent + return (title || cells[i]?.textContent || '').trim(); }; const href = (tr.querySelector('a') as HTMLAnchorElement | null)?.href ?? null; return { @@ -186,10 +188,16 @@ async function scrapeMembers(frame: Frame): Promise { ); log(`Parsed ${rows.length} rows from member table`); + for (const row of rows) { + log(` Row: StNr="${row.standesbuchNr}" Vorname="${row.vorname}" Zuname="${row.zuname}" Status="${row.status}"`); + } const members: FdiskMember[] = []; for (const row of rows) { - if (!row.standesbuchNr || !row.vorname || !row.zuname) continue; + if (!row.standesbuchNr || !row.vorname || !row.zuname) { + log(` SKIP: StNr="${row.standesbuchNr}" Vorname="${row.vorname}" Zuname="${row.zuname}" — missing required field`); + continue; + } const abmeldedatum = parseDate(row.abmeldedatum); members.push({ standesbuchNr: row.standesbuchNr,