mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure CSS
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import type { Headers, UploadPublicKeyRequest, BackupBlob } from "../types";
|
||||
import { generateX25519KeyPair } from "../../utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "../../utils/crypto/backup";
|
||||
import { b64, ub64 } from "../../utils/utils";
|
||||
import { API_BASE_URL } from "../config";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {boolean} json - Whether to include JSON content type header
|
||||
* @returns {Headers} Headers object with authentication and content type
|
||||
*/
|
||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.publicKey) return null;
|
||||
return ub64(data.publicKey);
|
||||
}
|
||||
|
||||
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const response: BackupBlob = await res.json();
|
||||
return response.blob;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveKeys(
|
||||
publicKey: Uint8Array<ArrayBufferLike>,
|
||||
privateKey: Uint8Array<ArrayBufferLike>
|
||||
) {
|
||||
const encodedPublicKey = b64(publicKey);
|
||||
const encodedPrivateKey = b64(privateKey);
|
||||
|
||||
localStorage.setItem("publicKey", encodedPublicKey);
|
||||
localStorage.setItem("privateKey", encodedPrivateKey);
|
||||
}
|
||||
|
||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob(token);
|
||||
if (blobJson) {
|
||||
const blob = decodeBlob(blobJson);
|
||||
const bundle = await decryptBackupWithPassword(password, blob);
|
||||
currentPrivateKey = bundle.privateKey;
|
||||
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
||||
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
||||
const serverPub = await fetchPublicKey(token);
|
||||
if (serverPub) {
|
||||
currentPublicKey = serverPub;
|
||||
} else {
|
||||
// We don't have the corresponding public key from server; regenerate pair to resync
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(currentPublicKey, token);
|
||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||
}
|
||||
|
||||
saveKeys(currentPublicKey!, currentPrivateKey!);
|
||||
|
||||
return {
|
||||
publicKey: currentPublicKey!,
|
||||
privateKey: currentPrivateKey!
|
||||
};
|
||||
}
|
||||
|
||||
// First-time setup: generate keys and upload
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(currentPublicKey, token);
|
||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||
|
||||
saveKeys(pair.publicKey, pair.privateKey);
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
export function restoreKeys() {
|
||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { API_BASE_URL } from "../config";
|
||||
import { getAuthHeaders } from "./authApi";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "../../utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../../utils/crypto/symmetric";
|
||||
import { randomBytes } from "../../utils/crypto/kdf";
|
||||
import { getCurrentKeys } from "./authApi";
|
||||
import { request } from "../websocket";
|
||||
import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "../types";
|
||||
import { b64, ub64 } from "../../utils/utils";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
||||
|
||||
// Decrypt
|
||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
||||
return new TextDecoder().decode(msg);
|
||||
}
|
||||
|
||||
export async function fetchUsers(token: string): Promise<User[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
// Encrypt the message
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
};
|
||||
if (replyToId) payload.replyToId = replyToId;
|
||||
|
||||
await request({
|
||||
type: "dmSend",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
data: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
const names: string[] = [];
|
||||
function sliceBuffer(u8: Uint8Array): ArrayBuffer {
|
||||
return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength);
|
||||
}
|
||||
|
||||
for (const f of files) {
|
||||
// Encrypt file with same mk
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||
const serverName = f.name; // server uses provided name
|
||||
names.push(serverName);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
try {
|
||||
obj = JSON.parse(plaintextJson);
|
||||
} catch {
|
||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
||||
}
|
||||
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
} satisfies BaseDmEnvelope));
|
||||
|
||||
await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, false),
|
||||
body: form
|
||||
});
|
||||
}
|
||||
|
||||
export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
const mk = randomBytes(32);
|
||||
const wkSalt = randomBytes(16);
|
||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getAuthHeaders } from "./authApi";
|
||||
import { API_BASE_URL } from "../config";
|
||||
import type { UserProfile } from "../types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
nickname?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
nickname: data.username,
|
||||
description: data.bio
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile picture to the server
|
||||
*/
|
||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile information
|
||||
*/
|
||||
export async function updateProfile(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
// Map frontend fields to backend fields
|
||||
const backendData = {
|
||||
nickname: data.nickname,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(backendData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user bio
|
||||
*/
|
||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by username
|
||||
*/
|
||||
export async function fetchUserProfile(token: string, username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @fileoverview Application configuration constants
|
||||
* @description Contains all configuration values used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base domain name for all requests in production
|
||||
* @constant
|
||||
*/
|
||||
export const BASE_DOMAIN = import.meta.env.VITE_API_BASE_URL ?? "fromchat.ru";
|
||||
|
||||
/**
|
||||
* Base API endpoint for all backend requests
|
||||
* @constant
|
||||
*/
|
||||
export const API_BASE_URL = `${location.host ? "" : `https://${BASE_DOMAIN}`}/api`;
|
||||
|
||||
/**
|
||||
* Full API URL including hostname and port for WebSocket connections
|
||||
* @constant
|
||||
*/
|
||||
export const API_WS_BASE_URL = `${location.host || BASE_DOMAIN}/api`;
|
||||
|
||||
/**
|
||||
* Application name displayed in UI and document title
|
||||
* @constant
|
||||
*/
|
||||
export const PRODUCT_NAME = "FromChat";
|
||||
|
||||
export const MINIMUM_WIDTH = 800;
|
||||
@@ -0,0 +1,41 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
#electron-title-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.electron {
|
||||
#electron-title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
background-color: $color-dark-surface-container;
|
||||
width: 100%;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
transition: background-color 0.5s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.color-surface {
|
||||
background-color: $color-dark-surface;
|
||||
}
|
||||
}
|
||||
|
||||
#window-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#main-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&.platform-darwin .macos-padding {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @fileoverview Electron-specific code
|
||||
* @description This module initializes Electron-specific functionality.
|
||||
* @author denis0001-dev
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import "./electron.scss";
|
||||
|
||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
|
||||
|
||||
if (isElectron) {
|
||||
console.log("Running in Electron");
|
||||
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
|
||||
} else {
|
||||
console.log("Running in normal browser");
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Application initialization logic
|
||||
* @description Handles initial application setup and state
|
||||
* @author FromChat Team
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { PRODUCT_NAME } from "./config";
|
||||
import { enableMapSet } from "immer";
|
||||
|
||||
document.title = PRODUCT_NAME;
|
||||
enableMapSet();
|
||||
@@ -0,0 +1,269 @@
|
||||
import { API_BASE_URL } from "../config";
|
||||
import { isElectron } from "../electron/electron";
|
||||
import { websocket } from "../websocket";
|
||||
import type { NewMessageWebSocketMessage, WebSocketMessage } from "../types";
|
||||
import serviceWorker from "./service-worker?worker&url";
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let isInitialized = false;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let subscription: PushSubscription | null = null;
|
||||
let isElectronReceiverRunning = false;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
// Helper functions
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
async function subscribeToWebPush(): Promise<PushSubscription | null> {
|
||||
if (!registration) {
|
||||
throw new Error("Service Worker not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
|
||||
).slice().buffer
|
||||
});
|
||||
|
||||
console.log("Push subscription successful");
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error("Push subscription failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSubscriptionToServer(token: string): Promise<boolean> {
|
||||
if (!subscription) {
|
||||
throw new Error("No push subscription available");
|
||||
}
|
||||
|
||||
const subscriptionData: PushSubscriptionData = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
|
||||
auth: arrayBufferToBase64(subscription.getKey("auth")!)
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(subscriptionData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("Failed to send subscription to server:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showMessageNotification(message: any): Promise<void> {
|
||||
try {
|
||||
await showNotification({
|
||||
title: `New message from ${message.username}`,
|
||||
body: message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content,
|
||||
icon: message.profile_picture || "/logo.png",
|
||||
tag: `message_${message.id}`,
|
||||
data: {
|
||||
type: "public_message",
|
||||
message_id: message.id,
|
||||
sender_id: message.user_id,
|
||||
sender_username: message.username
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show message notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
|
||||
// Handle notifications for new messages
|
||||
if (response.type === "newMessage" && response.data) {
|
||||
const newResponse = response as NewMessageWebSocketMessage;
|
||||
await showMessageNotification(newResponse.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API functions
|
||||
export async function initialize(): Promise<boolean> {
|
||||
if (isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isElectron) {
|
||||
// For Electron, we just need to request permission
|
||||
const permission = await window.electronInterface.notifications.requestPermission();
|
||||
isInitialized = permission === "granted";
|
||||
return isInitialized;
|
||||
} else {
|
||||
// For web browsers, initialize service worker and push manager
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
console.log("Push messaging is not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
|
||||
console.log("Service Worker registered successfully");
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
await subscribeToWebPush();
|
||||
isInitialized = true;
|
||||
}
|
||||
return isInitialized;
|
||||
} catch (error) {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize notification service:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribe(token: string): Promise<boolean> {
|
||||
if (!isInitialized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need server-side subscription
|
||||
return true;
|
||||
}
|
||||
|
||||
return await sendSubscriptionToServer(token);
|
||||
}
|
||||
|
||||
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
try {
|
||||
return await window.electronInterface.notifications.show({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon,
|
||||
tag: payload.tag
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show Electron notification:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For web browsers, notifications are handled by the service worker
|
||||
// when push messages are received from the server
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function unsubscribe(): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need to unsubscribe from server
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to unsubscribe:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupported(): boolean {
|
||||
if (isElectron) {
|
||||
return true; // Electron always supports notifications
|
||||
}
|
||||
return "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
// Electron-specific functions
|
||||
export async function startElectronReceiver(): Promise<void> {
|
||||
if (!isElectron || isElectronReceiverRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = true;
|
||||
|
||||
// Add our own message listener to the existing WebSocket
|
||||
messageListener = (event: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(event.data);
|
||||
handleWebSocketMessage(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener('message', messageListener);
|
||||
}
|
||||
|
||||
export function stopElectronReceiver(): void {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = false;
|
||||
|
||||
// Remove our message listener
|
||||
if (messageListener) {
|
||||
websocket.removeEventListener('message', messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface NotificationAction {
|
||||
action: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface NotificationOptions {
|
||||
body: string;
|
||||
icon: string;
|
||||
badge: string;
|
||||
image?: string;
|
||||
tag: string;
|
||||
data?: any;
|
||||
actions: NotificationAction[];
|
||||
requireInteraction: boolean;
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
// Service Worker for Push Notifications
|
||||
self.addEventListener("push", function(event: ExtendableEvent) {
|
||||
const pushEvent = event as PushEvent;
|
||||
if (pushEvent.data) {
|
||||
const data: NotificationPayload = pushEvent.data.json();
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: data.body,
|
||||
icon: data.icon || "/logo.png",
|
||||
badge: "/logo.png",
|
||||
image: data.image,
|
||||
tag: data.tag || "message",
|
||||
data: data.data,
|
||||
actions: [
|
||||
{
|
||||
action: "open",
|
||||
title: "Open Chat"
|
||||
},
|
||||
{
|
||||
action: "close",
|
||||
title: "Close"
|
||||
}
|
||||
],
|
||||
requireInteraction: true,
|
||||
silent: false
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", function(event: ExtendableEvent) {
|
||||
const notificationEvent = event as NotificationEvent;
|
||||
notificationEvent.notification.close();
|
||||
|
||||
if (notificationEvent.action === "open" || !notificationEvent.action) {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window" }).then(function(clientList: readonly WindowClient[]) {
|
||||
// If there's already a window open, focus it
|
||||
for (let i = 0; i < clientList.length; i++) {
|
||||
const client = clientList[i];
|
||||
if (client.url === self.location.origin && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise, open a new window
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(self.location.origin);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclose", function(_event: ExtendableEvent) {
|
||||
// Handle notification close if needed
|
||||
});
|
||||
Vendored
+431
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* @fileoverview Global TypeScript type definitions
|
||||
* @description Contains all type definitions used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* HTTP headers object type
|
||||
* @typedef {Object.<string, string>} Headers
|
||||
*/
|
||||
export type Headers = {[x: string]: string}
|
||||
|
||||
/**
|
||||
* API error response structure
|
||||
* @interface ErrorResponse
|
||||
* @property {string} message - Error message from the server
|
||||
*/
|
||||
export interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D coordinate structure
|
||||
* @interface Size2D
|
||||
* @property {number} x - X coordinate
|
||||
* @property {number} y - Y coordinate
|
||||
*/
|
||||
export interface Size2D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect extends Size2D {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// App types
|
||||
|
||||
/**
|
||||
* Chat message structure
|
||||
* @interface Message
|
||||
* @property {number} id - Unique message identifier
|
||||
* @property {string} username - Username of the message sender
|
||||
* @property {string} content - Message content
|
||||
* @property {boolean} is_read - Whether the message has been read
|
||||
* @property {boolean} is_edited - Whether the message has been edited
|
||||
* @property {string} timestamp - ISO timestamp of the message
|
||||
* @property {string} [profile_picture] - URL to sender's profile picture
|
||||
* @property {Message} [reply_to] - The message this is replying to
|
||||
*/
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
is_edited: boolean;
|
||||
timestamp: string;
|
||||
profile_picture?: string;
|
||||
reply_to?: Message;
|
||||
files?: Attachment[];
|
||||
reactions?: Reaction[];
|
||||
|
||||
runtimeData?: {
|
||||
dmEnvelope?: DmEnvelope;
|
||||
sendingState?: {
|
||||
status: 'sending' | 'sent' | 'failed';
|
||||
tempId?: string; // Temporary ID for tracking until server confirms
|
||||
retryData?: {
|
||||
content: string;
|
||||
replyToId?: number;
|
||||
files?: File[];
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of messages
|
||||
* @interface Messages
|
||||
* @property {Message[]} messages - Array of message objects
|
||||
*/
|
||||
export interface Messages {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* User information structure
|
||||
* @interface User
|
||||
* @property {number} id - Unique user identifier
|
||||
* @property {string} created_at - ISO timestamp of account creation
|
||||
* @property {string} last_seen - ISO timestamp of last activity
|
||||
* @property {boolean} online - Whether the user is currently online
|
||||
* @property {string} username - Username
|
||||
* @property {string} [bio] - User biography
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
online: boolean;
|
||||
username: string;
|
||||
admin?: boolean;
|
||||
bio?: string;
|
||||
profile_picture: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile response structure
|
||||
* @interface UserProfile
|
||||
* @property {number} id - Unique user identifier
|
||||
* @property {string} username - Username
|
||||
* @property {string} [profile_picture] - URL to user's profile picture
|
||||
* @property {string} [bio] - User biography
|
||||
* @property {boolean} online - Whether the user is currently online
|
||||
* @property {string} last_seen - ISO timestamp of last activity
|
||||
* @property {string} created_at - ISO timestamp of account creation
|
||||
*/
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
profile_picture?: string;
|
||||
bio?: string;
|
||||
online: boolean;
|
||||
last_seen: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ----------
|
||||
// API models
|
||||
// ----------
|
||||
|
||||
// Requests
|
||||
|
||||
/**
|
||||
* Login request structure
|
||||
* @interface LoginRequest
|
||||
* @property {string} username - Username for authentication
|
||||
* @property {string} password - Password for authentication
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration request structure
|
||||
* @interface RegisterRequest
|
||||
* @property {string} username - Desired username
|
||||
* @property {string} password - Desired password
|
||||
* @property {string} confirm_password - Password confirmation
|
||||
*/
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
export interface UploadPublicKeyRequest {
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export interface SendDMRequest {
|
||||
recipientId: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
// Responses
|
||||
|
||||
/**
|
||||
* Login response structure
|
||||
* @interface LoginResponse
|
||||
* @property {User} user - User information
|
||||
* @property {string} token - JWT authentication token
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface BackupBlob {
|
||||
blob: string;
|
||||
}
|
||||
|
||||
export interface BaseDmEnvelope {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedMk: string;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export interface DmEnvelope extends BaseDmEnvelope {
|
||||
id: number;
|
||||
senderId: number;
|
||||
files?: DmFile[];
|
||||
timestamp: string;
|
||||
reactions?: Reaction[];
|
||||
}
|
||||
|
||||
export interface DmFile {
|
||||
name: string;
|
||||
id: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface DmEditedPayload {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface DmDeletedPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number
|
||||
}
|
||||
|
||||
export interface FetchDMResponse {
|
||||
messages: DmEnvelope[]
|
||||
}
|
||||
|
||||
export interface DmEncryptedJSON {
|
||||
type: "text",
|
||||
data: {
|
||||
content: string;
|
||||
reply_to_id?: number;
|
||||
files?: Attachment[];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------
|
||||
// WebSocket types
|
||||
// ---------------
|
||||
|
||||
/**
|
||||
* WebSocket message structure
|
||||
* @interface WebSocketMessage
|
||||
* @property {string} type - Message type identifier
|
||||
* @property {WebSocketCredentials} [credentials] - Authentication credentials
|
||||
* @property {any} [data] - Message payload data
|
||||
* @property {WebSocketError} [error] - Error information if applicable
|
||||
*/
|
||||
export interface WebSocketMessage<T> {
|
||||
type: string;
|
||||
credentials?: WebSocketCredentials;
|
||||
data?: T;
|
||||
error?: WebSocketError;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket error structure
|
||||
* @interface WebSocketError
|
||||
* @property {number} code - Error code
|
||||
* @property {string} detail - Error detail message
|
||||
*/
|
||||
export interface WebSocketError {
|
||||
code: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket authentication credentials
|
||||
* @interface WebSocketCredentials
|
||||
* @property {string} scheme - Authentication scheme (e.g., "Bearer")
|
||||
* @property {string} credentials - Authentication token or credentials
|
||||
*/
|
||||
export interface WebSocketCredentials {
|
||||
scheme: string;
|
||||
credentials: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
path: string;
|
||||
encrypted: boolean;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddReactionRequest extends WebSocketMessage {
|
||||
type: "addReaction",
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
message_id: number;
|
||||
emoji: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddDmReactionRequest extends WebSocketMessage {
|
||||
type: "addDmReaction",
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
dm_envelope_id: number;
|
||||
emoji: string;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "reactionUpdate",
|
||||
data: {
|
||||
message_id: number;
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
user_id: number;
|
||||
username: string;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmReactionUpdate",
|
||||
data: {
|
||||
dm_envelope_id: number;
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
user_id: number;
|
||||
username: string;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
}
|
||||
|
||||
// Shared types
|
||||
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage
|
||||
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage
|
||||
|
||||
// -----------
|
||||
// Encrypted message JSON (plaintext structure before encryption)
|
||||
// -----------
|
||||
|
||||
export type ChatMessageKind = "text"; // Extendable for future kinds
|
||||
|
||||
export interface EncryptedTextMessageData {
|
||||
content: string;
|
||||
files?: Attachment[];
|
||||
reply_to_id?: number | null;
|
||||
}
|
||||
|
||||
export interface EncryptedMessageJson {
|
||||
type: ChatMessageKind;
|
||||
data: EncryptedTextMessageData;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// React types
|
||||
// -----------
|
||||
export interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @fileoverview WebSocket connection management for real-time chat
|
||||
* @description Handles WebSocket connections, message processing, and auto-reconnection
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { API_WS_BASE_URL } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { delay } from "../utils/utils";
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection to the chat server
|
||||
* @returns {WebSocket} New WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function create(): WebSocket {
|
||||
let prefix = "ws://";
|
||||
if (location.protocol.includes("https")) {
|
||||
prefix = "wss://";
|
||||
}
|
||||
|
||||
return new WebSocket(`${prefix}${API_WS_BASE_URL}/chat/ws`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global WebSocket instance
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
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<any>) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Set the global WebSocket message handler
|
||||
* @param handler - Function to handle WebSocket messages
|
||||
*/
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
|
||||
globalMessageHandler = handler;
|
||||
}
|
||||
|
||||
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
console.log("WebSocket request:", payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
function requestInner() {
|
||||
let listener: ((e: MessageEvent) => void) | null = null;
|
||||
listener = (e) => {
|
||||
resolve(JSON.parse(e.data));
|
||||
websocket.removeEventListener("message", listener!);
|
||||
}
|
||||
websocket.addEventListener("message", listener);
|
||||
websocket.send(JSON.stringify(payload))
|
||||
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
}
|
||||
|
||||
if (websocket.readyState == 0) {
|
||||
websocket.addEventListener("open", requestInner);
|
||||
setTimeout(() => reject("Request timed out"), 10000);
|
||||
} else {
|
||||
requestInner();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will wait 3 seconds and them attempts to reconnect the WebSocket.
|
||||
* If it fails, tries again in an endless loop until the connection is established
|
||||
* again.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async function onError() {
|
||||
console.warn("WebSocket disconnected, retrying in 3 seconds...");
|
||||
await delay(3000);
|
||||
websocket = create();
|
||||
|
||||
let listener: () => void | null;
|
||||
listener = () => {
|
||||
console.log("WebSocket successfully reconnected!");
|
||||
websocket.removeEventListener("open", listener);
|
||||
}
|
||||
|
||||
websocket.addEventListener("open", listener);
|
||||
websocket.addEventListener("error", onError);
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Initialization
|
||||
// --------------
|
||||
|
||||
websocket.addEventListener("message", (e) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(e.data);
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
globalMessageHandler(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
});
|
||||
websocket.addEventListener("error", onError);
|
||||
Reference in New Issue
Block a user