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;
onReplyClick?: (message: NextcloudMessage) => void;
onReactionToggled?: (messageId: number) => void;
reactionsOverride?: { reactions: Record<string, number>; reactionsSelf: string[] };
}
function hasFileParams(params?: Record<string, any>): boolean {
@@ -27,7 +28,7 @@ function hasPollParam(params?: Record<string, any>): boolean {
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 longPressTimer = 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 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 (

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 TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
@@ -33,6 +33,25 @@ const ChatMessageView: React.FC = () => {
const fileInputRef = useRef<HTMLInputElement>(null);
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
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,11 +93,30 @@ 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));
// 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) {
if (cancelled) break;
if (err?.code === 'ERR_CANCELED' || err?.name === 'CanceledError') 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)}
/>
))
)}

View File

@@ -167,7 +167,9 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
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<FdiskMember[]> {
);
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,