This commit is contained in:
Matthias Hochmeister
2026-03-13 15:42:15 +01:00
parent 3dda069611
commit 75c919c063
13 changed files with 926 additions and 30 deletions

View File

@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Popover from '@mui/material/Popover';
import AddReactionOutlinedIcon from '@mui/icons-material/AddReactionOutlined';
import { nextcloudApi } from '../../services/nextcloud';
const QUICK_REACTIONS = ['\u{1F44D}', '\u2764\uFE0F', '\u{1F602}', '\u{1F62E}', '\u{1F622}', '\u{1F389}'];
interface MessageReactionsProps {
token: string;
messageId: number;
reactions: Record<string, number>;
reactionsSelf: string[];
onReactionToggled: () => void;
}
const MessageReactions: React.FC<MessageReactionsProps> = ({
token,
messageId,
reactions,
reactionsSelf,
onReactionToggled,
}) => {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [loading, setLoading] = useState<string | null>(null);
const handleToggle = async (emoji: string) => {
setLoading(emoji);
try {
if (reactionsSelf.includes(emoji)) {
await nextcloudApi.removeReaction(token, messageId, emoji);
} else {
await nextcloudApi.addReaction(token, messageId, emoji);
}
onReactionToggled();
} catch {
// ignore
} finally {
setLoading(null);
}
};
const hasReactions = Object.keys(reactions).length > 0;
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.25, mt: 0.25, alignItems: 'center' }}>
{hasReactions && Object.entries(reactions).map(([emoji, count]) => (
<Chip
key={emoji}
label={`${emoji} ${count}`}
size="small"
onClick={() => handleToggle(emoji)}
disabled={loading === emoji}
sx={{
height: 20,
fontSize: '0.75rem',
cursor: 'pointer',
border: '1px solid',
borderColor: reactionsSelf.includes(emoji) ? 'primary.main' : 'transparent',
bgcolor: reactionsSelf.includes(emoji) ? 'primary.light' : 'action.hover',
'& .MuiChip-label': { px: 0.5 },
}}
/>
))}
<Tooltip title="Reaktion hinzuf\u00FCgen">
<IconButton
size="small"
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{ width: 20, height: 20, opacity: 0.6, '&:hover': { opacity: 1 } }}
>
<AddReactionOutlinedIcon sx={{ fontSize: '0.85rem' }} />
</IconButton>
</Tooltip>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
<Box sx={{ display: 'flex', p: 0.5, gap: 0.25 }}>
{QUICK_REACTIONS.map((emoji) => (
<IconButton
key={emoji}
size="small"
onClick={() => { handleToggle(emoji); setAnchorEl(null); }}
sx={{ fontSize: '1rem', minWidth: 32, minHeight: 32 }}
>
{emoji}
</IconButton>
))}
</Box>
</Popover>
</Box>
);
};
export default MessageReactions;

View File

