From 03155dcf7a37464737f7b56688ef07f5cd4f4f79 Mon Sep 17 00:00:00 2001 From: Matthias Hochmeister Date: Fri, 13 Mar 2026 15:49:58 +0100 Subject: [PATCH] update --- .../src/controllers/nextcloud.controller.ts | 16 +- backend/src/routes/nextcloud.routes.ts | 7 + frontend/src/components/chat/ChatMessage.tsx | 184 +++++++++++++----- .../src/components/chat/ChatMessageView.tsx | 29 ++- frontend/src/components/chat/ChatRoomList.tsx | 29 ++- .../components/chat/FileMessageContent.tsx | 108 +++++++--- 6 files changed, 289 insertions(+), 84 deletions(-) diff --git a/backend/src/controllers/nextcloud.controller.ts b/backend/src/controllers/nextcloud.controller.ts index 71fc6b2..d0e4443 100644 --- a/backend/src/controllers/nextcloud.controller.ts +++ b/backend/src/controllers/nextcloud.controller.ts @@ -354,8 +354,8 @@ class NextcloudController { try { const credentials = await userService.getNextcloudCredentials(req.user!.id); if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } - const token = req.params.token; - const messageId = parseInt(req.params.messageId, 10); + const token = req.params.token as string; + const messageId = parseInt(req.params.messageId as string, 10); const { reaction } = req.body; 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); @@ -371,8 +371,8 @@ class NextcloudController { try { const credentials = await userService.getNextcloudCredentials(req.user!.id); if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } - const token = req.params.token; - const messageId = parseInt(req.params.messageId, 10); + const token = req.params.token as string; + const messageId = parseInt(req.params.messageId as string, 10); const reaction = req.query.reaction as string; 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); @@ -388,8 +388,8 @@ class NextcloudController { try { const credentials = await userService.getNextcloudCredentials(req.user!.id); if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } - const token = req.params.token; - const messageId = parseInt(req.params.messageId, 10); + const token = req.params.token as string; + const messageId = parseInt(req.params.messageId as string, 10); 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); res.status(200).json({ success: true, data }); @@ -404,8 +404,8 @@ class NextcloudController { try { const credentials = await userService.getNextcloudCredentials(req.user!.id); if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; } - const token = req.params.token; - const pollId = parseInt(req.params.pollId, 10); + const token = req.params.token as string; + const pollId = parseInt(req.params.pollId as string, 10); 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); res.status(200).json({ success: true, data }); diff --git a/backend/src/routes/nextcloud.routes.ts b/backend/src/routes/nextcloud.routes.ts index d1499fb..97a7572 100644 --- a/backend/src/routes/nextcloud.routes.ts +++ b/backend/src/routes/nextcloud.routes.ts @@ -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/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; diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index 779927a..7f1c52c 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -1,13 +1,21 @@ -import React from 'react'; +import React, { useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; 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 FileMessageContent from './FileMessageContent'; +import RichMessageText from './RichMessageText'; +import PollMessageContent from './PollMessageContent'; +import MessageReactions from './MessageReactions'; interface ChatMessageProps { message: NextcloudMessage; isOwnMessage: boolean; + onReplyClick?: (message: NextcloudMessage) => void; + onReactionToggled?: (messageId: number) => void; } function hasFileParams(params?: Record): boolean { @@ -15,7 +23,12 @@ function hasFileParams(params?: Record): boolean { return Object.keys(params).some((k) => k === 'file' || k.startsWith('file')); } -const ChatMessage: React.FC = ({ message, isOwnMessage }) => { +function hasPollParam(params?: Record): boolean { + return params?.object?.type === 'talk-poll'; +} + +const ChatMessage: React.FC = ({ message, isOwnMessage, onReplyClick, onReactionToggled }) => { + const [hovered, setHovered] = useState(false); const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', @@ -25,7 +38,7 @@ const ChatMessage: React.FC = ({ message, isOwnMessage }) => { return ( - {message.message} - {time} + - {time} ); @@ -33,10 +46,13 @@ const ChatMessage: React.FC = ({ message, isOwnMessage }) => { const isFileMessage = 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 reactions = message.reactions ?? {}; + const reactionsSelf = message.reactionsSelf ?? []; + return ( = ({ message, isOwnMessage }) => { justifyContent: isOwnMessage ? 'flex-end' : 'flex-start', my: 0.5, px: 1, + alignItems: 'flex-end', + gap: 0.5, }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} > - theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200', - color: isOwnMessage ? 'primary.contrastText' : 'text.primary', - borderRadius: 2, - }} - > - {!isOwnMessage && ( - - {message.actorDisplayName} - - )} - {isFileMessage ? ( - <> - {textBeforeFile && ( - - {textBeforeFile} - - )} - - - ) : ( - - {message.message} - - )} - + onReplyClick(message)} + sx={{ opacity: hovered ? 0.7 : 0, transition: 'opacity 0.15s', mb: 0.5 }} + > + + + + )} + + + theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200', + color: isOwnMessage ? 'primary.contrastText' : 'text.primary', + borderRadius: 2, }} > - {time} - - + {!isOwnMessage && ( + + {message.actorDisplayName} + + )} + + {/* Quoted parent message */} + {message.parent && ( + + + {message.parent.actorDisplayName} + + + {message.parent.message} + + + )} + + {isPollMessage ? ( + + ) : isFileMessage ? ( + <> + {textBeforeFile && ( + + )} + + + ) : ( + + )} + + + {time} + + + + {/* Reactions */} + {!message.systemMessage && ( + onReactionToggled?.(message.id)} + /> + )} + + + {/* Reply button for other messages (shown on right) */} + {!isOwnMessage && onReplyClick && ( + + onReplyClick(message)} + sx={{ opacity: hovered ? 0.7 : 0, transition: 'opacity 0.15s', mb: 0.5 }} + > + + + + )} ); }; diff --git a/frontend/src/components/chat/ChatMessageView.tsx b/frontend/src/components/chat/ChatMessageView.tsx index 377e48e..c48dcd9 100644 --- a/frontend/src/components/chat/ChatMessageView.tsx +++ b/frontend/src/components/chat/ChatMessageView.tsx @@ -9,6 +9,7 @@ import Tooltip from '@mui/material/Tooltip'; import SendIcon from '@mui/icons-material/Send'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import AttachFileIcon from '@mui/icons-material/AttachFile'; +import CloseIcon from '@mui/icons-material/Close'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { nextcloudApi } from '../../services/nextcloud'; import { useChat } from '../../contexts/ChatContext'; @@ -28,6 +29,7 @@ const ChatMessageView: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const [isDragOver, setIsDragOver] = useState(false); + const [replyToMessage, setReplyToMessage] = useState(null); const fileInputRef = useRef(null); const lastMsgIdRef = useRef(0); @@ -91,7 +93,8 @@ const ChatMessageView: React.FC = () => { const room = rooms.find((r) => r.token === selectedRoomToken); const sendMutation = useMutation({ - mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message), + mutationFn: ({ message, replyTo }: { message: string; replyTo?: number }) => + nextcloudApi.sendMessage(selectedRoomToken!, message, replyTo), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); }, @@ -113,8 +116,9 @@ const ChatMessageView: React.FC = () => { const handleSend = () => { const trimmed = input.trim(); if (!trimmed || !selectedRoomToken) return; - sendMutation.mutate(trimmed); + sendMutation.mutate({ message: trimmed, replyTo: replyToMessage?.id }); setInput(''); + setReplyToMessage(null); }; 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) => { if (!selectedRoomToken) return; if (file.size > 50 * 1024 * 1024) { @@ -161,7 +169,7 @@ const ChatMessageView: React.FC = () => { return ( - Raum auswählen + Raum ausw\u00E4hlen ); @@ -202,6 +210,8 @@ const ChatMessageView: React.FC = () => { key={msg.id} message={msg} isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName} + onReplyClick={setReplyToMessage} + onReactionToggled={handleReactionToggled} /> )) )} @@ -209,6 +219,17 @@ const ChatMessageView: React.FC = () => { + {replyToMessage && ( + + + {replyToMessage.actorDisplayName} + {replyToMessage.message} + + setReplyToMessage(null)}> + + + + )} {uploadProgress !== null && ( )} @@ -219,7 +240,7 @@ const ChatMessageView: React.FC = () => { style={{ display: 'none' }} onChange={handleFileInputChange} /> - + { const { rooms, selectedRoomToken, selectRoom } = useChat(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = useState(false); return ( + + + GESPR\u00C4CHE + + + setDialogOpen(true)}> + + + + {rooms.map((room) => { const isSelected = room.token === selectedRoomToken; @@ -75,6 +93,15 @@ const ChatRoomList: React.FC = () => { ); })} + setDialogOpen(false)} + onRoomCreated={(token) => { + queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] }); + selectRoom(token); + setDialogOpen(false); + }} + /> ); }; diff --git a/frontend/src/components/chat/FileMessageContent.tsx b/frontend/src/components/chat/FileMessageContent.tsx index c03a0ea..33df955 100644 --- a/frontend/src/components/chat/FileMessageContent.tsx +++ b/frontend/src/components/chat/FileMessageContent.tsx @@ -43,31 +43,46 @@ function extractFileParams(messageParameters: Record): FileParam[] return files; } -interface ImageLightboxProps { +type OverlayMode = 'image' | 'pdf' | 'video'; + +interface ContentOverlayProps { open: boolean; onClose: () => void; - previewUrl: string; + mode: OverlayMode; + contentUrl: string; downloadUrl: string; name: string; } -const ImageLightbox: React.FC = ({ open, onClose, previewUrl, downloadUrl, name }) => ( +const ContentOverlay: React.FC = ({ open, onClose, mode, contentUrl, downloadUrl, name }) => ( - - {/* Close button */} - + {mode === 'image' && ( + + )} + {mode === 'pdf' && ( + + )} + {mode === 'video' && ( + + )} + = ({ open, onClose, previewUrl - {/* Download button */} = ({ open, onClose, previewUrl - {/* Filename */} = ({ open, onClose, previewUrl ); const FileMessageContent: React.FC = ({ messageParameters, isOwnMessage }) => { - const [lightboxFile, setLightboxFile] = useState(null); + const [overlayFile, setOverlayFile] = useState<{ file: FileParam; mode: OverlayMode } | null>(null); const files = extractFileParams(messageParameters); 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 ( <> {files.map((file, idx) => { 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 fullUrl = `/api/nextcloud/talk/files/${file.id}/preview?w=1200&h=1200`; - const isImage = file.mimetype?.startsWith('image/') && file.previewAvailable === 'yes'; + const mime = file.mimetype ?? ''; + const isImage = mime.startsWith('image/') && file.previewAvailable === 'yes'; + const isAudio = mime.startsWith('audio/'); + const overlayMode = getOverlayMode(file); if (isImage) { return ( @@ -122,7 +145,7 @@ const FileMessageContent: React.FC = ({ messageParamete component="img" src={thumbUrl} alt={file.name} - onClick={() => setLightboxFile(file)} + onClick={() => setOverlayFile({ file, mode: 'image' })} sx={{ maxWidth: '100%', maxHeight: 200, @@ -154,9 +177,32 @@ const FileMessageContent: React.FC = ({ messageParamete ); } + if (isAudio) { + return ( + + + + {file.name} + + + ); + } + return ( { + if (overlayMode) { + setOverlayFile({ file, mode: overlayMode }); + } else { + window.open(downloadUrl, '_blank', 'noopener,noreferrer'); + } + }} sx={{ mt: 0.5, display: 'flex', @@ -164,9 +210,15 @@ const FileMessageContent: React.FC = ({ messageParamete gap: 1, p: 0.75, borderRadius: 1, + cursor: 'pointer', bgcolor: isOwnMessage ? '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)', + }, }} > @@ -188,6 +240,7 @@ const FileMessageContent: React.FC = ({ messageParamete download={file.name} target="_blank" rel="noopener noreferrer" + onClick={(e) => e.stopPropagation()} sx={{ flexShrink: 0, color: 'inherit' }} > @@ -197,13 +250,18 @@ const FileMessageContent: React.FC = ({ messageParamete ); })} - {lightboxFile && ( - setLightboxFile(null)} - previewUrl={`/api/nextcloud/talk/files/${lightboxFile.id}/preview?w=1200&h=1200`} - downloadUrl={`/api/nextcloud/talk/files/${lightboxFile.id}/download${lightboxFile.path ? `?path=${encodeURIComponent(lightboxFile.path)}` : ''}`} - name={lightboxFile.name} + onClose={() => setOverlayFile(null)} + mode={overlayFile.mode} + contentUrl={ + 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} /> )}