update
This commit is contained in:
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user