diff --git a/backend/models.py b/backend/models.py index c50210e..4e2b703 100644 --- a/backend/models.py +++ b/backend/models.py @@ -45,6 +45,7 @@ class MessageFile(Base): id = Column(Integer, primary_key=True, index=True) message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) path = Column(Text, nullable=False) + name = Column(Text, nullable=False) message = relationship("Message", back_populates="files") diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index b03c0e4..3b09e29 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -43,6 +43,7 @@ def convert_message(msg: Message) -> dict: { "path": f"/api/uploads/files/normal/{Path(f.path).name}", "id": f.id, + "name": f.name, "message_id": f.message_id } for f in (msg.files or []) @@ -64,7 +65,7 @@ async def send_message( # Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null} try: obj = json.loads(payload) - content = obj.get("data", {}).get("content", "") + content = obj.get("content", "") reply_to_id = obj.get("reply_to_id", None) request = SendMessageRequest(content=content, reply_to_id=reply_to_id) except Exception: @@ -147,6 +148,7 @@ async def send_message( mf = MessageFile( message_id=new_message.id, + name=original_name, path=str(out_path) ) db.add(mf) diff --git a/frontend/electron.d.ts b/frontend/electron.d.ts index a825785..5dd2c3c 100644 --- a/frontend/electron.d.ts +++ b/frontend/electron.d.ts @@ -1,13 +1,15 @@ export type Platform = "win32" | "darwin" | "linux" +export interface NotificationShowOptions { + title: string; + body: string; + icon?: string; + tag?: string; +} + export interface ElectronNotifications { requestPermission: () => Promise; - show: (options: { - title: string; - body: string; - icon?: string; - tag?: string; - }) => Promise; + show: (options: NotificationShowOptions) => Promise; } export interface ElectronInterface { diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index cc9b44b..e5aff25 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import path from "node:path"; +import { NotificationShowOptions } from '../electron'; let mainWindow: BrowserWindow | null = null; @@ -35,7 +36,7 @@ app.whenReady().then(() => { }); // Handle showing notifications - ipcMain.handle('show-notification', async (event, options) => { + ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => { if (Notification.isSupported()) { try { const notification = new Notification({ diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index e1ad02c..f4f86f3 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -1,13 +1,11 @@ import { contextBridge, ipcRenderer } from "electron"; import type { ElectronInterface, Platform } from "../electron"; -const electronInterface: ElectronInterface = { +contextBridge.exposeInMainWorld("electronInterface", { desktop: true, platform: process.platform as Platform, notifications: { requestPermission: () => ipcRenderer.invoke('request-notification-permission'), - show: (options: any) => ipcRenderer.invoke('show-notification', options) + show: (options) => ipcRenderer.invoke('show-notification', options) } -} - -contextBridge.exposeInMainWorld("electronInterface", electronInterface); \ No newline at end of file +} satisfies ElectronInterface); \ No newline at end of file diff --git a/frontend/src/api/dmApi.ts b/frontend/src/api/dmApi.ts index 86194a9..84d6347 100644 --- a/frontend/src/api/dmApi.ts +++ b/frontend/src/api/dmApi.ts @@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s import { randomBytes } from "../utils/crypto/kdf"; import { getCurrentKeys } from "../auth/crypto"; import { request } from "../core/websocket"; -import type { SendDMRequest, DmEnvelope, User, DMEditWebSocketMessage, DmEncryptedJSON, BaseDmEnvelope } from "../core/types"; +import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "../core/types"; import { b64, ub64 } from "../utils/utils"; export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { @@ -159,7 +159,7 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, wrappedMk: b64(wrap.ciphertext), salt: b64(wkSalt) } - } as DMEditWebSocketMessage); + } as DMEditRequest); } export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { diff --git a/frontend/src/core/types.d.ts b/frontend/src/core/types.d.ts index 7d8d852..b5214ec 100644 --- a/frontend/src/core/types.d.ts +++ b/frontend/src/core/types.d.ts @@ -235,10 +235,10 @@ export interface DmEncryptedJSON { * @property {any} [data] - Message payload data * @property {WebSocketError} [error] - Error information if applicable */ -export interface WebSocketMessage { +export interface WebSocketMessage { type: string; credentials?: WebSocketCredentials; - data?: any; + data?: T; error?: WebSocketError; } @@ -264,26 +264,81 @@ export interface WebSocketCredentials { credentials: string; } -export interface DMEditWebSocketMessage extends WebSocketMessage { - type: "dmEdit", - data: { - id: number; - iv: string; - ciphertext: string; - iv2: string; - wrappedMk: string; - salt: string; - } -} - export interface Attachment { path: string; encrypted: boolean; - filename?: string; - content_type?: string; - size?: number; + name: string; } +// ----------------------- +// WebSocket message types +// ----------------------- + +// Utils +export interface DMEditPayload { + id: number; + iv: string; + ciphertext: string; + iv2: string; + wrappedMk: string; + salt: string; +} + +// Requests +export interface DMEditRequest extends WebSocketMessage { + type: "dmEdit", + credentials: WebSocketCredentials; + data: DMEditPayload +} + +export interface SendMessageRequest extends WebSocketMessage { + type: "sendMessage", + credentials: WebSocketCredentials; + data: { + content: string; + reply_to_id: number | null; + } +} + +// Messages +export interface DMNewWebSocketMessage extends WebSocketMessage { + type: "dmNew", + data: DmEnvelope +} + +export interface DMEditedWebSocketMessage extends WebSocketMessage { + type: "dmEdited", + data: DMEditPayload +} + +export interface DMDeletedWebSocketMessage extends WebSocketMessage { + type: "dmDeleted", + data: { + id: number; + } +} + +export interface MessageEditedWebSocketMessage extends WebSocketMessage { + type: "messageEdited", + data: Partial & { id: number } +} + +export interface MessageDeletedWebSocketMessage extends WebSocketMessage { + type: "messageDeleted", + data: { + message_id: number; + } +} + +export interface NewMessageWebSocketMessage extends WebSocketMessage { + type: "newMessage", + data: Message +} + +// Shared types +export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage +export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage + // ----------- // Encrypted message JSON (plaintext structure before encryption) // ----------- diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index e062641..c881cf3 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -33,17 +33,17 @@ export let websocket: WebSocket = create(); * Global WebSocket message handler reference * This will be set by the active panel to handle incoming messages */ -let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; +let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; /** * Set the global WebSocket message handler * @param handler - Function to handle WebSocket messages */ -export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void { +export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void { globalMessageHandler = handler; } -export function request(payload: WebSocketMessage): Promise { +export function request(payload: WebSocketMessage): Promise> { console.log("WebSocket request:", payload); return new Promise((resolve, reject) => { function requestInner() { @@ -95,7 +95,7 @@ async function onError() { websocket.addEventListener("message", (e) => { try { - const response: WebSocketMessage = JSON.parse(e.data); + const response: WebSocketMessage = JSON.parse(e.data); // Route message to global handler if set if (globalMessageHandler) { diff --git a/frontend/src/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/ui/components/chat/ChatInputWrapper.tsx index 9f5cc5e..e308f98 100644 --- a/frontend/src/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/ui/components/chat/ChatInputWrapper.tsx @@ -4,6 +4,7 @@ import { RichTextArea } from "../core/RichTextArea"; import type { Message } from "../../../core/types"; import Quote from "../core/Quote"; import AnimatedHeight from "../core/animations/AnimatedHeight"; +import { useImmer } from "use-immer"; interface ChatInputWrapperProps { onSendMessage: (message: string, files: File[]) => void; @@ -35,7 +36,7 @@ export function ChatInputWrapper( }: ChatInputWrapperProps ) { const [message, setMessage] = useState(""); - const [selectedFiles, setSelectedFiles] = useState([]); + const [selectedFiles, setSelectedFiles] = useImmer([]); const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [errorOpen, setErrorOpen] = useState(false); @@ -44,8 +45,7 @@ export function ChatInputWrapper( if (onProvideFileAdder) { const addFiles = (files: File[]) => { if (!files || files.length === 0) return; - setSelectedFiles(prev => [...prev, ...files]); - setAttachmentsVisible(true); + setSelectedFiles(draft => { draft.push(...files) }); }; onProvideFileAdder(addFiles); } @@ -53,18 +53,12 @@ export function ChatInputWrapper( // When entering edit mode, preload the message content useEffect(() => { - if (editingMessage) { - setMessage(editingMessage.content || ""); - } else { - setMessage(""); - } + setMessage(editingMessage ? editingMessage.content || "" : ""); }, [editingMessage]); useEffect(() => { - if (selectedFiles.length > 0) { - setAttachmentsVisible(true); - } - }, [selectedFiles]) + setAttachmentsVisible(selectedFiles.length > 0); + }, [selectedFiles]); const handleSubmit = async (e: React.FormEvent | Event) => { e.preventDefault(); @@ -94,10 +88,9 @@ export function ChatInputWrapper( const input = document.createElement("input"); input.type = "file"; input.multiple = true; - input.onchange = () => { - const files = Array.from(input.files || []); - setSelectedFiles(files); - }; + input.addEventListener("change", () => { + setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) }); + }); input.click(); } @@ -133,16 +126,22 @@ export function ChatInputWrapper(
- {selectedFiles.map((f, i) => ( + {selectedFiles.map((file, i) => ( setSelectedFiles(prev => prev.filter((_, idx) => idx !== i))} + title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`} + onClick={() => { + if (selectedFiles.length == 1) { + setAttachmentsVisible(false); + } else { + setSelectedFiles(draft => { draft.splice(i) }) + } + }} > - {f.name} + {file.name} ))}
diff --git a/frontend/src/ui/components/chat/Message.tsx b/frontend/src/ui/components/chat/Message.tsx index 5f6d56b..ae7588a 100644 --- a/frontend/src/ui/components/chat/Message.tsx +++ b/frontend/src/ui/components/chat/Message.tsx @@ -142,16 +142,16 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo {message.files && message.files.length > 0 && ( {message.files.map((file, idx) => { - const isImage = !file.encrypted && (file.content_type?.startsWith("image/") || /\.(png|jpg|jpeg|gif|webp)$/i.test(file.filename || "")); + const isImage = !file.encrypted && /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || ""); const downloadUrl = decryptedFiles.get(file.path) || file.path; return ( diff --git a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx index 4c735f5..60c7e86 100644 --- a/frontend/src/ui/components/core/animations/AnimatedHeight.tsx +++ b/frontend/src/ui/components/core/animations/AnimatedHeight.tsx @@ -22,26 +22,24 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi setIsAnimating(false); }, duration * 1000); }, 0); - } else { - if (shouldRender) { - setIsAnimating(true); - if (measureRef.current) { - const contentHeight = measureRef.current.scrollHeight; - setHeight(`${contentHeight}px`); - // Force a reflow before animating to 0 - requestAnimationFrame(() => { - setHeight("0px"); - }); - } - // Hide content after animation completes - setTimeout(() => { - setShouldRender(false); - setIsAnimating(false); - if (onFinish) { - onFinish(); - } - }, duration * 1000); + } else if (shouldRender) { + setIsAnimating(true); + if (measureRef.current) { + const contentHeight = measureRef.current.scrollHeight; + setHeight(`${contentHeight}px`); + // Force a reflow before animating to 0 + requestAnimationFrame(() => { + setHeight("0px"); + }); } + // Hide content after animation completes + setTimeout(() => { + setShouldRender(false); + setIsAnimating(false); + if (onFinish) { + onFinish(); + } + }, duration * 1000); } }, [visible, shouldRender]); diff --git a/frontend/src/ui/panels/DMPanel.ts b/frontend/src/ui/panels/DMPanel.ts index 14af6de..074c952 100644 --- a/frontend/src/ui/panels/DMPanel.ts +++ b/frontend/src/ui/panels/DMPanel.ts @@ -7,7 +7,7 @@ import { editDmEnvelope, deleteDmEnvelope } from "../../api/dmApi"; -import type { DmEncryptedJSON, DmEnvelope, EncryptedMessageJson, Message, WebSocketMessage } from "../../core/types"; +import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../core/types"; import type { UserState } from "../state"; export interface DMPanelData { @@ -58,19 +58,14 @@ export class DMPanel extends MessagePanel { } } catch {} - const dmMsg: Message & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = { + const dmMsg: Message = { id: env.id, content: content, username: username, timestamp: env.timestamp, is_read: false, is_edited: false, - files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [], - dmEnvelope: { - salt: env.salt, - iv2: env.iv2, - wrappedMk: env.wrappedMk - } + files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [] }; if (reply_to_id) { @@ -165,9 +160,9 @@ export class DMPanel extends MessagePanel { } // Handle incoming WebSocket DM messages - handleWebSocketMessage = async (response: WebSocketMessage): Promise => { + handleWebSocketMessage = async (response: DMWebSocketMessage): Promise => { if (response.type === "dmNew" && this.dmData) { - const envelope = response.data as DmEnvelope; + const envelope = response.data; // If this is for the active DM conversation if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { diff --git a/frontend/src/ui/panels/PublicChatPanel.ts b/frontend/src/ui/panels/PublicChatPanel.ts index 8bdf755..ca9a403 100644 --- a/frontend/src/ui/panels/PublicChatPanel.ts +++ b/frontend/src/ui/panels/PublicChatPanel.ts @@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel"; import { API_BASE_URL } from "../../core/config"; import { getAuthHeaders } from "../../auth/api"; import { request } from "../../core/websocket"; -import type { Message, WebSocketMessage } from "../../core/types"; +import type { ChatWebSocketMessage, Message, SendMessageRequest } from "../../core/types"; import type { UserState } from "../state"; export class PublicChatPanel extends MessagePanel { @@ -65,7 +65,7 @@ export class PublicChatPanel extends MessagePanel { try { if (files.length === 0) { const response = await request({ - data: { + data: { content: content.trim(), reply_to_id: replyToId ?? null }, @@ -74,13 +74,16 @@ export class PublicChatPanel extends MessagePanel { credentials: this.currentUser.authToken }, type: "sendMessage" - }); + } satisfies SendMessageRequest); if (response.error) { console.error("Error sending message:", response.error); } } else { const form = new FormData(); - form.append("payload", JSON.stringify({ type: "text", data: { content: content.trim() }, reply_to_id: replyToId ?? null })); + form.append("payload", JSON.stringify({ + content: content.trim(), + reply_to_id: replyToId ?? null + } satisfies SendMessageRequest["data"])); for (const f of files) form.append("files", f, f.name); const res = await fetch(`${API_BASE_URL}/send_message`, { method: "POST", @@ -97,7 +100,7 @@ export class PublicChatPanel extends MessagePanel { } // Handle incoming WebSocket messages - handleWebSocketMessage = (response: WebSocketMessage): void => { + handleWebSocketMessage = (response: ChatWebSocketMessage): void => { switch (response.type) { case 'messageEdited': if (response.data) { diff --git a/frontend/src/utils/push-notifications.ts b/frontend/src/utils/push-notifications.ts index 00ec50b..eb8cf5d 100644 --- a/frontend/src/utils/push-notifications.ts +++ b/frontend/src/utils/push-notifications.ts @@ -1,7 +1,7 @@ import { API_BASE_URL } from "../core/config"; import { isElectron } from "../electron/electron"; import { websocket } from "../core/websocket"; -import type { WebSocketMessage } from "../core/types"; +import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types"; export interface PushSubscriptionData { endpoint: string; @@ -124,10 +124,11 @@ async function showMessageNotification(message: any): Promise { } } -async function handleWebSocketMessage(response: WebSocketMessage): Promise { +async function handleWebSocketMessage(response: WebSocketMessage): Promise { // Handle notifications for new messages if (response.type === "newMessage" && response.data) { - await showMessageNotification(response.data); + const newResponse = response as NewMessageWebSocketMessage; + await showMessageNotification(newResponse.data); } } @@ -242,7 +243,7 @@ export async function startElectronReceiver(): Promise { // Add our own message listener to the existing WebSocket messageListener = (event: MessageEvent) => { try { - const response: WebSocketMessage = JSON.parse(event.data); + const response: WebSocketMessage = JSON.parse(event.data); handleWebSocketMessage(response); } catch (error) { console.error('Failed to parse WebSocket message:', error);