update nextcloud for file support

This commit is contained in:
Matthias Hochmeister
2026-03-13 13:46:08 +01:00
parent e36de3199a
commit e26d77ef35
10 changed files with 600 additions and 24 deletions

View File

@@ -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={{

View File

@@ -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;

View 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;

View File

@@ -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}`;
},
};

View File

@@ -38,6 +38,7 @@ export interface NextcloudMessage {
timestamp: number;
messageType: string;
systemMessage: string;
messageParameters?: Record<string, any>;
}
export interface NextcloudRoomListData {