feat: Improve file display, timezone handling, and LOT management
Changes: - Fix datetime serialization with UTC 'Z' suffix for correct timezone display - Add PDF upload support with extension fallback for MIME detection - Fix LOT add/remove by creating new list for SQLAlchemy JSON change detection - Add file message components (FileMessage, ImageLightbox, UploadPreview) - Add multi-file upload support with progress tracking - Link uploaded files to chat messages via message_id - Include file attachments in AI report generation - Update specs for file-storage, realtime-messaging, and ai-report-generation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ interface ActionBarProps {
|
||||
canManageMembers: boolean
|
||||
isGeneratingReport: boolean
|
||||
uploadProgress: number | null
|
||||
uploadInfo?: { current: number; total: number } | null // For multi-file upload
|
||||
onFileSelect: (files: FileList | null) => void
|
||||
onGenerateReport: () => void
|
||||
onAddMemberClick: () => void
|
||||
@@ -29,6 +30,7 @@ export function ActionBar({
|
||||
canManageMembers,
|
||||
isGeneratingReport,
|
||||
uploadProgress,
|
||||
uploadInfo,
|
||||
onFileSelect,
|
||||
onGenerateReport,
|
||||
onAddMemberClick,
|
||||
@@ -54,13 +56,18 @@ export function ActionBar({
|
||||
|
||||
return (
|
||||
<div className="bg-white border-t border-gray-200">
|
||||
{/* Hidden file input */}
|
||||
{/* Hidden file input - supports multiple file selection */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => onFileSelect(e.target.files)}
|
||||
onChange={(e) => {
|
||||
onFileSelect(e.target.files)
|
||||
// Reset input so same files can be selected again
|
||||
e.target.value = ''
|
||||
}}
|
||||
className="hidden"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.log"
|
||||
multiple
|
||||
/>
|
||||
|
||||
{/* Action bar content */}
|
||||
@@ -121,12 +128,16 @@ export function ActionBar({
|
||||
onClick={handleFileClick}
|
||||
disabled={uploadProgress !== null}
|
||||
className={buttonClass}
|
||||
title="Upload file"
|
||||
title="Upload files (multiple supported)"
|
||||
>
|
||||
{uploadProgress !== null ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
<span>{uploadProgress}%</span>
|
||||
<span>
|
||||
{uploadInfo && uploadInfo.total > 1
|
||||
? `${uploadInfo.current}/${uploadInfo.total} (${uploadProgress}%)`
|
||||
: `${uploadProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -217,7 +228,11 @@ export function ActionBar({
|
||||
{uploadProgress !== null ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs">{uploadProgress}%</span>
|
||||
<span className="text-xs">
|
||||
{uploadInfo && uploadInfo.total > 1
|
||||
? `${uploadInfo.current}/${uploadInfo.total}`
|
||||
: `${uploadProgress}%`}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
288
frontend/src/components/chat/FileMessage.tsx
Normal file
288
frontend/src/components/chat/FileMessage.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState } from 'react'
|
||||
import type { Message } from '../../types'
|
||||
import { formatMessageTime } from '../../utils/datetime'
|
||||
import { filesService } from '../../services/files'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
|
||||
// File type icon mapping with colors
|
||||
const FILE_ICONS: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||
'application/pdf': {
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM9 15h6v1H9v-1zm0-2h6v1H9v-1zm0-2h6v1H9v-1z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'text-red-500 bg-red-100',
|
||||
},
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 17h3v-1H8v1zm0-2h3v-1H8v1zm0-2h3v-1H8v1zm5 4h3v-1h-3v1zm0-2h3v-1h-3v1zm0-2h3v-1h-3v1z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'text-green-600 bg-green-100',
|
||||
},
|
||||
'application/vnd.ms-excel': {
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM8 17h3v-1H8v1zm0-2h3v-1H8v1zm0-2h3v-1H8v1zm5 4h3v-1h-3v1zm0-2h3v-1h-3v1zm0-2h3v-1h-3v1z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'text-green-600 bg-green-100',
|
||||
},
|
||||
'text/plain': {
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-gray-500 bg-gray-100',
|
||||
},
|
||||
'text/csv': {
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM7 13h2v2H7v-2zm0 3h2v2H7v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2zm3-3h2v2h-2v-2zm0 3h2v2h-2v-2z"/>
|
||||
</svg>
|
||||
),
|
||||
color: 'text-green-500 bg-green-100',
|
||||
},
|
||||
'application/x-log': {
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-orange-500 bg-orange-100',
|
||||
},
|
||||
default: {
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'text-gray-400 bg-gray-100',
|
||||
},
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string, filename: string): { icon: React.ReactNode; color: string } {
|
||||
// Check mime type first
|
||||
if (FILE_ICONS[mimeType]) {
|
||||
return FILE_ICONS[mimeType]
|
||||
}
|
||||
|
||||
// Check file extension for .log files
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (ext === 'log') {
|
||||
return FILE_ICONS['application/x-log']
|
||||
}
|
||||
|
||||
return FILE_ICONS.default
|
||||
}
|
||||
|
||||
interface FileMessageProps {
|
||||
message: Message
|
||||
isOwnMessage: boolean
|
||||
onDownload: (url: string, filename: string) => void
|
||||
}
|
||||
|
||||
interface FileMetadataFromMessage {
|
||||
file_id: string
|
||||
file_url: string
|
||||
filename: string
|
||||
file_type: string
|
||||
mime_type?: string
|
||||
file_size?: number
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
export function FileMessage({ message, isOwnMessage, onDownload }: FileMessageProps) {
|
||||
const [showLightbox, setShowLightbox] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
// Debug: Log the message data
|
||||
console.log('[FileMessage] Rendering message:', {
|
||||
message_id: message.message_id,
|
||||
message_type: message.message_type,
|
||||
metadata: message.metadata,
|
||||
content: message.content,
|
||||
})
|
||||
|
||||
// Extract file metadata from message.metadata
|
||||
const metadata = message.metadata as FileMetadataFromMessage | undefined
|
||||
if (!metadata?.file_url) {
|
||||
console.warn('[FileMessage] No file_url in metadata, returning null. Metadata:', metadata)
|
||||
return null
|
||||
}
|
||||
|
||||
const {
|
||||
file_url,
|
||||
filename,
|
||||
file_type,
|
||||
mime_type = '',
|
||||
file_size = 0,
|
||||
thumbnail_url,
|
||||
} = metadata
|
||||
|
||||
const isImage = file_type === 'image' || mime_type.startsWith('image/')
|
||||
const fileIcon = getFileIcon(mime_type, filename)
|
||||
const displayUrl = thumbnail_url || file_url
|
||||
|
||||
// Extract caption from message content (if not the default [Image] or [File] prefix)
|
||||
const hasCaption = message.content &&
|
||||
!message.content.startsWith('[Image]') &&
|
||||
!message.content.startsWith('[File]')
|
||||
const caption = hasCaption ? message.content : null
|
||||
|
||||
const handleDownloadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDownload(file_url, filename)
|
||||
}
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg overflow-hidden ${
|
||||
isOwnMessage ? 'bg-blue-600' : 'bg-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{/* Sender name */}
|
||||
{!isOwnMessage && (
|
||||
<div className="px-3 pt-2 text-xs font-medium text-gray-500">
|
||||
{message.sender_display_name || message.sender_id}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image thumbnail */}
|
||||
<div
|
||||
className="relative cursor-pointer group"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
>
|
||||
{!imageLoaded && !imageError && (
|
||||
<div className="w-48 h-48 flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
{imageError ? (
|
||||
<div className="w-48 h-32 flex items-center justify-center bg-gray-100 text-gray-400">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt={filename}
|
||||
className={`max-w-[300px] max-h-[300px] object-contain ${!imageLoaded ? 'hidden' : ''}`}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Caption and metadata */}
|
||||
<div className="px-3 py-2">
|
||||
{caption && (
|
||||
<p className={`text-sm mb-1 ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs ${isOwnMessage ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
{formatMessageTime(message.created_at)}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDownloadClick}
|
||||
className={`p-1 rounded hover:bg-black/10 ${
|
||||
isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title="Download"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{showLightbox && (
|
||||
<ImageLightbox
|
||||
src={file_url}
|
||||
alt={filename}
|
||||
filename={filename}
|
||||
onClose={() => setShowLightbox(false)}
|
||||
onDownload={() => onDownload(file_url, filename)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Non-image file display
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[70%] rounded-lg px-4 py-3 ${
|
||||
isOwnMessage ? 'bg-blue-600 text-white' : 'bg-white shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{/* Sender name */}
|
||||
{!isOwnMessage && (
|
||||
<div className="text-xs font-medium text-gray-500 mb-2">
|
||||
{message.sender_display_name || message.sender_id}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File info */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* File icon */}
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${fileIcon.color}`}>
|
||||
{fileIcon.icon}
|
||||
</div>
|
||||
|
||||
{/* File details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
|
||||
{filename}
|
||||
</p>
|
||||
<p className={`text-xs ${isOwnMessage ? 'text-blue-200' : 'text-gray-500'}`}>
|
||||
{file_size ? filesService.formatFileSize(file_size) : 'File'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Download button */}
|
||||
<button
|
||||
onClick={handleDownloadClick}
|
||||
className={`p-2 rounded-full hover:bg-black/10 ${
|
||||
isOwnMessage ? 'text-blue-200 hover:text-white' : 'text-gray-400 hover:text-blue-600'
|
||||
}`}
|
||||
title="Download"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Caption if any */}
|
||||
{caption && (
|
||||
<p className={`text-sm mt-2 ${isOwnMessage ? 'text-white' : 'text-gray-900'}`}>
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className={`text-xs mt-2 ${isOwnMessage ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||
{formatMessageTime(message.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
frontend/src/components/chat/ImageLightbox.tsx
Normal file
123
frontend/src/components/chat/ImageLightbox.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface ImageLightboxProps {
|
||||
src: string
|
||||
alt: string
|
||||
filename: string
|
||||
onClose: () => void
|
||||
onDownload: () => void
|
||||
}
|
||||
|
||||
export function ImageLightbox({ src, alt, filename, onClose, onDownload }: ImageLightboxProps) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
// Handle keyboard events
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
// Add event listener
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
// Prevent body scroll when lightbox is open
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [handleKeyDown])
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDownload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Image preview: ${filename}`}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||
aria-label="Close preview (Escape)"
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Image container */}
|
||||
<div className="relative max-w-[90vw] max-h-[85vh] flex flex-col items-center">
|
||||
{/* Loading state */}
|
||||
{isLoading && !hasError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/30 border-t-white"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{hasError && (
|
||||
<div className="flex flex-col items-center justify-center text-white/70 py-16">
|
||||
<svg className="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<p className="text-lg">Failed to load image</p>
|
||||
<p className="text-sm text-white/50 mt-1">{filename}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`max-w-full max-h-[85vh] object-contain ${isLoading || hasError ? 'invisible' : ''}`}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => {
|
||||
setIsLoading(false)
|
||||
setHasError(true)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="absolute -bottom-12 left-0 right-0 flex items-center justify-center gap-6">
|
||||
{/* Filename */}
|
||||
<span className="text-white/80 text-sm truncate max-w-[50vw]">
|
||||
{filename}
|
||||
</span>
|
||||
|
||||
{/* Download button */}
|
||||
<button
|
||||
onClick={handleDownloadClick}
|
||||
className="flex items-center gap-2 text-white/80 hover:text-white text-sm px-3 py-1.5 rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/40 text-xs">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-white/10 rounded text-white/60">ESC</kbd> to close
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
207
frontend/src/components/chat/UploadPreview.tsx
Normal file
207
frontend/src/components/chat/UploadPreview.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { filesService } from '../../services/files'
|
||||
|
||||
// File type icon mapping with colors
|
||||
const FILE_ICONS: Record<string, { icon: React.ReactNode; bgColor: string; textColor: string }> = {
|
||||
'application/pdf': {
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4zM9 15h6v1H9v-1zm0-2h6v1H9v-1zm0-2h6v1H9v-1z"/>
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-red-100',
|
||||
textColor: 'text-red-500',
|
||||
},
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4z"/>
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-600',
|
||||
},
|
||||
'application/vnd.ms-excel': {
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm-1 2l5 5h-5V4z"/>
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-600',
|
||||
},
|
||||
'text/plain': {
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-500',
|
||||
},
|
||||
default: {
|
||||
icon: (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-400',
|
||||
},
|
||||
}
|
||||
|
||||
function getFileIcon(mimeType: string, filename: string) {
|
||||
if (FILE_ICONS[mimeType]) {
|
||||
return FILE_ICONS[mimeType]
|
||||
}
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (ext === 'log') {
|
||||
return { ...FILE_ICONS.default, bgColor: 'bg-orange-100', textColor: 'text-orange-500' }
|
||||
}
|
||||
return FILE_ICONS.default
|
||||
}
|
||||
|
||||
interface UploadPreviewProps {
|
||||
file: File
|
||||
uploadProgress: number | null
|
||||
onCancel: () => void
|
||||
onSend: (description?: string) => void
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export function UploadPreview({ file, uploadProgress, onCancel, onSend, isMobile = false }: UploadPreviewProps) {
|
||||
const [description, setDescription] = useState('')
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isImage = file.type.startsWith('image/')
|
||||
|
||||
// Generate preview for images
|
||||
useEffect(() => {
|
||||
if (isImage) {
|
||||
const url = URL.createObjectURL(file)
|
||||
setPreviewUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}
|
||||
}, [file, isImage])
|
||||
|
||||
// Auto-focus the input
|
||||
useEffect(() => {
|
||||
if (inputRef.current && !isMobile) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isMobile])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSend(description.trim() || undefined)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSend(description.trim() || undefined)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const isUploading = uploadProgress !== null
|
||||
const fileIcon = getFileIcon(file.type, file.name)
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-50 border-t border-gray-200 ${isMobile ? 'p-3' : 'p-4'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Preview */}
|
||||
<div className="flex-shrink-0">
|
||||
{isImage && previewUrl ? (
|
||||
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`w-20 h-20 rounded-lg flex items-center justify-center ${fileIcon.bgColor}`}>
|
||||
<div className={fileIcon.textColor}>{fileIcon.icon}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info and input */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-sm font-medium text-gray-900 truncate pr-2">{file.name}</p>
|
||||
{!isUploading && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
title="Cancel"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-2">{filesService.formatFileSize(file.size)}</p>
|
||||
|
||||
{/* Description input */}
|
||||
{!isUploading && (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a caption (optional)..."
|
||||
className={`flex-1 px-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none ${
|
||||
isMobile ? 'py-2.5 text-base' : 'py-1.5 text-sm'
|
||||
}`}
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={`flex-shrink-0 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-1 ${
|
||||
isMobile ? 'px-4 py-2.5' : 'px-3 py-1.5'
|
||||
}`}
|
||||
>
|
||||
<svg className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
{!isMobile && <span className="text-sm">Send</span>}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{isUploading && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-1">
|
||||
<span>Uploading...</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard hint (desktop only) */}
|
||||
{!isMobile && !isUploading && (
|
||||
<div className="mt-2 text-xs text-gray-400 text-right">
|
||||
Press <kbd className="px-1 py-0.5 bg-gray-200 rounded text-gray-500">Enter</kbd> to send,{' '}
|
||||
<kbd className="px-1 py-0.5 bg-gray-200 rounded text-gray-500">Esc</kbd> to cancel
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -134,10 +134,18 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
(data: unknown) => {
|
||||
const msg = data as { type: string }
|
||||
|
||||
// Debug: Log all incoming WebSocket messages
|
||||
console.log('[WebSocket] Received message:', msg.type, data)
|
||||
|
||||
switch (msg.type) {
|
||||
case 'message':
|
||||
case 'edit_message': {
|
||||
const messageBroadcast = data as MessageBroadcast
|
||||
console.log('[WebSocket] Processing message broadcast:', {
|
||||
message_id: messageBroadcast.message_id,
|
||||
message_type: messageBroadcast.message_type,
|
||||
metadata: messageBroadcast.metadata,
|
||||
})
|
||||
const message: Message = {
|
||||
message_id: messageBroadcast.message_id,
|
||||
room_id: messageBroadcast.room_id,
|
||||
@@ -160,7 +168,8 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
break
|
||||
}
|
||||
|
||||
case 'delete_message': {
|
||||
case 'delete_message':
|
||||
case 'message_deleted': {
|
||||
const deleteMsg = data as { message_id: string }
|
||||
removeMessage(deleteMsg.message_id)
|
||||
break
|
||||
@@ -192,6 +201,10 @@ export function useWebSocket(roomId: string | null, options?: UseWebSocketOption
|
||||
|
||||
case 'file_deleted': {
|
||||
const fileData = data as FileDeletedBroadcast
|
||||
// Also remove the associated message from chat if it exists
|
||||
if (fileData.message_id) {
|
||||
removeMessage(fileData.message_id)
|
||||
}
|
||||
options?.onFileDeleted?.(fileData)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import { MobileHeader, SlidePanel } from '../components/mobile'
|
||||
import { ActionBar } from '../components/chat/ActionBar'
|
||||
import { MentionInput, highlightMentions } from '../components/chat/MentionInput'
|
||||
import { NotificationSettings } from '../components/chat/NotificationSettings'
|
||||
import { FileMessage } from '../components/chat/FileMessage'
|
||||
import { UploadPreview } from '../components/chat/UploadPreview'
|
||||
import ReportProgress from '../components/report/ReportProgress'
|
||||
import { formatMessageTime } from '../utils/datetime'
|
||||
import type { SeverityLevel, RoomStatus, MemberRole, FileMetadata, ReportStatus, Message } from '../types'
|
||||
@@ -138,8 +140,11 @@ export default function RoomDetail() {
|
||||
const [showEmojiPickerFor, setShowEmojiPickerFor] = useState<string | null>(null)
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
const [uploadInfo, setUploadInfo] = useState<{ current: number; total: number } | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [previewFile, setPreviewFile] = useState<FileMetadata | null>(null)
|
||||
const [pendingUploadFile, setPendingUploadFile] = useState<File | null>(null)
|
||||
const [pendingUploadFiles, setPendingUploadFiles] = useState<File[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [newMemberUsername, setNewMemberUsername] = useState('')
|
||||
const [newMemberRole, setNewMemberRole] = useState<MemberRole>('viewer')
|
||||
@@ -310,25 +315,68 @@ export default function RoomDetail() {
|
||||
})
|
||||
}
|
||||
|
||||
// File handlers
|
||||
const handleFileUpload = useCallback(
|
||||
(files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
// File handlers - Show preview before upload (single file) or upload immediately (multiple files)
|
||||
const handleFileSelect = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files || files.length === 0 || !roomId) return
|
||||
|
||||
if (files.length === 1) {
|
||||
// Single file: show preview before upload
|
||||
setPendingUploadFile(files[0])
|
||||
} else {
|
||||
// Multiple files: upload immediately without preview
|
||||
const fileArray = Array.from(files)
|
||||
setPendingUploadFiles(fileArray)
|
||||
setUploadProgress(0)
|
||||
setUploadInfo({ current: 1, total: fileArray.length })
|
||||
|
||||
try {
|
||||
const result = await filesService.uploadFiles(
|
||||
roomId,
|
||||
fileArray,
|
||||
(progress, current, total) => {
|
||||
setUploadProgress(progress)
|
||||
setUploadInfo({ current, total })
|
||||
}
|
||||
)
|
||||
|
||||
if (result.failed.length > 0) {
|
||||
// Show alert for failed uploads
|
||||
const failedNames = result.failed.map(f => f.file).map(f => f.name).join(', ')
|
||||
alert(`Failed to upload: ${failedNames}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Multi-file upload failed:', error)
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
setUploadInfo(null)
|
||||
setPendingUploadFiles([])
|
||||
}
|
||||
}
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
},
|
||||
[roomId]
|
||||
)
|
||||
|
||||
const handleUploadWithDescription = useCallback(
|
||||
(description?: string) => {
|
||||
if (!pendingUploadFile) return
|
||||
|
||||
const file = files[0]
|
||||
setUploadProgress(0)
|
||||
|
||||
uploadFile.mutate(
|
||||
{
|
||||
file,
|
||||
file: pendingUploadFile,
|
||||
description,
|
||||
onProgress: (progress) => setUploadProgress(progress),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUploadProgress(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
setPendingUploadFile(null)
|
||||
},
|
||||
onError: () => {
|
||||
setUploadProgress(null)
|
||||
@@ -336,16 +384,21 @@ export default function RoomDetail() {
|
||||
}
|
||||
)
|
||||
},
|
||||
[uploadFile]
|
||||
[pendingUploadFile, uploadFile]
|
||||
)
|
||||
|
||||
const handleCancelUpload = useCallback(() => {
|
||||
setPendingUploadFile(null)
|
||||
setUploadProgress(null)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFileUpload(e.dataTransfer.files)
|
||||
handleFileSelect(e.dataTransfer.files)
|
||||
},
|
||||
[handleFileUpload]
|
||||
[handleFileSelect]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
@@ -678,6 +731,32 @@ export default function RoomDetail() {
|
||||
messages.map((message) => {
|
||||
const isOwnMessage = message.sender_id === user?.username
|
||||
const isEditing = editingMessageId === message.message_id
|
||||
const isFileMessage = message.message_type === 'image_ref' || message.message_type === 'file_ref'
|
||||
|
||||
// Handle file/image messages with FileMessage component
|
||||
if (isFileMessage) {
|
||||
return (
|
||||
<div
|
||||
key={message.message_id}
|
||||
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} group`}
|
||||
>
|
||||
<FileMessage
|
||||
message={message}
|
||||
isOwnMessage={isOwnMessage}
|
||||
onDownload={(url, filename) => {
|
||||
// Open in new tab for download
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -858,15 +937,27 @@ export default function RoomDetail() {
|
||||
canWrite={permissions?.can_write || false}
|
||||
canManageMembers={permissions?.can_manage_members || false}
|
||||
isGeneratingReport={generateReport.isPending}
|
||||
uploadProgress={uploadProgress}
|
||||
onFileSelect={handleFileUpload}
|
||||
uploadProgress={pendingUploadFile || pendingUploadFiles.length > 0 ? uploadProgress : null}
|
||||
uploadInfo={uploadInfo}
|
||||
onFileSelect={handleFileSelect}
|
||||
onGenerateReport={handleGenerateReport}
|
||||
onAddMemberClick={() => setShowAddMember(true)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{/* Upload Preview - Show when file selected but not yet uploaded */}
|
||||
{pendingUploadFile && (
|
||||
<UploadPreview
|
||||
file={pendingUploadFile}
|
||||
uploadProgress={uploadProgress}
|
||||
onCancel={handleCancelUpload}
|
||||
onSend={handleUploadWithDescription}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message Input with @Mention Support */}
|
||||
{permissions?.can_write && (
|
||||
{permissions?.can_write && !pendingUploadFile && (
|
||||
<form onSubmit={handleSendMessage} className={`p-4 bg-white border-t ${isMobile ? 'pb-2' : ''}`}>
|
||||
<div className="flex gap-2">
|
||||
<MentionInput
|
||||
@@ -1091,7 +1182,7 @@ export default function RoomDetail() {
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
{uploadProgress !== null ? (
|
||||
@@ -1370,7 +1461,7 @@ export default function RoomDetail() {
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
{uploadProgress !== null ? (
|
||||
|
||||
@@ -46,6 +46,55 @@ export const filesService = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload multiple files to room sequentially
|
||||
* Reports overall progress across all files
|
||||
*/
|
||||
async uploadFiles(
|
||||
roomId: string,
|
||||
files: File[],
|
||||
onProgress?: (progress: number, currentFile: number, totalFiles: number) => void
|
||||
): Promise<{ successful: FileUploadResponse[]; failed: { file: File; error: string }[] }> {
|
||||
const successful: FileUploadResponse[] = []
|
||||
const failed: { file: File; error: string }[] = []
|
||||
const totalFiles = files.length
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const currentFile = i + 1
|
||||
|
||||
try {
|
||||
const result = await this.uploadFile(
|
||||
roomId,
|
||||
file,
|
||||
undefined,
|
||||
(fileProgress) => {
|
||||
if (onProgress) {
|
||||
// Calculate overall progress: completed files + current file progress
|
||||
const completedProgress = (i / totalFiles) * 100
|
||||
const currentFileContribution = (fileProgress / totalFiles)
|
||||
const overallProgress = Math.round(completedProgress + currentFileContribution)
|
||||
onProgress(overallProgress, currentFile, totalFiles)
|
||||
}
|
||||
}
|
||||
)
|
||||
successful.push(result)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Upload failed'
|
||||
failed.push({ file, error: errorMessage })
|
||||
console.error(`Failed to upload ${file.name}:`, error)
|
||||
}
|
||||
|
||||
// Report completion of this file
|
||||
if (onProgress) {
|
||||
const overallProgress = Math.round(((i + 1) / totalFiles) * 100)
|
||||
onProgress(overallProgress, currentFile, totalFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return { successful, failed }
|
||||
},
|
||||
|
||||
/**
|
||||
* List files in a room
|
||||
*/
|
||||
|
||||
@@ -127,6 +127,7 @@ export type FileType = 'image' | 'document' | 'log'
|
||||
|
||||
export interface FileMetadata {
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID
|
||||
room_id: string
|
||||
filename: string
|
||||
file_type: FileType
|
||||
@@ -138,15 +139,18 @@ export interface FileMetadata {
|
||||
uploader_id: string
|
||||
deleted_at?: string | null
|
||||
download_url?: string
|
||||
thumbnail_url?: string | null // Thumbnail URL for images
|
||||
}
|
||||
|
||||
export interface FileUploadResponse {
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID
|
||||
filename: string
|
||||
file_type: FileType
|
||||
file_size: number
|
||||
mime_type: string
|
||||
download_url: string
|
||||
thumbnail_url?: string | null // Thumbnail URL for images
|
||||
uploaded_at: string
|
||||
uploader_id: string
|
||||
}
|
||||
@@ -222,6 +226,7 @@ export interface TypingBroadcast {
|
||||
export interface FileUploadedBroadcast {
|
||||
type: 'file_uploaded'
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID
|
||||
room_id: string
|
||||
uploader_id: string
|
||||
filename: string
|
||||
@@ -229,12 +234,14 @@ export interface FileUploadedBroadcast {
|
||||
file_size: number
|
||||
mime_type: string
|
||||
download_url?: string
|
||||
thumbnail_url?: string | null // Thumbnail URL for images
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
export interface FileDeletedBroadcast {
|
||||
type: 'file_deleted'
|
||||
file_id: string
|
||||
message_id?: string | null // Associated chat message ID (also deleted)
|
||||
room_id: string
|
||||
deleted_by: string
|
||||
deleted_at: string
|
||||
|
||||
Reference in New Issue
Block a user