diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts index 8cbf787..7263f6c 100644 --- a/backend/src/controllers/nextcloud.controller.ts +++ b/backend/src/controllers/nextcloud.controller.ts @@ -113,7 +113,18 @@ class NextcloudController { res.status(400).json({ success: false, message: 'Room token fehlt' }); return; } - const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword); + const lookIntoFuture = req.query.lookIntoFuture === '1'; + const lastKnownMessageId = req.query.lastKnownMessageId + ? parseInt(req.query.lastKnownMessageId as string, 10) + : undefined; + const timeout = req.query.timeout + ? Math.min(parseInt(req.query.timeout as string, 10), 25) + : 25; + const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword, { + lookIntoFuture, + lastKnownMessageId, + timeout, + }); res.status(200).json({ success: true, data: messages }); } catch (error: any) { if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { diff --git a/backend/src/services/nextcloud.service.ts b/backend/src/services/nextcloud.service.ts index 00d71ee..1f7cffa 100644 --- a/backend/src/services/nextcloud.service.ts +++ b/backend/src/services/nextcloud.service.ts @@ -195,25 +195,53 @@ async function getAllConversations(loginName: string, appPassword: string): Prom } } -async function getMessages(token: string, loginName: string, appPassword: string): Promise { +interface GetMessagesOptions { + lookIntoFuture?: boolean; + lastKnownMessageId?: number; + timeout?: number; +} + +async function getMessages(token: string, loginName: string, appPassword: string, options?: GetMessagesOptions): Promise { const baseUrl = environment.nextcloudUrl; if (!baseUrl || !isValidServiceUrl(baseUrl)) { throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL'); } + const lookIntoFuture = options?.lookIntoFuture ?? false; + const ncTimeout = options?.timeout ?? 25; + + const params: Record = { + lookIntoFuture: lookIntoFuture ? 1 : 0, + limit: lookIntoFuture ? 100 : 50, + setReadMarker: 0, + }; + + if (lookIntoFuture) { + params.lastKnownMessageId = options?.lastKnownMessageId ?? 0; + params.timeout = ncTimeout; + } + try { const response = await httpClient.get( `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`, { - params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 }, + params, headers: { 'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`, 'OCS-APIRequest': 'true', 'Accept': 'application/json', }, + ...(lookIntoFuture && { + timeout: (ncTimeout + 5) * 1000, + validateStatus: (s: number) => (s >= 200 && s < 300) || s === 304, + }), }, ); + if (response.status === 304) { + return []; + } + const messages: any[] = response.data?.ocs?.data ?? []; return messages.map((m: any) => ({ id: m.id, @@ -384,5 +412,5 @@ async function getConversations(loginName: string, appPassword: string): Promise } } -export type { NextcloudChatMessage }; +export type { NextcloudChatMessage, GetMessagesOptions }; export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead }; diff --git a/frontend/src/components/chat/ChatMessageView.tsx b/frontend/src/components/chat/ChatMessageView.tsx index 85c27fd..f6176b8 100644 --- a/frontend/src/components/chat/ChatMessageView.tsx +++ b/frontend/src/components/chat/ChatMessageView.tsx @@ -3,13 +3,17 @@ import Box from '@mui/material/Box'; import TextField from '@mui/material/TextField'; import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; import SendIcon from '@mui/icons-material/Send'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { nextcloudApi } from '../../services/nextcloud'; import { useChat } from '../../contexts/ChatContext'; import { useLayout } from '../../contexts/LayoutContext'; import ChatMessage from './ChatMessage'; +import type { NextcloudMessage } from '../../types/nextcloud.types'; + +const LONG_POLL_TIMEOUT = 25; const ChatMessageView: React.FC = () => { const { selectedRoomToken, selectRoom, rooms, loginName } = useChat(); @@ -17,41 +21,84 @@ const ChatMessageView: React.FC = () => { const queryClient = useQueryClient(); const messagesEndRef = useRef(null); const [input, setInput] = useState(''); - const prevActiveRef = useRef(false); + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const lastMsgIdRef = useRef(0); - // Invalidate messages immediately when this room + panel becomes active + // Initial fetch + long-poll loop for near-instant message delivery useEffect(() => { - const active = !!selectedRoomToken && chatPanelOpen; - if (active && !prevActiveRef.current) { - queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] }); + if (!selectedRoomToken || !chatPanelOpen) return; + + let cancelled = false; + let currentAbort: AbortController | null = null; + + async function run() { + setIsLoading(true); + setMessages([]); + lastMsgIdRef.current = 0; + + // Step 1: Initial fetch — Nextcloud returns newest-first, so reverse for chronological display + currentAbort = new AbortController(); + try { + const initial = await nextcloudApi.getMessages(selectedRoomToken!, undefined, currentAbort.signal); + if (cancelled) return; + setMessages([...initial].reverse()); + lastMsgIdRef.current = initial.length > 0 ? Math.max(...initial.map(m => m.id)) : 0; + setIsLoading(false); + } catch { + if (cancelled) return; + setIsLoading(false); + } + + // Step 2: Long-poll loop — blocks on server until new messages arrive + while (!cancelled) { + currentAbort = new AbortController(); + try { + const newMsgs = await nextcloudApi.getMessages( + selectedRoomToken!, + { lookIntoFuture: true, lastKnownMessageId: lastMsgIdRef.current, timeout: LONG_POLL_TIMEOUT }, + currentAbort.signal, + ); + if (cancelled) break; + if (newMsgs.length > 0) { + // Long-poll returns ascending order — append directly + setMessages(prev => [...prev, ...newMsgs]); + lastMsgIdRef.current = Math.max(...newMsgs.map(m => m.id)); + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + } + } catch (err: any) { + if (cancelled) break; + if (err?.code === 'ERR_CANCELED' || err?.name === 'CanceledError') break; + // Brief backoff before retrying on error + await new Promise(r => setTimeout(r, 3000)); + } + } } - prevActiveRef.current = active; + + run(); + return () => { + cancelled = true; + currentAbort?.abort(); + }; }, [selectedRoomToken, chatPanelOpen, queryClient]); const room = rooms.find((r) => r.token === selectedRoomToken); - const { data: messages } = useQuery({ - queryKey: ['nextcloud', 'messages', selectedRoomToken], - queryFn: () => nextcloudApi.getMessages(selectedRoomToken!), - enabled: !!selectedRoomToken && chatPanelOpen, - refetchInterval: 5000, - }); - const sendMutation = useMutation({ mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] }); queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); }, }); + // Mark room as read while viewing messages useEffect(() => { if (selectedRoomToken && chatPanelOpen) { nextcloudApi.markAsRead(selectedRoomToken).then(() => { queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); }).catch(() => {}); } - }, [selectedRoomToken, chatPanelOpen, queryClient, messages?.length]); + }, [selectedRoomToken, chatPanelOpen, queryClient, messages.length]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -93,13 +140,19 @@ const ChatMessageView: React.FC = () => { - {[...(messages ?? [])].reverse().map((msg) => ( - - ))} + {isLoading ? ( + + + + ) : ( + messages.map((msg) => ( + + )) + )}
@@ -127,3 +180,4 @@ const ChatMessageView: React.FC = () => { }; export default ChatMessageView; + diff --git a/frontend/src/services/nextcloud.ts b/frontend/src/services/nextcloud.ts index 46f23a0..5297265 100644 --- a/frontend/src/services/nextcloud.ts +++ b/frontend/src/services/nextcloud.ts @@ -37,9 +37,23 @@ export const nextcloudApi = { .then((r) => r.data.data); }, - getMessages(token: string): Promise { + getMessages( + token: string, + options?: { lookIntoFuture?: boolean; lastKnownMessageId?: number; timeout?: number }, + signal?: AbortSignal, + ): Promise { + const params: Record = {}; + if (options?.lookIntoFuture) { + params.lookIntoFuture = '1'; + params.lastKnownMessageId = options.lastKnownMessageId ?? 0; + params.timeout = options.timeout ?? 25; + } return api - .get>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`) + .get>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`, { + params, + signal, + ...(options?.lookIntoFuture && { timeout: ((options.timeout ?? 25) + 7) * 1000 }), + }) .then((r) => r.data.data); }, diff --git a/sync/src/scraper.ts b/sync/src/scraper.ts index 097d584..b38691a 100644 --- a/sync/src/scraper.ts +++ b/sync/src/scraper.ts @@ -2,7 +2,7 @@ import { chromium, Page } from '@playwright/test'; import { FdiskMember, FdiskAusbildung } from './types'; const BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at'; -const LOGIN_URL = `${BASE_URL}/fdisk/`; +const LOGIN_URL = `${BASE_URL}/fdisk/module/vws/logins/logins.aspx`; const MEMBERS_URL = `${BASE_URL}/fdisk/module/vws/Start.aspx`; function log(msg: string) {