update nextcloud for file support
This commit is contained in:
@@ -24,11 +24,13 @@
|
|||||||
"jose": "^6.2.1",
|
"jose": "^6.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"multer": "^1.4.5-lts.2",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
|
|||||||
@@ -167,6 +167,117 @@ class NextcloudController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async uploadFile(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = req.params.token as string;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ success: false, message: 'Keine Datei übermittelt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextcloudService.uploadFileToTalk(
|
||||||
|
token,
|
||||||
|
req.file.buffer,
|
||||||
|
req.file.originalname,
|
||||||
|
req.file.mimetype,
|
||||||
|
credentials.loginName,
|
||||||
|
credentials.appPassword,
|
||||||
|
);
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('uploadFile error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = req.query.path as string;
|
||||||
|
if (!filePath) {
|
||||||
|
res.status(400).json({ success: false, message: 'Dateipfad fehlt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await nextcloudService.downloadFile(
|
||||||
|
filePath,
|
||||||
|
credentials.loginName,
|
||||||
|
credentials.appPassword,
|
||||||
|
);
|
||||||
|
const contentType = response.headers['content-type'] ?? 'application/octet-stream';
|
||||||
|
const contentDisposition = response.headers['content-disposition'] ?? `attachment; filename="${req.params.fileId}"`;
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition);
|
||||||
|
if (response.headers['content-length']) {
|
||||||
|
res.setHeader('Content-Length', response.headers['content-length']);
|
||||||
|
}
|
||||||
|
response.data.pipe(res);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('downloadFile error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Datei konnte nicht heruntergeladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFilePreview(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileId = parseInt(req.params.fileId as string, 10);
|
||||||
|
if (isNaN(fileId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Datei-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = parseInt((req.query.w as string) ?? '400', 10) || 400;
|
||||||
|
const h = parseInt((req.query.h as string) ?? '400', 10) || 400;
|
||||||
|
const response = await nextcloudService.getFilePreview(
|
||||||
|
fileId,
|
||||||
|
Math.min(w, 1200),
|
||||||
|
Math.min(h, 1200),
|
||||||
|
credentials.loginName,
|
||||||
|
credentials.appPassword,
|
||||||
|
);
|
||||||
|
const contentType = response.headers['content-type'] ?? 'image/jpeg';
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
if (response.headers['content-length']) {
|
||||||
|
res.setHeader('Content-Length', response.headers['content-length']);
|
||||||
|
}
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||||
|
response.data.pipe(res);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('getFilePreview error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Vorschau konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async markRoomAsRead(req: Request, res: Response): Promise<void> {
|
async markRoomAsRead(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
import nextcloudController from '../controllers/nextcloud.controller';
|
import nextcloudController from '../controllers/nextcloud.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||||
|
|
||||||
router.get('/', authenticate, nextcloudController.getConversations.bind(nextcloudController));
|
router.get('/', authenticate, nextcloudController.getConversations.bind(nextcloudController));
|
||||||
router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(nextcloudController));
|
router.post('/connect', authenticate, nextcloudController.initiateConnect.bind(nextcloudController));
|
||||||
@@ -12,6 +14,10 @@ router.delete('/connect', authenticate, nextcloudController.disconnect.bind(next
|
|||||||
router.get('/rooms', authenticate, nextcloudController.getRooms.bind(nextcloudController));
|
router.get('/rooms', authenticate, nextcloudController.getRooms.bind(nextcloudController));
|
||||||
router.get('/rooms/:token/messages', authenticate, nextcloudController.getMessages.bind(nextcloudController));
|
router.get('/rooms/:token/messages', authenticate, nextcloudController.getMessages.bind(nextcloudController));
|
||||||
router.post('/rooms/:token/messages', authenticate, nextcloudController.sendMessage.bind(nextcloudController));
|
router.post('/rooms/:token/messages', authenticate, nextcloudController.sendMessage.bind(nextcloudController));
|
||||||
|
router.post('/rooms/:token/files', authenticate, upload.single('file'), nextcloudController.uploadFile.bind(nextcloudController));
|
||||||
router.post('/rooms/:token/read', authenticate, nextcloudController.markRoomAsRead.bind(nextcloudController));
|
router.post('/rooms/:token/read', authenticate, nextcloudController.markRoomAsRead.bind(nextcloudController));
|
||||||
|
|
||||||
|
router.get('/files/:fileId/download', authenticate, nextcloudController.downloadFile.bind(nextcloudController));
|
||||||
|
router.get('/files/:fileId/preview', authenticate, nextcloudController.getFilePreview.bind(nextcloudController));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ interface NextcloudChatMessage {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
messageType: string;
|
messageType: string;
|
||||||
systemMessage: string;
|
systemMessage: string;
|
||||||
|
messageParameters: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
|
async function getAllConversations(loginName: string, appPassword: string): Promise<NextcloudConversation[]> {
|
||||||
@@ -253,6 +254,7 @@ async function getMessages(token: string, loginName: string, appPassword: string
|
|||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
messageType: m.messageType ?? '',
|
messageType: m.messageType ?? '',
|
||||||
systemMessage: m.systemMessage ?? '',
|
systemMessage: m.systemMessage ?? '',
|
||||||
|
messageParameters: m.messageParameters ?? {},
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
@@ -412,5 +414,151 @@ async function getConversations(loginName: string, appPassword: string): Promise
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadFileToTalk(
|
||||||
|
token: string,
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
filename: string,
|
||||||
|
mimetype: string,
|
||||||
|
loginName: string,
|
||||||
|
appPassword: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const baseUrl = environment.nextcloudUrl;
|
||||||
|
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||||
|
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`;
|
||||||
|
|
||||||
|
// Add timestamp to avoid filename conflicts
|
||||||
|
const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : '';
|
||||||
|
const base = filename.includes('.') ? filename.slice(0, filename.lastIndexOf('.')) : filename;
|
||||||
|
const safeName = `${base}_${Date.now()}${ext}`;
|
||||||
|
const remotePath = `/Talk/${safeName}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Upload via WebDAV
|
||||||
|
await httpClient.put(
|
||||||
|
`${baseUrl}/remote.php/dav/files/${encodeURIComponent(loginName)}${remotePath}`,
|
||||||
|
fileBuffer,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeader,
|
||||||
|
'Content-Type': mimetype,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Share file to room
|
||||||
|
await httpClient.post(
|
||||||
|
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/chat/${encodeURIComponent(token)}/share`,
|
||||||
|
{ path: remotePath, shareType: 10 },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': authHeader,
|
||||||
|
'OCS-APIRequest': 'true',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
const err = new Error('Nextcloud authentication invalid');
|
||||||
|
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('NextcloudService.uploadFileToTalk failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
logger.error('NextcloudService.uploadFileToTalk failed', { error });
|
||||||
|
throw new Error('Failed to upload file to Nextcloud Talk');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(
|
||||||
|
filePath: string,
|
||||||
|
loginName: string,
|
||||||
|
appPassword: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const baseUrl = environment.nextcloudUrl;
|
||||||
|
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||||
|
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.get(
|
||||||
|
`${baseUrl}/remote.php/dav/files/${encodeURIComponent(loginName)}/${filePath.replace(/^\//, '')}`,
|
||||||
|
{
|
||||||
|
headers: { 'Authorization': authHeader },
|
||||||
|
responseType: 'stream',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
const err = new Error('Nextcloud authentication invalid');
|
||||||
|
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('NextcloudService.downloadFile failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
logger.error('NextcloudService.downloadFile failed', { error });
|
||||||
|
throw new Error('Failed to download file from Nextcloud');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFilePreview(
|
||||||
|
fileId: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
loginName: string,
|
||||||
|
appPassword: string,
|
||||||
|
): Promise<any> {
|
||||||
|
const baseUrl = environment.nextcloudUrl;
|
||||||
|
if (!baseUrl || !isValidServiceUrl(baseUrl)) {
|
||||||
|
throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = `Basic ${Buffer.from(loginName + ':' + appPassword).toString('base64')}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await httpClient.get(
|
||||||
|
`${baseUrl}/index.php/core/preview`,
|
||||||
|
{
|
||||||
|
params: { fileId, x: width, y: height, a: 1 },
|
||||||
|
headers: { 'Authorization': authHeader },
|
||||||
|
responseType: 'stream',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
const err = new Error('Nextcloud authentication invalid');
|
||||||
|
(err as any).code = 'NEXTCLOUD_AUTH_INVALID';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('NextcloudService.getFilePreview failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
throw new Error(`Nextcloud API error: ${error.response?.status ?? 'unknown'}`);
|
||||||
|
}
|
||||||
|
logger.error('NextcloudService.getFilePreview failed', { error });
|
||||||
|
throw new Error('Failed to get file preview from Nextcloud');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type { NextcloudChatMessage, GetMessagesOptions };
|
export type { NextcloudChatMessage, GetMessagesOptions };
|
||||||
export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead };
|
export default { initiateLoginFlow, pollLoginFlow, getConversations, getAllConversations, getMessages, sendMessage, markAsRead, uploadFileToTalk, downloadFile, getFilePreview };
|
||||||
|
|||||||
51
backend/src/types/multer.d.ts
vendored
Normal file
51
backend/src/types/multer.d.ts
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Minimal type declaration for multer — replaced by @types/multer after npm install
|
||||||
|
declare module 'multer' {
|
||||||
|
import { RequestHandler } from 'express';
|
||||||
|
|
||||||
|
interface StorageEngine {}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
storage?: StorageEngine;
|
||||||
|
limits?: {
|
||||||
|
fileSize?: number;
|
||||||
|
files?: number;
|
||||||
|
fields?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Multer {
|
||||||
|
single(fieldname: string): RequestHandler;
|
||||||
|
array(fieldname: string, maxCount?: number): RequestHandler;
|
||||||
|
none(): RequestHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MulterStatic {
|
||||||
|
(options?: Options): Multer;
|
||||||
|
memoryStorage(): StorageEngine;
|
||||||
|
diskStorage(options: any): StorageEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
const multer: MulterStatic;
|
||||||
|
export default multer;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace Express {
|
||||||
|
interface Request {
|
||||||
|
file?: {
|
||||||
|
fieldname: string;
|
||||||
|
originalname: string;
|
||||||
|
encoding: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
};
|
||||||
|
files?: {
|
||||||
|
fieldname: string;
|
||||||
|
originalname: string;
|
||||||
|
encoding: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,18 @@ 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 type { NextcloudMessage } from '../../types/nextcloud.types';
|
import type { NextcloudMessage } from '../../types/nextcloud.types';
|
||||||
|
import FileMessageContent from './FileMessageContent';
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: NextcloudMessage;
|
message: NextcloudMessage;
|
||||||
isOwnMessage: boolean;
|
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 ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
|
||||||
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',
|
||||||
@@ -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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -50,9 +62,23 @@ const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
|
|||||||
{message.actorDisplayName}
|
{message.actorDisplayName}
|
||||||
</Typography>
|
</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' }}>
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
{message.message}
|
{message.message}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
)}
|
||||||
<Typography
|
<Typography
|
||||||
variant="caption"
|
variant="caption"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import TextField from '@mui/material/TextField';
|
|||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
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 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 { 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';
|
||||||
@@ -23,6 +26,9 @@ const ChatMessageView: React.FC = () => {
|
|||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [messages, setMessages] = useState<NextcloudMessage[]>([]);
|
const [messages, setMessages] = useState<NextcloudMessage[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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);
|
const lastMsgIdRef = useRef<number>(0);
|
||||||
|
|
||||||
// Initial fetch + long-poll loop for near-instant message delivery
|
// 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) {
|
if (!selectedRoomToken) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
|
||||||
@@ -139,7 +178,20 @@ const ChatMessageView: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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 ? (
|
{isLoading ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 2 }}>
|
||||||
<CircularProgress size={24} />
|
<CircularProgress size={24} />
|
||||||
@@ -156,7 +208,28 @@ const ChatMessageView: React.FC = () => {
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider', display: 'flex', gap: 0.5 }}>
|
<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
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -176,8 +249,8 @@ const ChatMessageView: React.FC = () => {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChatMessageView;
|
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`)
|
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/read`)
|
||||||
.then(() => undefined);
|
.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;
|
timestamp: number;
|
||||||
messageType: string;
|
messageType: string;
|
||||||
systemMessage: string;
|
systemMessage: string;
|
||||||
|
messageParameters?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NextcloudRoomListData {
|
export interface NextcloudRoomListData {
|
||||||
|
|||||||
Reference in New Issue
Block a user