Improve code and type safety

This commit is contained in:
2025-09-25 22:40:45 +03:00
Unverified
parent 0fbdcd04c9
commit f0925564c6
14 changed files with 156 additions and 99 deletions
+1
View File
@@ -45,6 +45,7 @@ class MessageFile(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True) message_id = Column(Integer, ForeignKey("message.id"), nullable=False, index=True)
path = Column(Text, nullable=False) path = Column(Text, nullable=False)
name = Column(Text, nullable=False)
message = relationship("Message", back_populates="files") message = relationship("Message", back_populates="files")
+3 -1
View File
@@ -43,6 +43,7 @@ def convert_message(msg: Message) -> dict:
{ {
"path": f"/api/uploads/files/normal/{Path(f.path).name}", "path": f"/api/uploads/files/normal/{Path(f.path).name}",
"id": f.id, "id": f.id,
"name": f.name,
"message_id": f.message_id "message_id": f.message_id
} }
for f in (msg.files or []) 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} # Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null}
try: try:
obj = json.loads(payload) obj = json.loads(payload)
content = obj.get("data", {}).get("content", "") content = obj.get("content", "")
reply_to_id = obj.get("reply_to_id", None) reply_to_id = obj.get("reply_to_id", None)
request = SendMessageRequest(content=content, reply_to_id=reply_to_id) request = SendMessageRequest(content=content, reply_to_id=reply_to_id)
except Exception: except Exception:
@@ -147,6 +148,7 @@ async def send_message(
mf = MessageFile( mf = MessageFile(
message_id=new_message.id, message_id=new_message.id,
name=original_name,
path=str(out_path) path=str(out_path)
) )
db.add(mf) db.add(mf)
+6 -4
View File
@@ -1,13 +1,15 @@
export type Platform = "win32" | "darwin" | "linux" export type Platform = "win32" | "darwin" | "linux"
export interface ElectronNotifications { export interface NotificationShowOptions {
requestPermission: () => Promise<NotificationPermission>;
show: (options: {
title: string; title: string;
body: string; body: string;
icon?: string; icon?: string;
tag?: string; tag?: string;
}) => Promise<boolean>; }
export interface ElectronNotifications {
requestPermission: () => Promise<NotificationPermission>;
show: (options: NotificationShowOptions) => Promise<boolean>;
} }
export interface ElectronInterface { export interface ElectronInterface {
+2 -1
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, Notification, ipcMain } from 'electron'; import { app, BrowserWindow, Notification, ipcMain } from 'electron';
import path from "node:path"; import path from "node:path";
import { NotificationShowOptions } from '../electron';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@@ -35,7 +36,7 @@ app.whenReady().then(() => {
}); });
// Handle showing notifications // Handle showing notifications
ipcMain.handle('show-notification', async (event, options) => { ipcMain.handle('show-notification', async (event, options: NotificationShowOptions) => {
if (Notification.isSupported()) { if (Notification.isSupported()) {
try { try {
const notification = new Notification({ const notification = new Notification({
+3 -5
View File
@@ -1,13 +1,11 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import type { ElectronInterface, Platform } from "../electron"; import type { ElectronInterface, Platform } from "../electron";
const electronInterface: ElectronInterface = { contextBridge.exposeInMainWorld("electronInterface", {
desktop: true, desktop: true,
platform: process.platform as Platform, platform: process.platform as Platform,
notifications: { notifications: {
requestPermission: () => ipcRenderer.invoke('request-notification-permission'), requestPermission: () => ipcRenderer.invoke('request-notification-permission'),
show: (options: any) => ipcRenderer.invoke('show-notification', options) show: (options) => ipcRenderer.invoke('show-notification', options)
} }
} } satisfies ElectronInterface);
contextBridge.exposeInMainWorld("electronInterface", electronInterface);
+2 -2
View File
@@ -5,7 +5,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../utils/crypto/s
import { randomBytes } from "../utils/crypto/kdf"; import { randomBytes } from "../utils/crypto/kdf";
import { getCurrentKeys } from "../auth/crypto"; import { getCurrentKeys } from "../auth/crypto";
import { request } from "../core/websocket"; 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"; import { b64, ub64 } from "../utils/utils";
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
@@ -159,7 +159,7 @@ export async function editDmEnvelope(id: number, recipientPublicKeyB64: string,
wrappedMk: b64(wrap.ciphertext), wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt) salt: b64(wkSalt)
} }
} as DMEditWebSocketMessage); } as DMEditRequest);
} }
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> { export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
+66 -11
View File
@@ -235,10 +235,10 @@ export interface DmEncryptedJSON {
* @property {any} [data] - Message payload data * @property {any} [data] - Message payload data
* @property {WebSocketError} [error] - Error information if applicable * @property {WebSocketError} [error] - Error information if applicable
*/ */
export interface WebSocketMessage { export interface WebSocketMessage<T> {
type: string; type: string;
credentials?: WebSocketCredentials; credentials?: WebSocketCredentials;
data?: any; data?: T;
error?: WebSocketError; error?: WebSocketError;
} }
@@ -264,9 +264,18 @@ export interface WebSocketCredentials {
credentials: string; credentials: string;
} }
export interface DMEditWebSocketMessage extends WebSocketMessage { export interface Attachment {
type: "dmEdit", path: string;
data: { encrypted: boolean;
name: string;
}
// -----------------------
// WebSocket message types
// -----------------------
// Utils
export interface DMEditPayload {
id: number; id: number;
iv: string; iv: string;
ciphertext: string; ciphertext: string;
@@ -274,15 +283,61 @@ export interface DMEditWebSocketMessage extends WebSocketMessage {
wrappedMk: string; wrappedMk: string;
salt: string; salt: string;
} }
// Requests
export interface DMEditRequest extends WebSocketMessage {
type: "dmEdit",
credentials: WebSocketCredentials;
data: DMEditPayload
} }
export interface Attachment { export interface SendMessageRequest extends WebSocketMessage {
path: string; type: "sendMessage",
encrypted: boolean; credentials: WebSocketCredentials;
filename?: string; data: {
content_type?: string; content: string;
size?: number; 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<Message> & { 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) // Encrypted message JSON (plaintext structure before encryption)
+4 -4
View File
@@ -33,17 +33,17 @@ export let websocket: WebSocket = create();
* Global WebSocket message handler reference * Global WebSocket message handler reference
* This will be set by the active panel to handle incoming messages * This will be set by the active panel to handle incoming messages
*/ */
let globalMessageHandler: ((response: WebSocketMessage) => void) | null = null; let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
/** /**
* Set the global WebSocket message handler * Set the global WebSocket message handler
* @param handler - Function to handle WebSocket messages * @param handler - Function to handle WebSocket messages
*/ */
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage) => void) | null): void { export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
globalMessageHandler = handler; globalMessageHandler = handler;
} }
export function request(payload: WebSocketMessage): Promise<WebSocketMessage> { export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload); console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
function requestInner() { function requestInner() {
@@ -95,7 +95,7 @@ async function onError() {
websocket.addEventListener("message", (e) => { websocket.addEventListener("message", (e) => {
try { try {
const response: WebSocketMessage = JSON.parse(e.data); const response: WebSocketMessage<any> = JSON.parse(e.data);
// Route message to global handler if set // Route message to global handler if set
if (globalMessageHandler) { if (globalMessageHandler) {
@@ -4,6 +4,7 @@ import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../core/types"; import type { Message } from "../../../core/types";
import Quote from "../core/Quote"; import Quote from "../core/Quote";
import AnimatedHeight from "../core/animations/AnimatedHeight"; import AnimatedHeight from "../core/animations/AnimatedHeight";
import { useImmer } from "use-immer";
interface ChatInputWrapperProps { interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void; onSendMessage: (message: string, files: File[]) => void;
@@ -35,7 +36,7 @@ export function ChatInputWrapper(
}: ChatInputWrapperProps }: ChatInputWrapperProps
) { ) {
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false); const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [errorOpen, setErrorOpen] = useState(false); const [errorOpen, setErrorOpen] = useState(false);
@@ -44,8 +45,7 @@ export function ChatInputWrapper(
if (onProvideFileAdder) { if (onProvideFileAdder) {
const addFiles = (files: File[]) => { const addFiles = (files: File[]) => {
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
setSelectedFiles(prev => [...prev, ...files]); setSelectedFiles(draft => { draft.push(...files) });
setAttachmentsVisible(true);
}; };
onProvideFileAdder(addFiles); onProvideFileAdder(addFiles);
} }
@@ -53,18 +53,12 @@ export function ChatInputWrapper(
// When entering edit mode, preload the message content // When entering edit mode, preload the message content
useEffect(() => { useEffect(() => {
if (editingMessage) { setMessage(editingMessage ? editingMessage.content || "" : "");
setMessage(editingMessage.content || "");
} else {
setMessage("");
}
}, [editingMessage]); }, [editingMessage]);
useEffect(() => { useEffect(() => {
if (selectedFiles.length > 0) { setAttachmentsVisible(selectedFiles.length > 0);
setAttachmentsVisible(true); }, [selectedFiles]);
}
}, [selectedFiles])
const handleSubmit = async (e: React.FormEvent | Event) => { const handleSubmit = async (e: React.FormEvent | Event) => {
e.preventDefault(); e.preventDefault();
@@ -94,10 +88,9 @@ export function ChatInputWrapper(
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.multiple = true; input.multiple = true;
input.onchange = () => { input.addEventListener("change", () => {
const files = Array.from(input.files || []); setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) });
setSelectedFiles(files); });
};
input.click(); input.click();
} }
@@ -133,16 +126,22 @@ export function ChatInputWrapper(
<div className="attachments-preview contextual-preview"> <div className="attachments-preview contextual-preview">
<mdui-icon name="attach_file" /> <mdui-icon name="attach_file" />
<div className="attachments-chips"> <div className="attachments-chips">
{selectedFiles.map((f, i) => ( {selectedFiles.map((file, i) => (
<mdui-chip <mdui-chip
key={i} key={i}
variant="input" variant="input"
end-icon="close" end-icon="close"
title={`${f.name} (${Math.round(f.size/1024/1024)} MB)`} title={`${file.name} (${Math.round(file.size/1024/1024)} MB)`}
onClick={() => setSelectedFiles(prev => prev.filter((_, idx) => idx !== i))} onClick={() => {
if (selectedFiles.length == 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
}
}}
> >
<mdui-icon slot="icon" name="attach_file"></mdui-icon> <mdui-icon slot="icon" name="attach_file"></mdui-icon>
<span className="name">{f.name}</span> <span className="name">{file.name}</span>
</mdui-chip> </mdui-chip>
))} ))}
</div> </div>
+7 -5
View File
@@ -142,16 +142,16 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
{message.files && message.files.length > 0 && ( {message.files && message.files.length > 0 && (
<mdui-list className="message-attachments"> <mdui-list className="message-attachments">
{message.files.map((file, idx) => { {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; const downloadUrl = decryptedFiles.get(file.path) || file.path;
return ( return (
<div className="attachment" key={idx}> <div className="attachment" key={idx}>
{isImage ? ( {isImage ? (
<img src={file.path} alt={file.filename || "image"} style={{ maxWidth: "200px", borderRadius: "8px" }} /> <img src={file.path} alt={file.name || "image"} style={{ maxWidth: "200px", borderRadius: "8px" }} />
) : ( ) : (
<a <a
href={downloadUrl} href={downloadUrl}
download={file.filename || "file"} download={file.name || "file"}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={async (e) => { onClick={async (e) => {
@@ -161,13 +161,15 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, isLo
if (decryptedUrl) { if (decryptedUrl) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = decryptedUrl; link.href = decryptedUrl;
link.download = file.filename || "file"; link.download = file.name || "file";
link.click(); link.click();
} }
} }
}} }}
> >
<mdui-list-item icon="download--filled">{(file.filename || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}</mdui-list-item> <mdui-list-item icon="download--filled">
{(file.name || file.path.split("/").pop() || "Имя файла неизвестно").replace(/\d+_\d+_/, "")}
</mdui-list-item>
</a> </a>
)} )}
</div> </div>
@@ -22,8 +22,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
setIsAnimating(false); setIsAnimating(false);
}, duration * 1000); }, duration * 1000);
}, 0); }, 0);
} else { } else if (shouldRender) {
if (shouldRender) {
setIsAnimating(true); setIsAnimating(true);
if (measureRef.current) { if (measureRef.current) {
const contentHeight = measureRef.current.scrollHeight; const contentHeight = measureRef.current.scrollHeight;
@@ -42,7 +41,6 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
} }
}, duration * 1000); }, duration * 1000);
} }
}
}, [visible, shouldRender]); }, [visible, shouldRender]);
// Don't render if not visible and not animating // Don't render if not visible and not animating
+5 -10
View File
@@ -7,7 +7,7 @@ import {
editDmEnvelope, editDmEnvelope,
deleteDmEnvelope deleteDmEnvelope
} from "../../api/dmApi"; } 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"; import type { UserState } from "../state";
export interface DMPanelData { export interface DMPanelData {
@@ -58,19 +58,14 @@ export class DMPanel extends MessagePanel {
} }
} catch {} } catch {}
const dmMsg: Message & { dmEnvelope?: { salt: string; iv2: string; wrappedMk: string } } = { const dmMsg: Message = {
id: env.id, id: env.id,
content: content, content: content,
username: username, username: username,
timestamp: env.timestamp, timestamp: env.timestamp,
is_read: false, is_read: false,
is_edited: false, is_edited: false,
files: env.files?.map(file => { return {"filename": file.name, "encrypted": true, "path": file.path} }) || [], files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || []
dmEnvelope: {
salt: env.salt,
iv2: env.iv2,
wrappedMk: env.wrappedMk
}
}; };
if (reply_to_id) { if (reply_to_id) {
@@ -165,9 +160,9 @@ export class DMPanel extends MessagePanel {
} }
// Handle incoming WebSocket DM messages // Handle incoming WebSocket DM messages
handleWebSocketMessage = async (response: WebSocketMessage): Promise<void> => { handleWebSocketMessage = async (response: DMWebSocketMessage): Promise<void> => {
if (response.type === "dmNew" && this.dmData) { 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 this is for the active DM conversation
if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) { if (envelope.senderId === this.dmData.userId || envelope.recipientId === this.dmData.userId) {
+7 -4
View File
@@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../core/config"; import { API_BASE_URL } from "../../core/config";
import { getAuthHeaders } from "../../auth/api"; import { getAuthHeaders } from "../../auth/api";
import { request } from "../../core/websocket"; 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"; import type { UserState } from "../state";
export class PublicChatPanel extends MessagePanel { export class PublicChatPanel extends MessagePanel {
@@ -74,13 +74,16 @@ export class PublicChatPanel extends MessagePanel {
credentials: this.currentUser.authToken credentials: this.currentUser.authToken
}, },
type: "sendMessage" type: "sendMessage"
}); } satisfies SendMessageRequest);
if (response.error) { if (response.error) {
console.error("Error sending message:", response.error); console.error("Error sending message:", response.error);
} }
} else { } else {
const form = new FormData(); 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); for (const f of files) form.append("files", f, f.name);
const res = await fetch(`${API_BASE_URL}/send_message`, { const res = await fetch(`${API_BASE_URL}/send_message`, {
method: "POST", method: "POST",
@@ -97,7 +100,7 @@ export class PublicChatPanel extends MessagePanel {
} }
// Handle incoming WebSocket messages // Handle incoming WebSocket messages
handleWebSocketMessage = (response: WebSocketMessage): void => { handleWebSocketMessage = (response: ChatWebSocketMessage): void => {
switch (response.type) { switch (response.type) {
case 'messageEdited': case 'messageEdited':
if (response.data) { if (response.data) {
+5 -4
View File
@@ -1,7 +1,7 @@
import { API_BASE_URL } from "../core/config"; import { API_BASE_URL } from "../core/config";
import { isElectron } from "../electron/electron"; import { isElectron } from "../electron/electron";
import { websocket } from "../core/websocket"; import { websocket } from "../core/websocket";
import type { WebSocketMessage } from "../core/types"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "../core/types";
export interface PushSubscriptionData { export interface PushSubscriptionData {
endpoint: string; endpoint: string;
@@ -124,10 +124,11 @@ async function showMessageNotification(message: any): Promise<void> {
} }
} }
async function handleWebSocketMessage(response: WebSocketMessage): Promise<void> { async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
// Handle notifications for new messages // Handle notifications for new messages
if (response.type === "newMessage" && response.data) { 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<void> {
// Add our own message listener to the existing WebSocket // Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => { messageListener = (event: MessageEvent) => {
try { try {
const response: WebSocketMessage = JSON.parse(event.data); const response: WebSocketMessage<any> = JSON.parse(event.data);
handleWebSocketMessage(response); handleWebSocketMessage(response);
} catch (error) { } catch (error) {
console.error('Failed to parse WebSocket message:', error); console.error('Failed to parse WebSocket message:', error);