feat: image/file sharing in chat with drive grouping by contact
Build & Release / Docker / backend (push) Successful in 2m10s
Build & Release / Docker / frontend (push) Successful in 2m30s
Build & Release / Desktop / Linux & Windows (push) Successful in 18m15s
Build & Release / Deploy to server (push) Successful in 34s
Build & Release / Create release (push) Has been skipped
Build & Release / Docker / backend (push) Successful in 2m10s
Build & Release / Docker / frontend (push) Successful in 2m30s
Build & Release / Desktop / Linux & Windows (push) Successful in 18m15s
Build & Release / Deploy to server (push) Successful in 34s
Build & Release / Create release (push) Has been skipped
- Attach button in chat sends encrypted files via conversation key - Images render inline; other files show a download button - Shared files stored on server with conversation_id for access control - Drive groups files by conversation partner name (sender view) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
515bef4fc9
commit
15160b9e86
3 files changed
+273
-91
No files matched your search
@@ -57,8 +57,15 @@ router.post('/upload', upload.single('file'), async (req: AuthRequest, res) => {
|
||||
|
||||
router.get('/files', async (req: AuthRequest, res) => {
|
||||
const result = await db.query(
|
||||
`SELECT id, encrypted_name, mime_type, size_bytes, encrypted_file_key, uploaded_at
|
||||
FROM files WHERE owner_id = $1 AND deleted_at IS NULL ORDER BY uploaded_at DESC`,
|
||||
`SELECT f.id, f.encrypted_name, f.mime_type, f.size_bytes, f.encrypted_file_key,
|
||||
f.uploaded_at, f.conversation_id,
|
||||
u.username AS shared_with_username
|
||||
FROM files f
|
||||
LEFT JOIN conversation_members cm
|
||||
ON cm.conversation_id = f.conversation_id AND cm.user_id != $1
|
||||
LEFT JOIN users u ON u.id = cm.user_id
|
||||
WHERE f.owner_id = $1 AND f.deleted_at IS NULL
|
||||
ORDER BY f.uploaded_at DESC`,
|
||||
[req.userId]
|
||||
);
|
||||
res.json(result.rows);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Upload, File, Trash2, Download, Lock } from 'lucide-react';
|
||||
import { Upload, File, Trash2, Download, Lock, MessageSquare } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import api from '../lib/api';
|
||||
import { useAuthStore } from '../lib/store';
|
||||
@@ -21,6 +21,8 @@ interface DriveFile {
|
||||
size_bytes: number;
|
||||
encrypted_file_key: string;
|
||||
uploaded_at: string;
|
||||
conversation_id: string | null;
|
||||
shared_with_username: string | null;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
@@ -157,41 +159,86 @@ export default function DrivePage() {
|
||||
<p>No files yet — drag & drop or click Upload</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{files.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center gap-4 bg-surface-1 rounded-lg px-4 py-3 border border-slate-700"
|
||||
>
|
||||
<File className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{decryptedNames[f.id] ?? '…'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatBytes(f.size_bytes)} ·{' '}
|
||||
{formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(f)}
|
||||
className="p-1.5 text-slate-400 hover:text-white transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(f.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-red-400 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FileGroups files={files} decryptedNames={decryptedNames}
|
||||
onDownload={handleDownload} onDelete={handleDelete} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ f, name, onDownload, onDelete }: {
|
||||
f: DriveFile; name: string;
|
||||
onDownload: (f: DriveFile) => void; onDelete: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 bg-surface-1 rounded-lg px-4 py-3 border border-slate-700">
|
||||
<File className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatBytes(f.size_bytes)} · {formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => onDownload(f)} className="p-1.5 text-slate-400 hover:text-white transition-colors" title="Download">
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => onDelete(f.id)} className="p-1.5 text-slate-400 hover:text-red-400 transition-colors" title="Delete">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileGroups({ files, decryptedNames, onDownload, onDelete }: {
|
||||
files: DriveFile[];
|
||||
decryptedNames: Record<string, string>;
|
||||
onDownload: (f: DriveFile) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const personal = files.filter((f) => !f.conversation_id);
|
||||
const byPerson = files
|
||||
.filter((f) => f.conversation_id && f.shared_with_username)
|
||||
.reduce<Record<string, DriveFile[]>>((acc, f) => {
|
||||
const name = f.shared_with_username!;
|
||||
if (!acc[name]) acc[name] = [];
|
||||
acc[name].push(f);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(byPerson).map(([person, personFiles]) => (
|
||||
<div key={person}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MessageSquare className="w-4 h-4 text-slate-500" />
|
||||
<h2 className="text-sm font-semibold text-slate-400">{person}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{personFiles.map((f) => (
|
||||
<FileRow key={f.id} f={f} name={decryptedNames[f.id] ?? '…'}
|
||||
onDownload={onDownload} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{personal.length > 0 && (
|
||||
<div>
|
||||
{Object.keys(byPerson).length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<File className="w-4 h-4 text-slate-500" />
|
||||
<h2 className="text-sm font-semibold text-slate-400">Personal</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{personal.map((f) => (
|
||||
<FileRow key={f.id} f={f} name={decryptedNames[f.id] ?? '…'}
|
||||
onDownload={onDownload} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Send, Plus, Search, ArrowLeft } from 'lucide-react';
|
||||
import { Send, Plus, Search, ArrowLeft, Paperclip, Download, ImageIcon } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import api from '../lib/api';
|
||||
import { useAuthStore, useCryptoStore } from '../lib/store';
|
||||
@@ -13,6 +13,12 @@ import {
|
||||
sealConvKey,
|
||||
openSealedConvKey,
|
||||
importConvKey,
|
||||
encryptFile,
|
||||
decryptFile,
|
||||
generateFileKey,
|
||||
encryptFileKey,
|
||||
bufToB64,
|
||||
b64ToBuf,
|
||||
} from '../crypto';
|
||||
|
||||
interface Conversation {
|
||||
@@ -28,15 +34,98 @@ interface Message {
|
||||
sender_username: string;
|
||||
ciphertext: string;
|
||||
sent_at: string;
|
||||
file_id?: string;
|
||||
plaintext?: string;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
type: 'text' | 'file';
|
||||
text?: string;
|
||||
fileId?: string;
|
||||
fileKeyB64?: string;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
interface UserResult {
|
||||
id: string;
|
||||
username: string;
|
||||
public_key: string;
|
||||
}
|
||||
|
||||
function parsePlaintext(plain: string): ParsedMessage {
|
||||
try {
|
||||
const obj = JSON.parse(plain);
|
||||
if (obj.type === 'file' && obj.fileId) return obj as ParsedMessage;
|
||||
} catch {}
|
||||
return { type: 'text', text: plain };
|
||||
}
|
||||
|
||||
function FileMessage({ msg, fileKeyB64, fileName, mimeType, isMine }: {
|
||||
msg: Message; fileKeyB64: string; fileName: string; mimeType: string; isMine: boolean;
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isImage = mimeType.startsWith('image/');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || !msg.file_id) return;
|
||||
let url: string;
|
||||
setLoading(true);
|
||||
api.get(`/files/${msg.file_id}/download`, { responseType: 'blob' })
|
||||
.then(async (res) => {
|
||||
const fileKey = await importConvKey(b64ToBuf(fileKeyB64));
|
||||
const decrypted = await decryptFile(res.data, fileKey);
|
||||
url = URL.createObjectURL(new Blob([decrypted], { type: mimeType }));
|
||||
setSrc(url);
|
||||
})
|
||||
.catch(() => setSrc(null))
|
||||
.finally(() => setLoading(false));
|
||||
return () => { if (url) URL.revokeObjectURL(url); };
|
||||
}, [msg.file_id, fileKeyB64, mimeType, isImage]);
|
||||
|
||||
async function download() {
|
||||
if (!msg.file_id) return;
|
||||
const res = await api.get(`/files/${msg.file_id}/download`, { responseType: 'blob' });
|
||||
const fileKey = await importConvKey(b64ToBuf(fileKeyB64));
|
||||
const decrypted = await decryptFile(res.data, fileKey);
|
||||
const url = URL.createObjectURL(new Blob([decrypted], { type: mimeType }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = fileName; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`max-w-xs lg:max-w-sm rounded-2xl overflow-hidden text-sm ${
|
||||
isMine ? 'bg-accent text-white rounded-br-sm' : 'bg-surface-1 text-slate-100 rounded-bl-sm'
|
||||
}`}>
|
||||
{isImage ? (
|
||||
loading ? (
|
||||
<div className="w-48 h-32 flex items-center justify-center bg-slate-700">
|
||||
<ImageIcon className="w-6 h-6 text-slate-500 animate-pulse" />
|
||||
</div>
|
||||
) : src ? (
|
||||
<img src={src} alt={fileName} className="max-w-full max-h-64 object-contain cursor-pointer"
|
||||
onClick={download} />
|
||||
) : (
|
||||
<div className="w-48 h-32 flex items-center justify-center bg-slate-700 text-slate-400 text-xs">
|
||||
Failed to load
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<button onClick={download} className="flex items-center gap-1.5 flex-1 min-w-0 hover:opacity-80">
|
||||
<Download className="w-4 h-4 shrink-0" />
|
||||
<span className="truncate text-xs">{fileName}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-xs px-3 pb-2 ${isMine ? 'text-indigo-200' : 'text-slate-500'}`}>
|
||||
{msg.sent_at ? formatDistanceToNow(new Date(msg.sent_at), { addSuffix: true }) : 'just now'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { id: activeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -47,24 +136,23 @@ export default function MessagesPage() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [newUserSearch, setNewUserSearch] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<UserResult[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: conversations = [] } = useQuery<Conversation[]>({
|
||||
queryKey: ['conversations'],
|
||||
queryFn: () => api.get('/conversations').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Load + decrypt messages when active conversation changes
|
||||
useEffect(() => {
|
||||
if (!activeId || !publicKey || !privateKey) return;
|
||||
|
||||
async function load() {
|
||||
const conv = conversations.find((c) => c.id === activeId);
|
||||
if (!conv) return;
|
||||
|
||||
let convKey = getConvKey(activeId!);
|
||||
if (!convKey) {
|
||||
// Decrypt the conversation key that was sealed for us with our X25519 public key
|
||||
const raw = await openSealedConvKey(conv.encrypted_key, privateKey!);
|
||||
convKey = await importConvKey(raw);
|
||||
setConvKey(activeId!, convKey);
|
||||
@@ -79,16 +167,14 @@ export default function MessagesPage() {
|
||||
);
|
||||
setMessages(decrypted);
|
||||
}
|
||||
|
||||
load();
|
||||
}, [activeId, conversations.length]);
|
||||
|
||||
// WebSocket: decrypt incoming messages in real time (skip own — already shown optimistically)
|
||||
useEffect(() => {
|
||||
return socket.on(async (raw) => {
|
||||
const msg = raw as any;
|
||||
if (msg.type !== 'message' || msg.conversationId !== activeId) return;
|
||||
if (msg.senderId === userId) return; // Own message already added optimistically
|
||||
if (msg.senderId === userId) return;
|
||||
|
||||
const convKey = getConvKey(activeId!);
|
||||
if (!convKey) return;
|
||||
@@ -96,7 +182,13 @@ export default function MessagesPage() {
|
||||
const plaintext = await decryptMessage(msg.ciphertext, convKey).catch(() => '[encrypted]');
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ ...msg, sender_username: msg.senderUsername ?? msg.senderId, plaintext, sent_at: msg.sentAt ?? new Date().toISOString() },
|
||||
{
|
||||
...msg,
|
||||
sender_username: msg.senderUsername ?? msg.senderId,
|
||||
plaintext,
|
||||
file_id: msg.fileId ?? undefined,
|
||||
sent_at: msg.sentAt ?? new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [activeId, userId]);
|
||||
@@ -111,11 +203,9 @@ export default function MessagesPage() {
|
||||
if (!convKey) return;
|
||||
|
||||
const plaintext = text.trim();
|
||||
setText(''); // Clear immediately for responsiveness
|
||||
setText('');
|
||||
|
||||
const ciphertext = await encryptMessage(plaintext, convKey);
|
||||
|
||||
// Optimistic update — show in UI before server confirms
|
||||
setMessages((prev) => [...prev, {
|
||||
id: `pending-${Date.now()}`,
|
||||
sender_id: userId!,
|
||||
@@ -128,31 +218,64 @@ export default function MessagesPage() {
|
||||
socket.send({ type: 'message', conversationId: activeId, ciphertext });
|
||||
}
|
||||
|
||||
const sendFile = useCallback(async (file: File) => {
|
||||
if (!activeId) return;
|
||||
const convKey = getConvKey(activeId);
|
||||
if (!convKey) return;
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const fileKey = await generateFileKey();
|
||||
const encryptedBlob = await encryptFile(file, fileKey);
|
||||
const encryptedFileKey = await encryptFileKey(fileKey, useAuthStore.getState().masterKey!);
|
||||
const encryptedName = await encryptMessage(file.name, fileKey);
|
||||
|
||||
const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
|
||||
const fileKeyB64 = bufToB64(rawFileKey);
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', encryptedBlob, 'blob');
|
||||
form.append('encryptedName', encryptedName);
|
||||
form.append('encryptedFileKey', encryptedFileKey);
|
||||
form.append('mimeType', file.type || 'application/octet-stream');
|
||||
form.append('conversationId', activeId);
|
||||
|
||||
const uploadRes = await api.post('/upload', form, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
const fileId = uploadRes.data.id;
|
||||
|
||||
const payload = JSON.stringify({ type: 'file', fileId, fileKeyB64, fileName: file.name, mimeType: file.type });
|
||||
const ciphertext = await encryptMessage(payload, convKey);
|
||||
|
||||
setMessages((prev) => [...prev, {
|
||||
id: `pending-${Date.now()}`,
|
||||
sender_id: userId!,
|
||||
sender_username: useAuthStore.getState().username || userId!,
|
||||
ciphertext,
|
||||
plaintext: payload,
|
||||
file_id: fileId,
|
||||
sent_at: new Date().toISOString(),
|
||||
}]);
|
||||
|
||||
socket.send({ type: 'message', conversationId: activeId, ciphertext, fileId });
|
||||
qc.invalidateQueries({ queryKey: ['files'] });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [activeId, getConvKey, userId, qc]);
|
||||
|
||||
async function startConversation(recipient: UserResult) {
|
||||
if (!publicKey || !privateKey) return;
|
||||
|
||||
// Generate random conversation key
|
||||
const convKeyRaw = generateConvKeyRaw();
|
||||
|
||||
// Seal a copy for each participant using their X25519 public key
|
||||
// Each party can only open their own copy
|
||||
const [myEncKey, recipientEncKey] = await Promise.all([
|
||||
sealConvKey(convKeyRaw, publicKey),
|
||||
sealConvKey(convKeyRaw, recipient.public_key),
|
||||
]);
|
||||
|
||||
const res = await api.post('/conversations', {
|
||||
recipientId: recipient.id,
|
||||
encryptedKeys: {
|
||||
[userId!]: myEncKey,
|
||||
[recipient.id]: recipientEncKey,
|
||||
},
|
||||
encryptedKeys: { [userId!]: myEncKey, [recipient.id]: recipientEncKey },
|
||||
});
|
||||
|
||||
// Cache the decrypted key in memory for immediate use
|
||||
const convKey = await importConvKey(convKeyRaw);
|
||||
setConvKey(res.data.conversationId, convKey);
|
||||
|
||||
qc.invalidateQueries({ queryKey: ['conversations'] });
|
||||
setNewUserSearch('');
|
||||
setSearchResults([]);
|
||||
@@ -171,7 +294,7 @@ export default function MessagesPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Conversation list — full screen on mobile when no chat open, sidebar on desktop */}
|
||||
{/* Conversation list */}
|
||||
<aside className={`${activeId ? 'hidden md:flex' : 'flex'} w-full md:w-64 border-r border-slate-700 flex-col bg-surface-1 shrink-0`}>
|
||||
<div className="p-3 border-b border-slate-700">
|
||||
<div className="relative">
|
||||
@@ -186,11 +309,8 @@ export default function MessagesPage() {
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-1 bg-surface-2 rounded-lg border border-slate-600 overflow-hidden">
|
||||
{searchResults.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => startConversation(u)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
<button key={u.id} onClick={() => startConversation(u)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-600 transition-colors">
|
||||
{u.username}
|
||||
</button>
|
||||
))}
|
||||
@@ -202,13 +322,10 @@ export default function MessagesPage() {
|
||||
{conversations.map((conv) => {
|
||||
const other = conv.members.find((m) => m.id !== userId);
|
||||
return (
|
||||
<Link
|
||||
key={conv.id}
|
||||
to={`/messages/${conv.id}`}
|
||||
<Link key={conv.id} to={`/messages/${conv.id}`}
|
||||
className={`flex items-center gap-3 px-3 py-3 border-b border-slate-700/50 hover:bg-surface-2 transition-colors ${
|
||||
activeId === conv.id ? 'bg-surface-2' : ''
|
||||
}`}
|
||||
>
|
||||
}`}>
|
||||
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-xs font-bold shrink-0">
|
||||
{other?.username[0]?.toUpperCase() ?? '?'}
|
||||
</div>
|
||||
@@ -226,15 +343,12 @@ export default function MessagesPage() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Chat area — hidden on mobile when no chat open */}
|
||||
{/* Chat area */}
|
||||
<div className={`${activeId ? 'flex' : 'hidden md:flex'} flex-1 flex-col`}>
|
||||
{activeId ? (
|
||||
<>
|
||||
<header className="px-4 py-3 border-b border-slate-700 bg-surface-1 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/messages')}
|
||||
className="md:hidden p-1 -ml-1 text-slate-400 hover:text-white"
|
||||
>
|
||||
<button onClick={() => navigate('/messages')} className="md:hidden p-1 -ml-1 text-slate-400 hover:text-white">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="w-8 h-8 rounded-full bg-accent flex items-center justify-center text-sm font-bold shrink-0">
|
||||
@@ -250,20 +364,27 @@ export default function MessagesPage() {
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
|
||||
{messages.map((m) => {
|
||||
const mine = m.sender_id === userId;
|
||||
const parsed = parsePlaintext(m.plaintext ?? '');
|
||||
return (
|
||||
<div key={m.id} className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${
|
||||
mine
|
||||
? 'bg-accent text-white rounded-br-sm'
|
||||
: 'bg-surface-1 text-slate-100 rounded-bl-sm'
|
||||
}`}
|
||||
>
|
||||
<p>{m.plaintext}</p>
|
||||
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
|
||||
{m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'}
|
||||
</p>
|
||||
</div>
|
||||
{parsed.type === 'file' && parsed.fileId ? (
|
||||
<FileMessage
|
||||
msg={{ ...m, file_id: parsed.fileId }}
|
||||
fileKeyB64={parsed.fileKeyB64!}
|
||||
fileName={parsed.fileName ?? 'file'}
|
||||
mimeType={parsed.mimeType ?? 'application/octet-stream'}
|
||||
isMine={mine}
|
||||
/>
|
||||
) : (
|
||||
<div className={`max-w-xs lg:max-w-md px-4 py-2 rounded-2xl text-sm ${
|
||||
mine ? 'bg-accent text-white rounded-br-sm' : 'bg-surface-1 text-slate-100 rounded-bl-sm'
|
||||
}`}>
|
||||
<p>{parsed.text}</p>
|
||||
<p className={`text-xs mt-1 ${mine ? 'text-indigo-200' : 'text-slate-500'}`}>
|
||||
{m.sent_at ? formatDistanceToNow(new Date(m.sent_at), { addSuffix: true }) : 'just now'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -271,14 +392,21 @@ export default function MessagesPage() {
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-slate-700 flex gap-2">
|
||||
<input ref={fileInputRef} type="file" accept="image/*,*/*" className="hidden"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) sendFile(f); e.target.value = ''; }} />
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
|
||||
className="p-2 text-slate-400 hover:text-white transition-colors" title="Attach file">
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Message…"
|
||||
placeholder={uploading ? 'Uploading…' : 'Message…'}
|
||||
value={text}
|
||||
disabled={uploading}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()}
|
||||
/>
|
||||
<button onClick={sendMessage} className="btn-primary px-4">
|
||||
<button onClick={sendMessage} disabled={uploading} className="btn-primary px-4">
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user