This commit is contained in:
Matthias Hochmeister
2026-03-13 16:12:11 +01:00
parent 602d9fd5b9
commit 02d9d808b2
3 changed files with 66 additions and 13 deletions

View File

@@ -16,6 +16,7 @@ interface ChatMessageProps {
isOwnMessage: boolean; isOwnMessage: boolean;
onReplyClick?: (message: NextcloudMessage) => void; onReplyClick?: (message: NextcloudMessage) => void;
onReactionToggled?: (messageId: number) => void; onReactionToggled?: (messageId: number) => void;
reactionsOverride?: { reactions: Record<string, number>; reactionsSelf: string[] };
} }
function hasFileParams(params?: Record<string, any>): boolean { function hasFileParams(params?: Record<string, any>): boolean {
@@ -27,7 +28,7 @@ function hasPollParam(params?: Record<string, any>): boolean {
return params?.object?.type === 'talk-poll'; return params?.object?.type === 'talk-poll';
} }
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, onReplyClick, onReactionToggled }) => { const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, onReplyClick, onReactionToggled, reactionsOverride }) => {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -79,8 +80,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, onRepl
const textBeforeFile = isFileMessage ? message.message.split('{file}')[0].trim() : null; const textBeforeFile = isFileMessage ? message.message.split('{file}')[0].trim() : null;
const reactions = message.reactions ?? {}; const reactions = reactionsOverride?.reactions ?? message.reactions ?? {};
const reactionsSelf = message.reactionsSelf ?? []; const reactionsSelf = reactionsOverride?.reactionsSelf ?? message.reactionsSelf ?? [];
const hasReactions = Object.keys(reactions).length > 0; const hasReactions = Object.keys(reactions).length > 0;
return ( return (

View File

@@ -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 Box from '@mui/material/Box';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@@ -33,6 +33,25 @@ const ChatMessageView: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const lastMsgIdRef = useRef<number>(0); const lastMsgIdRef = useRef<number>(0);
// reactions override map: messageId → { reactions, reactionsSelf }
const [reactionsMap, setReactionsMap] = useState<Map<number, { reactions: Record<string, number>; reactionsSelf: string[] }>>(new Map());
const fetchReactions = useCallback(async (msgId: number) => {
if (!selectedRoomToken) return;
try {
const detailed = await nextcloudApi.getReactions(selectedRoomToken, msgId);
const counts: Record<string, number> = {};
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 // Initial fetch + long-poll loop for near-instant message delivery
useEffect(() => { useEffect(() => {
if (!selectedRoomToken || !chatPanelOpen) return; if (!selectedRoomToken || !chatPanelOpen) return;
@@ -43,6 +62,7 @@ const ChatMessageView: React.FC = () => {
async function run() { async function run() {
setIsLoading(true); setIsLoading(true);
setMessages([]); setMessages([]);
setReactionsMap(new Map());
lastMsgIdRef.current = 0; lastMsgIdRef.current = 0;
// Step 1: Initial fetch — Nextcloud returns newest-first, so reverse for chronological display // Step 1: Initial fetch — Nextcloud returns newest-first, so reverse for chronological display
@@ -50,7 +70,11 @@ const ChatMessageView: React.FC = () => {
try { try {
const initial = await nextcloudApi.getMessages(selectedRoomToken!, undefined, currentAbort.signal); const initial = await nextcloudApi.getMessages(selectedRoomToken!, undefined, currentAbort.signal);
if (cancelled) return; 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; lastMsgIdRef.current = initial.length > 0 ? Math.max(...initial.map(m => m.id)) : 0;
setIsLoading(false); setIsLoading(false);
} catch { } catch {
@@ -69,10 +93,29 @@ const ChatMessageView: React.FC = () => {
); );
if (cancelled) break; if (cancelled) break;
if (newMsgs.length > 0) { if (newMsgs.length > 0) {
// Long-poll returns ascending order — append directly // Separate reaction events from real messages
setMessages(prev => [...prev, ...newMsgs]); 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)); lastMsgIdRef.current = Math.max(...newMsgs.map(m => m.id));
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
// Refresh reaction counts for affected messages
const affectedIds = new Set<number>();
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) { } catch (err: any) {
if (cancelled) break; if (cancelled) break;
@@ -88,7 +131,7 @@ const ChatMessageView: React.FC = () => {
cancelled = true; cancelled = true;
currentAbort?.abort(); currentAbort?.abort();
}; };
}, [selectedRoomToken, chatPanelOpen, queryClient]); }, [selectedRoomToken, chatPanelOpen, queryClient, fetchReactions]);
const room = rooms.find((r) => r.token === selectedRoomToken); const room = rooms.find((r) => r.token === selectedRoomToken);
@@ -128,8 +171,8 @@ const ChatMessageView: React.FC = () => {
} }
}; };
const handleReactionToggled = (_messageId: number) => { const handleReactionToggled = (messageId: number) => {
// reactions are stored in message objects; for now just mark rooms as potentially changed fetchReactions(messageId);
}; };
const handleUploadFile = async (file: File) => { const handleUploadFile = async (file: File) => {
@@ -212,6 +255,7 @@ const ChatMessageView: React.FC = () => {
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName} isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
onReplyClick={setReplyToMessage} onReplyClick={setReplyToMessage}
onReactionToggled={handleReactionToggled} onReactionToggled={handleReactionToggled}
reactionsOverride={reactionsMap.get(msg.id)}
/> />
)) ))
)} )}

View File

@@ -167,7 +167,9 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
const cells = Array.from(tr.querySelectorAll('td')); const cells = Array.from(tr.querySelectorAll('td'));
const val = (i: number) => { const val = (i: number) => {
const a = cells[i]?.querySelector('a'); 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; const href = (tr.querySelector('a') as HTMLAnchorElement | null)?.href ?? null;
return { return {
@@ -186,10 +188,16 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
); );
log(`Parsed ${rows.length} rows from member table`); 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[] = []; const members: FdiskMember[] = [];
for (const row of rows) { 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); const abmeldedatum = parseDate(row.abmeldedatum);
members.push({ members.push({
standesbuchNr: row.standesbuchNr, standesbuchNr: row.standesbuchNr,