This commit is contained in:
Matthias Hochmeister
2026-03-13 15:49:58 +01:00
parent 75c919c063
commit 03155dcf7a
6 changed files with 289 additions and 84 deletions

View File

@@ -354,8 +354,8 @@ class NextcloudController {
try { try {
const credentials = await userService.getNextcloudCredentials(req.user!.id); const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token; const token = req.params.token as string;
const messageId = parseInt(req.params.messageId, 10); const messageId = parseInt(req.params.messageId as string, 10);
const { reaction } = req.body; const { reaction } = req.body;
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; } if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
await nextcloudService.addReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword); await nextcloudService.addReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
@@ -371,8 +371,8 @@ class NextcloudController {
try { try {
const credentials = await userService.getNextcloudCredentials(req.user!.id); const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token; const token = req.params.token as string;
const messageId = parseInt(req.params.messageId, 10); const messageId = parseInt(req.params.messageId as string, 10);
const reaction = req.query.reaction as string; const reaction = req.query.reaction as string;
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; } if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
await nextcloudService.removeReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword); await nextcloudService.removeReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
@@ -388,8 +388,8 @@ class NextcloudController {
try { try {
const credentials = await userService.getNextcloudCredentials(req.user!.id); const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token; const token = req.params.token as string;
const messageId = parseInt(req.params.messageId, 10); const messageId = parseInt(req.params.messageId as string, 10);
if (!token || isNaN(messageId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; } if (!token || isNaN(messageId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
const data = await nextcloudService.getReactions(token, messageId, credentials.loginName, credentials.appPassword); const data = await nextcloudService.getReactions(token, messageId, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data }); res.status(200).json({ success: true, data });
@@ -404,8 +404,8 @@ class NextcloudController {
try { try {
const credentials = await userService.getNextcloudCredentials(req.user!.id); const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token; const token = req.params.token as string;
const pollId = parseInt(req.params.pollId, 10); const pollId = parseInt(req.params.pollId as string, 10);
if (!token || isNaN(pollId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; } if (!token || isNaN(pollId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
const data = await nextcloudService.getPollDetails(token, pollId, credentials.loginName, credentials.appPassword); const data = await nextcloudService.getPollDetails(token, pollId, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data }); res.status(200).json({ success: true, data });

View File

@@ -20,4 +20,11 @@ router.post('/rooms/:token/read', authenticate, nextcloudController.markRoomAsRe
router.get('/files/:fileId/download', authenticate, nextcloudController.downloadFile.bind(nextcloudController)); router.get('/files/:fileId/download', authenticate, nextcloudController.downloadFile.bind(nextcloudController));
router.get('/files/:fileId/preview', authenticate, nextcloudController.getFilePreview.bind(nextcloudController)); router.get('/files/:fileId/preview', authenticate, nextcloudController.getFilePreview.bind(nextcloudController));
router.get('/users', authenticate, nextcloudController.searchUsers.bind(nextcloudController));
router.post('/rooms', authenticate, nextcloudController.createRoom.bind(nextcloudController));
router.get('/rooms/:token/messages/:messageId/reactions', authenticate, nextcloudController.getReactions.bind(nextcloudController));
router.post('/rooms/:token/messages/:messageId/reactions', authenticate, nextcloudController.addReaction.bind(nextcloudController));
router.delete('/rooms/:token/messages/:messageId/reactions', authenticate, nextcloudController.removeReaction.bind(nextcloudController));
router.get('/rooms/:token/polls/:pollId', authenticate, nextcloudController.getPoll.bind(nextcloudController));
export default router; export default router;

View File

@@ -1,13 +1,21 @@
import React from 'react'; import React, { useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import ReplyIcon from '@mui/icons-material/Reply';
import type { NextcloudMessage } from '../../types/nextcloud.types'; import type { NextcloudMessage } from '../../types/nextcloud.types';
import FileMessageContent from './FileMessageContent'; import FileMessageContent from './FileMessageContent';
import RichMessageText from './RichMessageText';
import PollMessageContent from './PollMessageContent';
import MessageReactions from './MessageReactions';
interface ChatMessageProps { interface ChatMessageProps {
message: NextcloudMessage; message: NextcloudMessage;
isOwnMessage: boolean; isOwnMessage: boolean;
onReplyClick?: (message: NextcloudMessage) => void;
onReactionToggled?: (messageId: number) => void;
} }
function hasFileParams(params?: Record<string, any>): boolean { function hasFileParams(params?: Record<string, any>): boolean {
@@ -15,7 +23,12 @@ function hasFileParams(params?: Record<string, any>): boolean {
return Object.keys(params).some((k) => k === 'file' || k.startsWith('file')); return Object.keys(params).some((k) => k === 'file' || k.startsWith('file'));
} }
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => { function hasPollParam(params?: Record<string, any>): boolean {
return params?.object?.type === 'talk-poll';
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, onReplyClick, onReactionToggled }) => {
const [hovered, setHovered] = useState(false);
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', { const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
@@ -25,7 +38,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 0.5 }}> <Box sx={{ display: 'flex', justifyContent: 'center', my: 0.5 }}>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
{message.message} - {time} <RichMessageText message={message.message} messageParameters={message.messageParameters} /> - {time}
</Typography> </Typography>
</Box> </Box>
); );
@@ -33,10 +46,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
const isFileMessage = const isFileMessage =
hasFileParams(message.messageParameters) && message.message.includes('{file}'); hasFileParams(message.messageParameters) && message.message.includes('{file}');
const isPollMessage = hasPollParam(message.messageParameters);
// For file messages, extract any surrounding text
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 reactionsSelf = message.reactionsSelf ?? [];
return ( return (
<Box <Box
sx={{ sx={{
@@ -44,53 +60,129 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
justifyContent: isOwnMessage ? 'flex-end' : 'flex-start', justifyContent: isOwnMessage ? 'flex-end' : 'flex-start',
my: 0.5, my: 0.5,
px: 1, px: 1,
alignItems: 'flex-end',
gap: 0.5,
}} }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
> >
<Paper {/* Reply button for own messages (shown on left) */}
elevation={0} {isOwnMessage && onReplyClick && (
sx={{ <Tooltip title="Antworten">
px: 1.5, <IconButton
py: 0.75, size="small"
maxWidth: '80%', onClick={() => onReplyClick(message)}
bgcolor: isOwnMessage ? 'primary.main' : (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200', sx={{ opacity: hovered ? 0.7 : 0, transition: 'opacity 0.15s', mb: 0.5 }}
color: isOwnMessage ? 'primary.contrastText' : 'text.primary', >
borderRadius: 2, <ReplyIcon sx={{ fontSize: '0.9rem', transform: 'scaleX(-1)' }} />
}} </IconButton>
> </Tooltip>
{!isOwnMessage && ( )}
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>
{message.actorDisplayName} <Box sx={{ maxWidth: '80%' }}>
</Typography> <Paper
)} elevation={0}
{isFileMessage ? (
<>
{textBeforeFile && (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mb: 0.5 }}>
{textBeforeFile}
</Typography>
)}
<FileMessageContent
messageParameters={message.messageParameters!}
isOwnMessage={isOwnMessage}
/>
</>
) : (
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{message.message}
</Typography>
)}
<Typography
variant="caption"
sx={{ sx={{
display: 'block', px: 1.5,
textAlign: 'right', py: 0.75,
mt: 0.25, bgcolor: isOwnMessage ? 'primary.main' : (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200',
opacity: 0.7, color: isOwnMessage ? 'primary.contrastText' : 'text.primary',
borderRadius: 2,
}} }}
> >
{time} {!isOwnMessage && (
</Typography> <Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>
</Paper> {message.actorDisplayName}
</Typography>
)}
{/* Quoted parent message */}
{message.parent && (
<Box sx={{
borderLeft: '3px solid',
borderColor: isOwnMessage ? 'rgba(255,255,255,0.4)' : 'divider',
pl: 1,
mb: 0.75,
opacity: 0.75,
borderRadius: '0 4px 4px 0',
py: 0.25,
bgcolor: isOwnMessage ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.04)',
}}>
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, lineHeight: 1.4 }}>
{message.parent.actorDisplayName}
</Typography>
<Typography variant="caption" noWrap sx={{ display: 'block', lineHeight: 1.4 }}>
{message.parent.message}
</Typography>
</Box>
)}
{isPollMessage ? (
<PollMessageContent
roomToken={message.token}
pollId={message.messageParameters!.object.id}
pollName={message.messageParameters!.object.name ?? ''}
isOwnMessage={isOwnMessage}
/>
) : isFileMessage ? (
<>
{textBeforeFile && (
<RichMessageText
message={textBeforeFile}
messageParameters={message.messageParameters}
isOwnMessage={isOwnMessage}
/>
)}
<FileMessageContent
messageParameters={message.messageParameters!}
isOwnMessage={isOwnMessage}
/>
</>
) : (
<RichMessageText
message={message.message}
messageParameters={message.messageParameters}
isOwnMessage={isOwnMessage}
/>
)}
<Typography
variant="caption"
sx={{
display: 'block',
textAlign: 'right',
mt: 0.25,
opacity: 0.7,
}}
>
{time}
</Typography>
</Paper>
{/* Reactions */}
{!message.systemMessage && (
<MessageReactions
token={message.token}
messageId={message.id}
reactions={reactions}
reactionsSelf={reactionsSelf}
onReactionToggled={() => onReactionToggled?.(message.id)}
/>
)}
</Box>
{/* Reply button for other messages (shown on right) */}
{!isOwnMessage && onReplyClick && (
<Tooltip title="Antworten">
<IconButton
size="small"
onClick={() => onReplyClick(message)}
sx={{ opacity: hovered ? 0.7 : 0, transition: 'opacity 0.15s', mb: 0.5 }}
>
<ReplyIcon sx={{ fontSize: '0.9rem' }} />
</IconButton>
</Tooltip>
)}
</Box> </Box>
); );
}; };

View File

@@ -9,6 +9,7 @@ import Tooltip from '@mui/material/Tooltip';
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 AttachFileIcon from '@mui/icons-material/AttachFile'; import AttachFileIcon from '@mui/icons-material/AttachFile';
import CloseIcon from '@mui/icons-material/Close';
import { 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';
@@ -28,6 +29,7 @@ const ChatMessageView: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null); const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [replyToMessage, setReplyToMessage] = useState<NextcloudMessage | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const lastMsgIdRef = useRef<number>(0); const lastMsgIdRef = useRef<number>(0);
@@ -91,7 +93,8 @@ const ChatMessageView: React.FC = () => {
const room = rooms.find((r) => r.token === selectedRoomToken); const room = rooms.find((r) => r.token === selectedRoomToken);
const sendMutation = useMutation({ const sendMutation = useMutation({
mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message), mutationFn: ({ message, replyTo }: { message: string; replyTo?: number }) =>
nextcloudApi.sendMessage(selectedRoomToken!, message, replyTo),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}, },
@@ -113,8 +116,9 @@ const ChatMessageView: React.FC = () => {
const handleSend = () => { const handleSend = () => {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed || !selectedRoomToken) return; if (!trimmed || !selectedRoomToken) return;
sendMutation.mutate(trimmed); sendMutation.mutate({ message: trimmed, replyTo: replyToMessage?.id });
setInput(''); setInput('');
setReplyToMessage(null);
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -124,6 +128,10 @@ const ChatMessageView: React.FC = () => {
} }
}; };
const handleReactionToggled = (_messageId: number) => {
// reactions are stored in message objects; for now just mark rooms as potentially changed
};
const handleUploadFile = async (file: File) => { const handleUploadFile = async (file: File) => {
if (!selectedRoomToken) return; if (!selectedRoomToken) return;
if (file.size > 50 * 1024 * 1024) { if (file.size > 50 * 1024 * 1024) {
@@ -161,7 +169,7 @@ const ChatMessageView: React.FC = () => {
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
<Typography color="text.secondary" variant="body2"> <Typography color="text.secondary" variant="body2">
Raum auswählen Raum ausw\u00E4hlen
</Typography> </Typography>
</Box> </Box>
); );
@@ -202,6 +210,8 @@ const ChatMessageView: React.FC = () => {
key={msg.id} key={msg.id}
message={msg} message={msg}
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName} isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
onReplyClick={setReplyToMessage}
onReactionToggled={handleReactionToggled}
/> />
)) ))
)} )}
@@ -209,6 +219,17 @@ const ChatMessageView: React.FC = () => {
</Box> </Box>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}> <Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
{replyToMessage && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 0.5, bgcolor: 'action.hover', borderRadius: 1, mb: 0.5 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>{replyToMessage.actorDisplayName}</Typography>
<Typography variant="caption" noWrap sx={{ display: 'block', opacity: 0.7 }}>{replyToMessage.message}</Typography>
</Box>
<IconButton size="small" onClick={() => setReplyToMessage(null)}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
)}
{uploadProgress !== null && ( {uploadProgress !== null && (
<LinearProgress variant="determinate" value={uploadProgress} sx={{ mb: 0.5, borderRadius: 1 }} /> <LinearProgress variant="determinate" value={uploadProgress} sx={{ mb: 0.5, borderRadius: 1 }} />
)} )}
@@ -219,7 +240,7 @@ const ChatMessageView: React.FC = () => {
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileInputChange} onChange={handleFileInputChange}
/> />
<Tooltip title="Datei anhängen"> <Tooltip title="Datei anh\u00E4ngen">
<span> <span>
<IconButton <IconButton
size="small" size="small"

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
@@ -7,13 +7,31 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge'; import Badge from '@mui/material/Badge';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import { useQueryClient } from '@tanstack/react-query';
import { useChat } from '../../contexts/ChatContext'; import { useChat } from '../../contexts/ChatContext';
import NewChatDialog from './NewChatDialog';
const ChatRoomList: React.FC = () => { const ChatRoomList: React.FC = () => {
const { rooms, selectedRoomToken, selectRoom } = useChat(); const { rooms, selectedRoomToken, selectRoom } = useChat();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
return ( return (
<Box sx={{ overflow: 'auto', flex: 1 }}> <Box sx={{ overflow: 'auto', flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', px: 1.5, py: 0.75, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="caption" sx={{ flex: 1, fontWeight: 600, opacity: 0.6, letterSpacing: '0.05em' }}>
GESPR\u00C4CHE
</Typography>
<Tooltip title="Neues Gespr\u00E4ch">
<IconButton size="small" onClick={() => setDialogOpen(true)}>
<AddIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<List disablePadding> <List disablePadding>
{rooms.map((room) => { {rooms.map((room) => {
const isSelected = room.token === selectedRoomToken; const isSelected = room.token === selectedRoomToken;
@@ -75,6 +93,15 @@ const ChatRoomList: React.FC = () => {
); );
})} })}
</List> </List>
<NewChatDialog
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onRoomCreated={(token) => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
selectRoom(token);
setDialogOpen(false);
}}
/>
</Box> </Box>
); );
}; };

View File

@@ -43,31 +43,46 @@ function extractFileParams(messageParameters: Record<string, any>): FileParam[]
return files; return files;
} }
interface ImageLightboxProps { type OverlayMode = 'image' | 'pdf' | 'video';
interface ContentOverlayProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
previewUrl: string; mode: OverlayMode;
contentUrl: string;
downloadUrl: string; downloadUrl: string;
name: string; name: string;
} }
const ImageLightbox: React.FC<ImageLightboxProps> = ({ open, onClose, previewUrl, downloadUrl, name }) => ( const ContentOverlay: React.FC<ContentOverlayProps> = ({ open, onClose, mode, contentUrl, downloadUrl, name }) => (
<Dialog <Dialog
open={open} open={open}
onClose={onClose} onClose={onClose}
maxWidth="lg" maxWidth="lg"
fullWidth fullWidth
slotProps={{ paper: { sx: { bgcolor: 'black', m: 1 } } }} slotProps={{ paper: { sx: { bgcolor: mode === 'image' ? 'black' : 'background.paper', m: 1 } } }}
> >
<DialogContent sx={{ p: 0, position: 'relative', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}> <DialogContent sx={{ p: 0, position: 'relative', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
<Box {mode === 'image' && (
component="img" <Box
src={previewUrl} component="img"
alt={name} src={contentUrl}
sx={{ maxWidth: '100%', maxHeight: '85vh', display: 'block', objectFit: 'contain' }} alt={name}
/> sx={{ maxWidth: '100%', maxHeight: '85vh', display: 'block', objectFit: 'contain' }}
{/* Close button */} />
<Tooltip title="Schließen"> )}
{mode === 'pdf' && (
<Box component="iframe" src={contentUrl} sx={{ width: '100%', height: '80vh', border: 'none', display: 'block' }} title={name} />
)}
{mode === 'video' && (
<Box
component="video"
controls
src={contentUrl}
sx={{ maxWidth: '100%', maxHeight: '85vh', display: 'block' }}
/>
)}
<Tooltip title="Schlie\u00DFen">
<IconButton <IconButton
onClick={onClose} onClick={onClose}
size="small" size="small"
@@ -76,7 +91,6 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({ open, onClose, previewUrl
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{/* Download button */}
<Tooltip title="Herunterladen"> <Tooltip title="Herunterladen">
<IconButton <IconButton
component="a" component="a"
@@ -90,7 +104,6 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({ open, onClose, previewUrl
<DownloadIcon fontSize="small" /> <DownloadIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{/* Filename */}
<Typography <Typography
variant="caption" variant="caption"
sx={{ position: 'absolute', bottom: 8, left: 0, right: 0, textAlign: 'center', color: 'rgba(255,255,255,0.7)', px: 2 }} sx={{ position: 'absolute', bottom: 8, left: 0, right: 0, textAlign: 'center', color: 'rgba(255,255,255,0.7)', px: 2 }}
@@ -103,17 +116,27 @@ const ImageLightbox: React.FC<ImageLightboxProps> = ({ open, onClose, previewUrl
); );
const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParameters, isOwnMessage }) => { const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParameters, isOwnMessage }) => {
const [lightboxFile, setLightboxFile] = useState<FileParam | null>(null); const [overlayFile, setOverlayFile] = useState<{ file: FileParam; mode: OverlayMode } | null>(null);
const files = extractFileParams(messageParameters); const files = extractFileParams(messageParameters);
if (files.length === 0) return null; if (files.length === 0) return null;
const getOverlayMode = (file: FileParam): OverlayMode | null => {
const mime = file.mimetype ?? '';
if (mime.startsWith('image/') && file.previewAvailable === 'yes') return 'image';
if (mime === 'application/pdf') return 'pdf';
if (mime.startsWith('video/')) return 'video';
return null;
};
return ( return (
<> <>
{files.map((file, idx) => { {files.map((file, idx) => {
const downloadUrl = `/api/nextcloud/talk/files/${file.id}/download${file.path ? `?path=${encodeURIComponent(file.path)}` : ''}`; const downloadUrl = `/api/nextcloud/talk/files/${file.id}/download${file.path ? `?path=${encodeURIComponent(file.path)}` : ''}`;
const thumbUrl = `/api/nextcloud/talk/files/${file.id}/preview?w=400&h=400`; const thumbUrl = `/api/nextcloud/talk/files/${file.id}/preview?w=400&h=400`;
const fullUrl = `/api/nextcloud/talk/files/${file.id}/preview?w=1200&h=1200`; const mime = file.mimetype ?? '';
const isImage = file.mimetype?.startsWith('image/') && file.previewAvailable === 'yes'; const isImage = mime.startsWith('image/') && file.previewAvailable === 'yes';
const isAudio = mime.startsWith('audio/');
const overlayMode = getOverlayMode(file);
if (isImage) { if (isImage) {
return ( return (
@@ -122,7 +145,7 @@ const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParamete
component="img" component="img"
src={thumbUrl} src={thumbUrl}
alt={file.name} alt={file.name}
onClick={() => setLightboxFile(file)} onClick={() => setOverlayFile({ file, mode: 'image' })}
sx={{ sx={{
maxWidth: '100%', maxWidth: '100%',
maxHeight: 200, maxHeight: 200,
@@ -154,9 +177,32 @@ const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParamete
); );
} }
if (isAudio) {
return (
<Box key={idx} sx={{ mt: 0.5 }}>
<Box
component="audio"
controls
src={downloadUrl}
sx={{ width: '100%', display: 'block' }}
/>
<Typography variant="caption" sx={{ opacity: 0.7, fontSize: '0.7rem', display: 'block', mt: 0.25 }} noWrap>
{file.name}
</Typography>
</Box>
);
}
return ( return (
<Box <Box
key={idx} key={idx}
onClick={() => {
if (overlayMode) {
setOverlayFile({ file, mode: overlayMode });
} else {
window.open(downloadUrl, '_blank', 'noopener,noreferrer');
}
}}
sx={{ sx={{
mt: 0.5, mt: 0.5,
display: 'flex', display: 'flex',
@@ -164,9 +210,15 @@ const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParamete
gap: 1, gap: 1,
p: 0.75, p: 0.75,
borderRadius: 1, borderRadius: 1,
cursor: 'pointer',
bgcolor: isOwnMessage bgcolor: isOwnMessage
? 'rgba(255,255,255,0.15)' ? 'rgba(255,255,255,0.15)'
: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', : (theme: any) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)',
'&:hover': {
bgcolor: isOwnMessage
? 'rgba(255,255,255,0.25)'
: (theme: any) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
},
}} }}
> >
<InsertDriveFileIcon fontSize="small" sx={{ opacity: 0.8, flexShrink: 0 }} /> <InsertDriveFileIcon fontSize="small" sx={{ opacity: 0.8, flexShrink: 0 }} />
@@ -188,6 +240,7 @@ const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParamete
download={file.name} download={file.name}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
sx={{ flexShrink: 0, color: 'inherit' }} sx={{ flexShrink: 0, color: 'inherit' }}
> >
<DownloadIcon fontSize="small" /> <DownloadIcon fontSize="small" />
@@ -197,13 +250,18 @@ const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParamete
); );
})} })}
{lightboxFile && ( {overlayFile && (
<ImageLightbox <ContentOverlay
open open
onClose={() => setLightboxFile(null)} onClose={() => setOverlayFile(null)}
previewUrl={`/api/nextcloud/talk/files/${lightboxFile.id}/preview?w=1200&h=1200`} mode={overlayFile.mode}
downloadUrl={`/api/nextcloud/talk/files/${lightboxFile.id}/download${lightboxFile.path ? `?path=${encodeURIComponent(lightboxFile.path)}` : ''}`} contentUrl={
name={lightboxFile.name} overlayFile.mode === 'image'
? `/api/nextcloud/talk/files/${overlayFile.file.id}/preview?w=1200&h=1200`
: `/api/nextcloud/talk/files/${overlayFile.file.id}/download${overlayFile.file.path ? `?path=${encodeURIComponent(overlayFile.file.path)}` : ''}`
}
downloadUrl={`/api/nextcloud/talk/files/${overlayFile.file.id}/download${overlayFile.file.path ? `?path=${encodeURIComponent(overlayFile.file.path)}` : ''}`}
name={overlayFile.file.name}
/> />
)} )}
</> </>