adding chat features, admin features and bug fixes

This commit is contained in:
Matthias Hochmeister
2026-03-12 08:16:34 +01:00
parent 7b14e3d5ba
commit 31f1414e06
43 changed files with 2610 additions and 16 deletions

View File

@@ -0,0 +1,72 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import type { NextcloudMessage } from '../../types/nextcloud.types';
interface ChatMessageProps {
message: NextcloudMessage;
isOwnMessage: boolean;
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
if (message.systemMessage) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 0.5 }}>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
{message.message} - {time}
</Typography>
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
justifyContent: isOwnMessage ? 'flex-end' : 'flex-start',
my: 0.5,
px: 1,
}}
>
<Paper
elevation={0}
sx={{
px: 1.5,
py: 0.75,
maxWidth: '80%',
bgcolor: isOwnMessage ? 'primary.main' : (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200',
color: isOwnMessage ? 'primary.contrastText' : 'text.primary',
borderRadius: 2,
}}
>
{!isOwnMessage && (
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>
{message.actorDisplayName}
</Typography>
)}
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{message.message}
</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
textAlign: 'right',
mt: 0.25,
opacity: 0.7,
}}
>
{time}
</Typography>
</Paper>
</Box>
);
};
export default ChatMessage;

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useRef, useState } from 'react';
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 SendIcon from '@mui/icons-material/Send';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useQuery, 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';
const ChatMessageView: React.FC = () => {
const { selectedRoomToken, selectRoom, rooms, loginName } = useChat();
const { chatPanelOpen } = useLayout();
const queryClient = useQueryClient();
const messagesEndRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
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'] });
},
});
useEffect(() => {
if (selectedRoomToken && chatPanelOpen) {
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}).catch(() => {});
}
}, [selectedRoomToken, chatPanelOpen, queryClient]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = () => {
const trimmed = input.trim();
if (!trimmed || !selectedRoomToken) return;
sendMutation.mutate(trimmed);
setInput('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (!selectedRoomToken) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
<Typography color="text.secondary" variant="body2">
Raum auswählen
</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => selectRoom(null)}>
<ArrowBackIcon fontSize="small" />
</IconButton>
<Typography variant="subtitle2" noWrap>
{room?.displayName ?? selectedRoomToken}
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
{messages?.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
/>
))}
<div ref={messagesEndRef} />
</Box>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider', display: 'flex', gap: 0.5 }}>
<TextField
size="small"
fullWidth
placeholder="Nachricht..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
multiline
maxRows={3}
/>
<IconButton
color="primary"
onClick={handleSend}
disabled={!input.trim() || sendMutation.isPending}
>
<SendIcon />
</IconButton>
</Box>
</Box>
);
};
export default ChatMessageView;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import ChatIcon from '@mui/icons-material/Chat';
import Typography from '@mui/material/Typography';
import { useLayout } from '../../contexts/LayoutContext';
import { ChatProvider, useChat } from '../../contexts/ChatContext';
import ChatRoomList from './ChatRoomList';
import ChatMessageView from './ChatMessageView';
const ChatPanelInner: React.FC = () => {
const { chatPanelOpen, setChatPanelOpen } = useLayout();
const { selectedRoomToken, connected } = useChat();
if (!chatPanelOpen) {
return (
<Paper
elevation={2}
sx={{
width: 60,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: 1,
flexShrink: 0,
transition: 'width 0.2s ease',
}}
>
<IconButton onClick={() => setChatPanelOpen(true)}>
<ChatIcon />
</IconButton>
</Paper>
);
}
return (
<Paper
elevation={2}
sx={{
width: 360,
height: '100%',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 1.5,
py: 1,
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography variant="subtitle1" fontWeight={600}>
Chat
</Typography>
<IconButton size="small" onClick={() => setChatPanelOpen(false)}>
<ChatIcon fontSize="small" />
</IconButton>
</Box>
{!connected ? (
<Box sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen.
</Typography>
</Box>
) : selectedRoomToken ? (
<ChatMessageView />
) : (
<ChatRoomList />
)}
</Paper>
);
};
const ChatPanel: React.FC = () => {
return (
<ChatProvider>
<ChatPanelInner />
</ChatProvider>
);
};
export default ChatPanel;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import Badge from '@mui/material/Badge';
import Typography from '@mui/material/Typography';
import { useChat } from '../../contexts/ChatContext';
const ChatRoomList: React.FC = () => {
const { rooms, selectedRoomToken, selectRoom } = useChat();
return (
<Box sx={{ overflow: 'auto', flex: 1 }}>
<List disablePadding>
{rooms.map((room) => (
<ListItemButton
key={room.token}
selected={room.token === selectedRoomToken}
onClick={() => selectRoom(room.token)}
sx={{ py: 1, px: 1.5 }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" noWrap sx={{ flex: 1 }}>
{room.displayName}
</Typography>
{room.unreadMessages > 0 && (
<Badge
badgeContent={room.unreadMessages}
color="primary"
sx={{ ml: 1 }}
/>
)}
</Box>
{room.lastMessage && (
<Typography variant="caption" color="text.secondary" noWrap>
{room.lastMessage.author}: {room.lastMessage.text}
</Typography>
)}
</Box>
</ListItemButton>
))}
</List>
</Box>
);
};
export default ChatRoomList;