update nextcloud handling
This commit is contained in:
@@ -113,7 +113,18 @@ class NextcloudController {
|
|||||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||||
return;
|
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 });
|
res.status(200).json({ success: true, data: messages });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
|||||||
@@ -195,25 +195,53 @@ async function getAllConversations(loginName: string, appPassword: string): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessages(token: string, loginName: string, appPassword: string): Promise<NextcloudChatMessage[]> {
|
interface GetMessagesOptions {
|
||||||
|
lookIntoFuture?: boolean;
|
||||||
|
lastKnownMessageId?: number;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessages(token: string, loginName: string, appPassword: string, options?: GetMessagesOptions): Promise<NextcloudChatMessage[]> {
|
||||||
const baseUrl = environment.nextcloudUrl;
|
const baseUrl = environment.nextcloudUrl;
|
||||||
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||||
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
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<string, any> = {
|
||||||
|
lookIntoFuture: lookIntoFuture ? 1 : 0,
|
||||||
|
limit: lookIntoFuture ? 100 : 50,
|
||||||
|
setReadMarker: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (lookIntoFuture) {
|
||||||
|
params.lastKnownMessageId = options?.lastKnownMessageId ?? 0;
|
||||||
|
params.timeout = ncTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.get(
|
const response = await httpClient.get(
|
||||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
|
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}`,
|
||||||
{
|
{
|
||||||
params: { lookIntoFuture: 0, limit: 50, setReadMarker: 0 },
|
params,
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
'Authorization': `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`,
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
'Accept': 'application/json',
|
'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 ?? [];
|
const messages: any[] = response.data?.ocs?.data ?? [];
|
||||||
return messages.map((m: any) => ({
|
return messages.map((m: any) => ({
|
||||||
id: m.id,
|
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 };
|
export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead };
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ 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';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
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 { nextcloudApi } from '../../services/nextcloud';
|
||||||
import { useChat } from '../../contexts/ChatContext';
|
import { useChat } from '../../contexts/ChatContext';
|
||||||
import { useLayout } from '../../contexts/LayoutContext';
|
import { useLayout } from '../../contexts/LayoutContext';
|
||||||
import ChatMessage from './ChatMessage';
|
import ChatMessage from './ChatMessage';
|
||||||
|
import type { NextcloudMessage } from '../../types/nextcloud.types';
|
||||||
|
|
||||||
|
const LONG_POLL_TIMEOUT = 25;
|
||||||
|
|
||||||
const ChatMessageView: React.FC = () => {
|
const ChatMessageView: React.FC = () => {
|
||||||
const { selectedRoomToken, selectRoom, rooms, loginName } = useChat();
|
const { selectedRoomToken, selectRoom, rooms, loginName } = useChat();
|
||||||
@@ -17,41 +21,84 @@ const ChatMessageView: React.FC = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const prevActiveRef = useRef(false);
|
const [messages, setMessages] = useState<NextcloudMessage[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const lastMsgIdRef = useRef<number>(0);
|
||||||
|
|
||||||
// Invalidate messages immediately when this room + panel becomes active
|
// Initial fetch + long-poll loop for near-instant message delivery
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const active = !!selectedRoomToken && chatPanelOpen;
|
if (!selectedRoomToken || !chatPanelOpen) return;
|
||||||
if (active && !prevActiveRef.current) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] });
|
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);
|
||||||
}
|
}
|
||||||
prevActiveRef.current = active;
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
currentAbort?.abort();
|
||||||
|
};
|
||||||
}, [selectedRoomToken, chatPanelOpen, queryClient]);
|
}, [selectedRoomToken, chatPanelOpen, queryClient]);
|
||||||
|
|
||||||
const room = rooms.find((r) => r.token === selectedRoomToken);
|
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({
|
const sendMutation = useMutation({
|
||||||
mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message),
|
mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mark room as read while viewing messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRoomToken && chatPanelOpen) {
|
if (selectedRoomToken && chatPanelOpen) {
|
||||||
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
|
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
}, [selectedRoomToken, chatPanelOpen, queryClient, messages?.length]);
|
}, [selectedRoomToken, chatPanelOpen, queryClient, messages.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
@@ -93,13 +140,19 @@ const ChatMessageView: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
|
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
|
||||||
{[...(messages ?? [])].reverse().map((msg) => (
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg}
|
message={msg}
|
||||||
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
|
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -127,3 +180,4 @@ const ChatMessageView: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ChatMessageView;
|
export default ChatMessageView;
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,23 @@ export const nextcloudApi = {
|
|||||||
.then((r) => r.data.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
getMessages(token: string): Promise<NextcloudMessage[]> {
|
getMessages(
|
||||||
|
token: string,
|
||||||
|
options?: { lookIntoFuture?: boolean; lastKnownMessageId?: number; timeout?: number },
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<NextcloudMessage[]> {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
if (options?.lookIntoFuture) {
|
||||||
|
params.lookIntoFuture = '1';
|
||||||
|
params.lastKnownMessageId = options.lastKnownMessageId ?? 0;
|
||||||
|
params.timeout = options.timeout ?? 25;
|
||||||
|
}
|
||||||
return api
|
return api
|
||||||
.get<ApiResponse<NextcloudMessage[]>>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`)
|
.get<ApiResponse<NextcloudMessage[]>>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`, {
|
||||||
|
params,
|
||||||
|
signal,
|
||||||
|
...(options?.lookIntoFuture && { timeout: ((options.timeout ?? 25) + 7) * 1000 }),
|
||||||
|
})
|
||||||
.then((r) => r.data.data);
|
.then((r) => r.data.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { chromium, Page } from '@playwright/test';
|
|||||||
import { FdiskMember, FdiskAusbildung } from './types';
|
import { FdiskMember, FdiskAusbildung } from './types';
|
||||||
|
|
||||||
const BASE_URL = process.env.FDISK_BASE_URL ?? 'https://app.fdisk.at';
|
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`;
|
const MEMBERS_URL = `${BASE_URL}/fdisk/module/vws/Start.aspx`;
|
||||||
|
|
||||||
function log(msg: string) {
|
function log(msg: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user