update
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user