diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts
index 0e8a012..77a48b1 100644
--- a/backend/src/routes/files.ts
+++ b/backend/src/routes/files.ts
@@ -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);
diff --git a/frontend/src/pages/DrivePage.tsx b/frontend/src/pages/DrivePage.tsx
index cd9173e..516ec2c 100644
--- a/frontend/src/pages/DrivePage.tsx
+++ b/frontend/src/pages/DrivePage.tsx
@@ -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() {
No files yet — drag & drop or click Upload
) : (
-
- {files.map((f) => (
-
-
-
-
- {decryptedNames[f.id] ?? '…'}
-
-
- {formatBytes(f.size_bytes)} ·{' '}
- {formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
-
-
-
-
-
- ))}
-
+
)}
);
}
+
+function FileRow({ f, name, onDownload, onDelete }: {
+ f: DriveFile; name: string;
+ onDownload: (f: DriveFile) => void; onDelete: (id: string) => void;
+}) {
+ return (
+
+
+
+
{name}
+
+ {formatBytes(f.size_bytes)} · {formatDistanceToNow(new Date(f.uploaded_at), { addSuffix: true })}
+
+
+
+
+
+ );
+}
+
+function FileGroups({ files, decryptedNames, onDownload, onDelete }: {
+ files: DriveFile[];
+ decryptedNames: Record;
+ 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>((acc, f) => {
+ const name = f.shared_with_username!;
+ if (!acc[name]) acc[name] = [];
+ acc[name].push(f);
+ return acc;
+ }, {});
+
+ return (
+
+ {Object.entries(byPerson).map(([person, personFiles]) => (
+
+
+
+
{person}
+
+
+ {personFiles.map((f) => (
+
+ ))}
+
+
+ ))}
+
+ {personal.length > 0 && (
+
+ {Object.keys(byPerson).length > 0 && (
+
+
+
Personal
+
+ )}
+
+ {personal.map((f) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/MessagesPage.tsx b/frontend/src/pages/MessagesPage.tsx
index d9381e0..e8ff69c 100644
--- a/frontend/src/pages/MessagesPage.tsx
+++ b/frontend/src/pages/MessagesPage.tsx
@@ -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(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 (
+
+ {isImage ? (
+ loading ? (
+
+
+
+ ) : src ? (
+

+ ) : (
+
+ Failed to load
+
+ )
+ ) : null}
+
+
+
+
+ {msg.sent_at ? formatDistanceToNow(new Date(msg.sent_at), { addSuffix: true }) : 'just now'}
+
+
+ );
+}
+
export default function MessagesPage() {
const { id: activeId } = useParams();
const navigate = useNavigate();
@@ -47,24 +136,23 @@ export default function MessagesPage() {
const [messages, setMessages] = useState([]);
const [newUserSearch, setNewUserSearch] = useState('');
const [searchResults, setSearchResults] = useState([]);
+ const [uploading, setUploading] = useState(false);
const bottomRef = useRef(null);
+ const fileInputRef = useRef(null);
const { data: conversations = [] } = useQuery({
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 (
- {/* Conversation list — full screen on mobile when no chat open, sidebar on desktop */}
+ {/* Conversation list */}