@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import TextField from '@mui/material/TextField';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemText from '@mui/material/ListItemText';
import Avatar from '@mui/material/Avatar';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useQuery } from '@tanstack/react-query';
import { nextcloudApi } from '../../services/nextcloud';
interface NewChatDialogProps {
open: boolean;
onClose: () => void;
onRoomCreated: (token: string) => void;
}
const NewChatDialog: React.FC<NewChatDialogProps> = ({ open, onClose, onRoomCreated }) => {
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
const { data: users, isLoading } = useQuery({
queryKey: ['nextcloud', 'users', search],
queryFn: () => nextcloudApi.searchUsers(search),
enabled: open && search.length >= 2,
staleTime: 30_000,
});
const handleSelect = async (userId: string, displayName: string) => {
setCreating(true);
try {
const { token } = await nextcloudApi.createRoom(1, userId, displayName);
onRoomCreated(token);
onClose();
} catch {
// ignore
} finally {
setCreating(false);
}
};
const handleClose = () => {
setSearch('');
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogTitle>Neues Gespr\u00E4ch</DialogTitle>
<DialogContent sx={{ pb: 1 }}>
<TextField
autoFocus
fullWidth
size="small"
placeholder="Benutzer suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ mb: 1 }}
/>
{(isLoading || creating) && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1 }}>
<CircularProgress size={24} />
</Box>
)}
{!isLoading && !creating && search.length >= 2 && (!users || users.length === 0) && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, textAlign: 'center' }}>
Keine Benutzer gefunden
</Typography>
)}
{!creating && users && users.length > 0 && (
<List disablePadding>
{users.map((user) => (
<ListItem key={user.id} disablePadding>
<ListItemButton onClick={() => handleSelect(user.id, user.label)}>
<ListItemAvatar sx={{ minWidth: 40 }}>
<Avatar sx={{ width: 32, height: 32, fontSize: '0.75rem' }}>
{user.label.substring(0, 2).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText primary={user.label} secondary={user.id} />
</ListItemButton>
</ListItem>
))}
</List>
)}
</DialogContent>
</Dialog>
);
};
export default NewChatDialog;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import HowToVoteIcon from '@mui/icons-material/HowToVote';
import { useQuery } from '@tanstack/react-query';
import { nextcloudApi } from '../../services/nextcloud';
interface PollMessageContentProps {
roomToken: string;
pollId: number;
pollName: string;
isOwnMessage: boolean;
}
const PollMessageContent: React.FC<PollMessageContentProps> = ({ roomToken, pollId, pollName, isOwnMessage }) => {
const { data: poll } = useQuery({
queryKey: ['nextcloud', 'poll', roomToken, pollId],
queryFn: () => nextcloudApi.getPollDetails(roomToken, pollId),
staleTime: 60_000,
});
return (
<Box
sx={{
mt: 0.5,
p: 1,
borderRadius: 1,
border: '1px solid',
borderColor: isOwnMessage ? 'rgba(255,255,255,0.3)' : 'divider',
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.1)' : 'action.hover',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.5 }}>
<HowToVoteIcon sx={{ fontSize: '1rem', opacity: 0.7 }} />
<Typography variant="caption" sx={{ opacity: 0.7 }}>Abstimmung</Typography>
</Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{poll?.question ?? pollName}
</Typography>
{poll?.options && poll.options.slice(0, 4).map((opt, idx) => (
<Typography key={idx} variant="caption" sx={{ display: 'block', opacity: 0.8 }}>
{opt}
</Typography>
))}
{poll?.options && poll.options.length > 4 && (
<Typography variant="caption" sx={{ opacity: 0.6 }}>
+{poll.options.length - 4} weitere Optionen
</Typography>
)}
</Box>
);
};
export default PollMessageContent;

View File

@@ -0,0 +1,63 @@
import React from 'react';
import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
interface RichMessageTextProps {
message: string;
messageParameters?: Record<string, any>;
isOwnMessage?: boolean;
}
const RichMessageText: React.FC<RichMessageTextProps> = ({ message, messageParameters, isOwnMessage }) => {
const parts = message.split(/(\{[^}]+\})/);
const elements = parts.map((part, i) => {
const match = part.match(/^\{([^}]+)\}$/);
if (!match) {
return part ? <span key={i}>{part}</span> : null;
}
const key = match[1];
const param = messageParameters?.[key];
if (!param) return <span key={i}>{part}</span>;
const { type, name, id } = param;
if (type === 'user' || type === 'guest' || type === 'call' || type === 'user-group') {
return (
<Chip
key={i}
label={`@${name ?? id}`}
size="small"
sx={{
height: 18,
fontSize: '0.75rem',
mx: 0.25,
bgcolor: isOwnMessage ? 'rgba(255,255,255,0.2)' : 'action.hover',
color: 'inherit',
'& .MuiChip-label': { px: 0.75 },
verticalAlign: 'middle',
}}
/>
);
}
if (type === 'highlight' || key === 'actor') {
return <strong key={i}>{name ?? id}</strong>;
}
return <span key={i}>{name ?? id ?? key}</span>;
});
return (
<Typography
variant="body2"
component="span"
sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', display: 'block' }}
>
{elements}
</Typography>
);
};
export default RichMessageText;