Files
dashboard/frontend/src/components/chat/ChatMessage.tsx
Matthias Hochmeister 02d9d808b2 update
2026-03-13 16:12:11 +01:00

225 lines
7.4 KiB
TypeScript

import React, { useRef, 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;
reactionsOverride?: { reactions: Record<string, number>; reactionsSelf: string[] };
}
function hasFileParams(params?: Record<string, any>): boolean {
if (!params) return false;
return Object.keys(params).some((k) => k === 'file' || k.startsWith('file'));
}
function hasPollParam(params?: Record<string, any>): boolean {
return params?.object?.type === 'talk-poll';
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage, onReplyClick, onReactionToggled, reactionsOverride }) => {
const [hovered, setHovered] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleTouchStart = () => {
longPressTimer.current = setTimeout(() => {
setHovered(true);
// Auto-hide after 4 seconds if the user doesn't interact
hideTimer.current = setTimeout(() => setHovered(false), 4000);
}, 500);
};
const handleTouchEnd = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const handleTouchMove = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setHovered(false);
};
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' }}>
<RichMessageText message={message.message} messageParameters={message.messageParameters} /> - {time}
</Typography>
</Box>
);
}
const isFileMessage =
hasFileParams(message.messageParameters) && message.message.includes('{file}');
const isPollMessage = hasPollParam(message.messageParameters);
const textBeforeFile = isFileMessage ? message.message.split('{file}')[0].trim() : null;
const reactions = reactionsOverride?.reactions ?? message.reactions ?? {};
const reactionsSelf = reactionsOverride?.reactionsSelf ?? message.reactionsSelf ?? [];
const hasReactions = Object.keys(reactions).length > 0;
return (
<Box
sx={{
display: 'flex',
justifyContent: isOwnMessage ? 'flex-end' : 'flex-start',
my: 0.5,
px: 1,
alignItems: 'flex-end',
gap: 0.5,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
>
{/* Reply button for own messages (shown on left) */}
{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', transform: 'scaleX(-1)' }} />
</IconButton>
</Tooltip>
)}
<Box sx={{ maxWidth: '80%' }}>
<Paper
elevation={0}
sx={{
px: 1.5,
py: 0.75,
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>
)}
{/* 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 — shown on hover or when reactions exist */}
{(hovered || hasReactions) && (
<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>
);
};
export default ChatMessage;