update nextcloud for file support
This commit is contained in:
@@ -3,12 +3,18 @@ import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import type { NextcloudMessage } from '../../types/nextcloud.types';
|
||||
import FileMessageContent from './FileMessageContent';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: NextcloudMessage;
|
||||
isOwnMessage: boolean;
|
||||
}
|
||||
|
||||
function hasFileParams(params?: Record<string, any>): boolean {
|
||||
if (!params) return false;
|
||||
return Object.keys(params).some((k) => k === 'file' || k.startsWith('file'));
|
||||
}
|
||||
|
||||
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
|
||||
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
@@ -25,6 +31,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const isFileMessage =
|
||||
hasFileParams(message.messageParameters) && message.message.includes('{file}');
|
||||
|
||||
// For file messages, extract any surrounding text
|
||||
const textBeforeFile = isFileMessage ? message.message.split('{file}')[0].trim() : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -50,9 +62,23 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
|
||||
{message.actorDisplayName}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{message.message}
|
||||
</Typography>
|
||||
{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={{
|
||||
|
||||
@@ -4,8 +4,11 @@ import TextField from '@mui/material/TextField';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
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 { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { nextcloudApi } from '../../services/nextcloud';
|
||||
import { useChat } from '../../contexts/ChatContext';
|
||||
@@ -23,6 +26,9 @@ const ChatMessageView: React.FC = () => {
|
||||
const [input, setInput] = useState('');
|
||||
const [messages, setMessages] = useState<NextcloudMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const lastMsgIdRef = useRef<number>(0);
|
||||
|
||||
// Initial fetch + long-poll loop for near-instant message delivery
|
||||
@@ -118,6 +124,39 @@ const ChatMessageView: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadFile = async (file: File) => {
|
||||
if (!selectedRoomToken) return;
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
return;
|
||||
}
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
await nextcloudApi.uploadFile(selectedRoomToken, file, setUploadProgress);
|
||||
} finally {
|
||||
setUploadProgress(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUploadFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => setIsDragOver(false);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleUploadFile(file);
|
||||
};
|
||||
|
||||
if (!selectedRoomToken) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
|
||||
@@ -139,7 +178,20 @@ const ChatMessageView: React.FC = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
py: 1,
|
||||
outline: isDragOver ? '2px dashed' : 'none',
|
||||
outlineColor: 'primary.main',
|
||||
outlineOffset: '-4px',
|
||||
transition: 'outline 0.1s',
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
|
||||
<CircularProgress size={24} />
|
||||
@@ -156,28 +208,49 @@ const ChatMessageView: React.FC = () => {
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider', display: 'flex', gap: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Nachricht..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
multiline
|
||||
maxRows={3}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sendMutation.isPending}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
{uploadProgress !== null && (
|
||||
<LinearProgress variant="determinate" value={uploadProgress} sx={{ mb: 0.5, borderRadius: 1 }} />
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<Tooltip title="Datei anhängen">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadProgress !== null || !selectedRoomToken}
|
||||
>
|
||||
<AttachFileIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Nachricht..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
multiline
|
||||
maxRows={3}
|
||||
/>
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sendMutation.isPending}
|
||||
>
|
||||
<SendIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageView;
|
||||
|
||||
|
||||
130
frontend/src/components/chat/FileMessageContent.tsx
Normal file
130
frontend/src/components/chat/FileMessageContent.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
interface FileParam {
|
||||
name: string;
|
||||
size?: number;
|
||||
mimetype?: string;
|
||||
id: number | string;
|
||||
path?: string;
|
||||
previewAvailable?: string;
|
||||
}
|
||||
|
||||
interface FileMessageContentProps {
|
||||
messageParameters: Record<string, any>;
|
||||
isOwnMessage: boolean;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function extractFileParams(messageParameters: Record<string, any>): FileParam[] {
|
||||
const files: FileParam[] = [];
|
||||
for (const key of Object.keys(messageParameters)) {
|
||||
if (key === 'file' || key.startsWith('file')) {
|
||||
const val = messageParameters[key];
|
||||
if (val && typeof val === 'object' && val.id) {
|
||||
files.push(val as FileParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const FileMessageContent: React.FC<FileMessageContentProps> = ({ messageParameters, isOwnMessage }) => {
|
||||
const files = extractFileParams(messageParameters);
|
||||
if (files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file, idx) => {
|
||||
const downloadUrl = `/api/nextcloud/talk/files/${file.id}/download${file.path ? `?path=${encodeURIComponent(file.path)}` : ''}`;
|
||||
const previewUrl = `/api/nextcloud/talk/files/${file.id}/preview?w=400&h=400`;
|
||||
const isImage = file.mimetype?.startsWith('image/') && file.previewAvailable === 'yes';
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<Box key={idx} sx={{ mt: 0.5 }}>
|
||||
<a href={downloadUrl} target="_blank" rel="noopener noreferrer" download={file.name}>
|
||||
<Box
|
||||
component="img"
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
sx={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 200,
|
||||
borderRadius: 1,
|
||||
display: 'block',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.9 },
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
<Typography variant="caption" sx={{ opacity: 0.7, fontSize: '0.7rem' }}>
|
||||
{file.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 0.75,
|
||||
borderRadius: 1,
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<InsertDriveFileIcon fontSize="small" sx={{ opacity: 0.8, flexShrink: 0 }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
title={file.name}
|
||||
sx={{ fontWeight: 500, lineHeight: 1.2 }}
|
||||
>
|
||||
{file.name}
|
||||
</Typography>
|
||||
{file.size && (
|
||||
<Typography variant="caption" sx={{ opacity: 0.7 }}>
|
||||
{formatFileSize(file.size)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Tooltip title="Herunterladen">
|
||||
<IconButton
|
||||
size="small"
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
download={file.name}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ flexShrink: 0, color: 'inherit' }}
|
||||
>
|
||||
<DownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileMessageContent;
|
||||
@@ -68,4 +68,32 @@ export const nextcloudApi = {
|
||||
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/read`)
|
||||
.then(() => undefined);
|
||||
},
|
||||
|
||||
uploadFile(
|
||||
token: string,
|
||||
file: File,
|
||||
onProgress?: (pct: number) => void,
|
||||
): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api
|
||||
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: onProgress
|
||||
? (e) => {
|
||||
if (e.total) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.then(() => undefined);
|
||||
},
|
||||
|
||||
getFileDownloadUrl(fileId: number | string, filePath?: string): string {
|
||||
const base = `/api/nextcloud/talk/files/${fileId}/download`;
|
||||
return filePath ? `${base}?path=${encodeURIComponent(filePath)}` : base;
|
||||
},
|
||||
|
||||
getFilePreviewUrl(fileId: number | string, w = 400, h = 400): string {
|
||||
return `/api/nextcloud/talk/files/${fileId}/preview?w=${w}&h=${h}`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface NextcloudMessage {
|
||||
timestamp: number;
|
||||
messageType: string;
|
||||
systemMessage: string;
|
||||
messageParameters?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface NextcloudRoomListData {
|
||||
|
||||
Reference in New Issue
Block a user