mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-24 12:05:05 +03:00
Backend moved to a separate repository
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
import { avatarGradientFromUserId } from "@/core/avatarGradient";
|
||||
import styles from "@/pages/chat/css/deleted-user-avatar.module.scss";
|
||||
|
||||
interface DeletedUserAvatarProps {
|
||||
userId: number;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function DeletedUserAvatar({ userId, className, iconClassName }: DeletedUserAvatarProps) {
|
||||
return (
|
||||
<div
|
||||
className={className ?? styles.deletedUserAvatar}
|
||||
style={{ background: avatarGradientFromUserId(userId) }}
|
||||
>
|
||||
<MaterialIcon
|
||||
name="account_circle_off--outlined"
|
||||
className={iconClassName ?? styles.deletedUserAvatarIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./index";
|
||||
|
||||
export interface DeviceInfo {
|
||||
session_id: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
os_name?: string;
|
||||
os_version?: string;
|
||||
browser_name?: string;
|
||||
browser_version?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
created_at?: string;
|
||||
last_seen?: string;
|
||||
revoked?: boolean;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export async function listDevices(token: string): Promise<DeviceInfo[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to fetch devices");
|
||||
const data = await res.json();
|
||||
return data.devices as DeviceInfo[];
|
||||
}
|
||||
|
||||
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to revoke device");
|
||||
}
|
||||
|
||||
export async function logoutAllOtherDevices(token: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to logout all devices");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
||||
import type { Headers } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {string | null} token - Authentication token
|
||||
* @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;
|
||||
}
|
||||
|
||||
export interface CheckAuthResponse {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user is authenticated
|
||||
*/
|
||||
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to check auth");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in a user with username and password
|
||||
*/
|
||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||
throw new Error(error.detail || "Login failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new user
|
||||
*/
|
||||
export async function register(request: RegisterRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
||||
throw new Error(error.detail || "Registration failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the current user
|
||||
*/
|
||||
export async function logout(token: string): Promise<LogoutResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/logout`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to logout");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a client-side authentication secret so the raw password never leaves the client.
|
||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
||||
*/
|
||||
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
||||
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
||||
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
||||
return b64(derived);
|
||||
}
|
||||
|
||||
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(pair.publicKey, 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(pair.publicKey, 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")!);
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's password
|
||||
*/
|
||||
export async function changePassword(
|
||||
token: string,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
logoutAllExceptCurrent: boolean
|
||||
): Promise<void> {
|
||||
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
||||
const newDerived = await deriveAuthSecret(username, newPassword);
|
||||
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
currentPasswordDerived: currentDerived,
|
||||
newPasswordDerived: newDerived,
|
||||
logoutAllExceptCurrent
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to change password");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current user's account
|
||||
*/
|
||||
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
||||
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
||||
throw new Error(error.detail || "Failed to delete account");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { getAuthHeaders } from ".";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: 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, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
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 = {
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(token, true),
|
||||
'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, true),
|
||||
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, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by user ID
|
||||
*/
|
||||
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles verification status for a user (owner only)
|
||||
*/
|
||||
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Suspends a user account (admin only)
|
||||
*/
|
||||
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error suspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuspends a user account (admin only)
|
||||
*/
|
||||
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error unsuspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (admin only)
|
||||
*/
|
||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import { getCurrentKeys } from "../user/auth";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "../crypto/identity";
|
||||
import { fetchUsers, searchUsers } from "../user/search";
|
||||
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import tweetnacl from "tweetnacl";
|
||||
|
||||
/**
|
||||
* Unwrap a MEK using the appropriate wrapping key for the current user
|
||||
*/
|
||||
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Determine context based on whether we're sender or recipient
|
||||
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
|
||||
const isRecipient = envelope.recipientId === currentUserId;
|
||||
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
|
||||
|
||||
// Derive wrapping key from our public key
|
||||
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
|
||||
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
|
||||
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
|
||||
|
||||
// Unwrap the MEK using AES-256-GCM
|
||||
const wrappedMekBytes = ub64(wrappedMekB64);
|
||||
const mekNonce = wrappedMekBytes.slice(0, 12);
|
||||
const mekCiphertext = wrappedMekBytes.slice(12);
|
||||
|
||||
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
|
||||
}
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
|
||||
try {
|
||||
// Use the wrapped MEK provided for this user
|
||||
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||
|
||||
// Unwrap the MEK using shared logic
|
||||
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||
|
||||
// Decrypt the message using the unwrapped MEK
|
||||
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||
const messageKey = await importAesGcmKey(mek);
|
||||
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||
|
||||
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||
const result = new TextDecoder().decode(plaintext);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||
console.error("Error details:", {
|
||||
envelope: envelope,
|
||||
userId: userId,
|
||||
localStorageUserId: localStorage.getItem('userId')
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transport public key from the server
|
||||
*/
|
||||
async function getTransportPublicKey(): Promise<string> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.public_key_b64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt message using transport key (client-side only)
|
||||
*/
|
||||
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
return {
|
||||
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
|
||||
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
|
||||
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption only
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key (from current keys)
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send to server (server will handle envelope encryption)
|
||||
const bodyPayload: any = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId
|
||||
};
|
||||
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(bodyPayload)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function sendWithFiles(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
files: File[],
|
||||
plaintext: string,
|
||||
authToken: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error("No files provided");
|
||||
}
|
||||
|
||||
// Get transport key for encryption (shared across message + files)
|
||||
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!transportKeyResponse.ok) {
|
||||
throw new Error("Failed to get transport key");
|
||||
}
|
||||
const transportKeyData = await transportKeyResponse.json();
|
||||
const transportPublicKeyB64 = transportKeyData.public_key_b64;
|
||||
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||
|
||||
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
|
||||
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const messageCiphertext = tweetnacl.box(
|
||||
messagePlaintextBytes,
|
||||
messageNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
|
||||
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Base64 encode helper (chunked)
|
||||
const uint8ToB64 = (uint8: Uint8Array): string => {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
|
||||
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
|
||||
for (const file of files) {
|
||||
const fileData = await file.arrayBuffer();
|
||||
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const transportEncrypted = tweetnacl.box(
|
||||
new Uint8Array(fileData),
|
||||
transportNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||
transportEncryptedWithNonce.set(transportNonce);
|
||||
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
|
||||
|
||||
transport_files.push({
|
||||
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
|
||||
filename: file.name,
|
||||
file_size: file.size
|
||||
});
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId,
|
||||
transport_files
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a DM as read
|
||||
*/
|
||||
export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmMarkRead",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMessage(
|
||||
messageId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintext: string,
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Get transport key for initial encryption
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption (same as sending)
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
|
||||
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64
|
||||
})
|
||||
});
|
||||
|
||||
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
@@ -0,0 +1,99 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
|
||||
/**
|
||||
* Fetches public chat messages
|
||||
*/
|
||||
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data: Messages & { has_more?: boolean } = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message via WebSocket
|
||||
*/
|
||||
export async function send(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
||||
await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message with files via HTTP
|
||||
*/
|
||||
export async function sendWithFiles(
|
||||
content: string,
|
||||
replyToId: number | null,
|
||||
files: File[],
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
const form = new FormData();
|
||||
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 globalThis.fetch(`${API_BASE_URL}/send_message`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || "Failed to send message with files");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a public chat message
|
||||
*/
|
||||
export async function edit(messageId: number, newContent: string, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ content: newContent })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to edit message");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a public chat message
|
||||
*/
|
||||
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(authToken, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete message");
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a message as read
|
||||
*/
|
||||
export async function markRead(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ message_id: messageId })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to mark message as read");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
*/
|
||||
export 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's public key
|
||||
*/
|
||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches another user's public key by user ID
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's backup blob
|
||||
*/
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { BackupBlob } from "@/core/types";
|
||||
import api from "@/core/api";
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = api.user.auth.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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's backup blob
|
||||
*/
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { UploadPublicKeyRequest } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
*/
|
||||
export 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's public key
|
||||
*/
|
||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches another user's public key by user ID
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Placeholder for Signal Protocol pre-key management
|
||||
// Will be implemented when Signal Protocol is added
|
||||
|
||||
export async function upload(_bundle: unknown, _token: string): Promise<void> {
|
||||
// TODO: Implement Signal Protocol pre-key upload
|
||||
throw new Error("Not implemented yet");
|
||||
}
|
||||
|
||||
export async function fetch(_userId: number, _token: string): Promise<unknown> {
|
||||
// TODO: Implement Signal Protocol pre-key fetch
|
||||
throw new Error("Not implemented yet");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
/**
|
||||
* Decrypt a DM envelope using client-side MEK unwrapping.
|
||||
* This delegates to the chats/dm module which has the updated implementation.
|
||||
*/
|
||||
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
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 || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
/**
|
||||
* Send DM via WebSocket using transport encryption.
|
||||
* This delegates to the HTTP endpoint which handles envelope encryption on server.
|
||||
*/
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Envelope Encryption (Private DMs with compliance support)
|
||||
// ============================================================================
|
||||
|
||||
interface TransportKey {
|
||||
key_id: string;
|
||||
public_key_b64: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface TransportEncryptedMessage {
|
||||
client_public_key_b64: string;
|
||||
nonce_b64: string;
|
||||
ciphertext_b64: string;
|
||||
}
|
||||
|
||||
let cachedTransportKey: TransportKey | null = null;
|
||||
|
||||
/**
|
||||
* Fetch current transport public key from messaging service.
|
||||
* Caches result with validation.
|
||||
*/
|
||||
export async function getTransportPublicKey(): Promise<TransportKey> {
|
||||
if (cachedTransportKey) {
|
||||
return cachedTransportKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data: TransportKey = await response.json();
|
||||
cachedTransportKey = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transport public key:", error);
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch transport public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message using the transport public key (X25519 + ChaCha20).
|
||||
*/
|
||||
function encryptMessageWithTransportKey(
|
||||
plaintext: string | Uint8Array,
|
||||
transportPublicKeyB64: string
|
||||
): { nonce_b64: string; ciphertext_b64: string; client_public_key_b64: string } {
|
||||
const tweetnacl = require("tweetnacl");
|
||||
|
||||
// Convert plaintext to bytes if string
|
||||
const plaintextBytes = typeof plaintext === "string" ? new TextEncoder().encode(plaintext) : plaintext;
|
||||
|
||||
// Generate ephemeral keypair for this message
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
|
||||
// Decode transport public key
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
// Perform ECDH (shared secret via tweetnacl's box)
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
// Encode to base64
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]));
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
|
||||
return { nonce_b64, ciphertext_b64, client_public_key_b64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with transport public key for sending to server.
|
||||
* Server will handle envelope encryption (MEK generation and wrapping).
|
||||
*/
|
||||
export async function encryptMessageForTransport(plaintext: string): Promise<TransportEncryptedMessage> {
|
||||
const transportKey = await getTransportPublicKey();
|
||||
return encryptMessageWithTransportKey(plaintext, transportKey.public_key_b64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted DM message using envelope encryption.
|
||||
* Client encrypts with transport key, server handles envelope encryption.
|
||||
*/
|
||||
export async function sendEncryptedDM(
|
||||
recipientId: number,
|
||||
plaintext: string,
|
||||
token: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Client-side transport encryption
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } =
|
||||
await encryptMessageForTransport(plaintext);
|
||||
|
||||
// Send to server
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(token, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
reply_to_id: replyToId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send encrypted DM:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encrypted conversation history with another user.
|
||||
*/
|
||||
export async function getEncryptedConversation(
|
||||
otherUserId: number,
|
||||
token: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const url = new URL(`${API_BASE_URL}/dm/conversation/${otherUserId}`);
|
||||
url.searchParams.append("limit", String(limit));
|
||||
url.searchParams.append("offset", String(offset));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch encrypted conversation with user ${otherUserId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an encrypted message.
|
||||
*/
|
||||
export async function deleteEncryptedDM(messageId: number, token: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete encrypted DM ${messageId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached keys (useful on logout).
|
||||
*/
|
||||
export function clearCachedKeys(): void {
|
||||
cachedTransportKey = null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
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 || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./user/auth";
|
||||
|
||||
export const normal = {
|
||||
/**
|
||||
* Gets the URL for a normal (unencrypted) file
|
||||
*/
|
||||
url(filename: string): string {
|
||||
return `${API_BASE_URL}/uploads/files/normal/${filename}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches a normal file (unencrypted)
|
||||
*/
|
||||
async fetch(filename: string, token: string): Promise<Blob> {
|
||||
const res = await fetch(this.url(filename), {
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch file");
|
||||
return await res.blob();
|
||||
}
|
||||
};
|
||||
|
||||
export const encrypted = {
|
||||
/**
|
||||
* Gets the URL for an encrypted file
|
||||
*/
|
||||
url(filename: string): string {
|
||||
return `${API_BASE_URL}/uploads/files/encrypted/${filename}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches an encrypted file
|
||||
*/
|
||||
async fetch(filename: string, token: string): Promise<Blob> {
|
||||
const res = await fetch(this.url(filename), {
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch encrypted file");
|
||||
return await res.blob();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as chatsGeneral from "./chats/general";
|
||||
import * as chatsDm from "./chats/dm";
|
||||
import * as userProfile from "./user/profile";
|
||||
import * as userAuth from "./user/auth";
|
||||
import * as userDevices from "./user/devices";
|
||||
import * as userSearch from "./user/search";
|
||||
import * as cryptoPrekeys from "./crypto/prekeys";
|
||||
import * as cryptoIdentity from "./crypto/identity";
|
||||
import * as cryptoBackup from "./crypto/backup";
|
||||
import * as moderationBlocklist from "./moderation/blocklist";
|
||||
import * as moderationUsers from "./moderation/users";
|
||||
import * as filesModule from "./files";
|
||||
import * as pushModule from "./push";
|
||||
|
||||
const api = {
|
||||
chats: {
|
||||
general: chatsGeneral,
|
||||
dm: chatsDm
|
||||
},
|
||||
user: {
|
||||
profile: userProfile,
|
||||
auth: userAuth,
|
||||
devices: userDevices,
|
||||
search: userSearch
|
||||
},
|
||||
crypto: {
|
||||
prekeys: cryptoPrekeys,
|
||||
identity: cryptoIdentity,
|
||||
backup: cryptoBackup
|
||||
},
|
||||
moderation: {
|
||||
blocklist: moderationBlocklist,
|
||||
users: moderationUsers
|
||||
},
|
||||
files: filesModule,
|
||||
push: pushModule
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
export const chats = api.chats;
|
||||
export const user = api.user;
|
||||
export const crypto = api.crypto;
|
||||
export const moderation = api.moderation;
|
||||
export const files = api.files;
|
||||
export const push = api.push;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
|
||||
class HttpError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
|
||||
constructor(message: string, status: number, detail: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches public chat messages
|
||||
*/
|
||||
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<Message[]> {
|
||||
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data: Messages = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message via WebSocket
|
||||
*/
|
||||
export async function sendMessage(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
||||
await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message with files via HTTP
|
||||
*/
|
||||
export async function sendMessageWithFiles(
|
||||
content: string,
|
||||
replyToId: number | null,
|
||||
files: File[],
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
const form = new FormData();
|
||||
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",
|
||||
headers: getAuthHeaders(authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errorDetail = "Failed to send message with files";
|
||||
try {
|
||||
const errorJson = await res.json();
|
||||
errorDetail = errorJson.detail || errorDetail;
|
||||
} catch {
|
||||
const errorText = await res.text();
|
||||
errorDetail = errorText || errorDetail;
|
||||
}
|
||||
throw new HttpError(errorDetail, res.status, errorDetail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a public chat message
|
||||
*/
|
||||
export async function editMessage(messageId: number, newContent: string, authToken: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ content: newContent })
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errorDetail = "Failed to edit message";
|
||||
try {
|
||||
const errorJson = await res.json();
|
||||
errorDetail = errorJson.detail || errorDetail;
|
||||
} catch {
|
||||
const errorText = await res.text();
|
||||
errorDetail = errorText || errorDetail;
|
||||
}
|
||||
throw new HttpError(errorDetail, res.status, errorDetail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a public chat message
|
||||
*/
|
||||
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(authToken, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete message");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
|
||||
export interface BlocklistResponse {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateRequest {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateResponse {
|
||||
added?: string[];
|
||||
removed?: string[];
|
||||
words: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current blocklist (admin only)
|
||||
*/
|
||||
export async function getBlocklist(token: string): Promise<BlocklistResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds words to the blocklist (admin only)
|
||||
*/
|
||||
export async function addToBlocklist(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add to blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes words from the blocklist (admin only)
|
||||
*/
|
||||
export async function removeFromBlocklist(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove from blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
|
||||
export interface BlocklistResponse {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateRequest {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateResponse {
|
||||
added?: string[];
|
||||
removed?: string[];
|
||||
words: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current blocklist (admin only)
|
||||
*/
|
||||
export async function get(token: string): Promise<BlocklistResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds words to the blocklist (admin only)
|
||||
*/
|
||||
export async function add(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add to blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes words from the blocklist (admin only)
|
||||
*/
|
||||
export async function remove(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove from blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
|
||||
/**
|
||||
* Toggles verification status for a user (owner only)
|
||||
*/
|
||||
export async function verify(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends a user account (admin only)
|
||||
*/
|
||||
export async function suspend(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error suspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuspends a user account (admin only)
|
||||
*/
|
||||
export async function unsuspend(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error unsuspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (admin only)
|
||||
*/
|
||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: 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,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
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 = {
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by user ID
|
||||
*/
|
||||
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles verification status for a user (owner only)
|
||||
*/
|
||||
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Suspends a user account (admin only)
|
||||
*/
|
||||
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error suspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuspends a user account (admin only)
|
||||
*/
|
||||
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error unsuspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (admin only)
|
||||
*/
|
||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./user/auth";
|
||||
|
||||
export interface PushSubscriptionRequest {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushSubscriptionResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const subscription = {
|
||||
/**
|
||||
* Subscribes the current user to push notifications
|
||||
*/
|
||||
async subscribe(
|
||||
subscription: PushSubscriptionRequest,
|
||||
token: string
|
||||
): Promise<PushSubscriptionResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" }));
|
||||
throw new Error(error.detail || "Failed to subscribe to push notifications");
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Unsubscribes the current user from push notifications
|
||||
*/
|
||||
async unsubscribe(token: string): Promise<PushSubscriptionResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" }));
|
||||
throw new Error(error.detail || "Failed to unsubscribe from push notifications");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
|
||||
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {string | null} token - Authentication token
|
||||
* @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;
|
||||
}
|
||||
|
||||
export interface CheckAuthResponse {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user is authenticated
|
||||
*/
|
||||
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to check auth");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in a user with username and password
|
||||
*/
|
||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||
throw new Error(error.detail || "Login failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new user
|
||||
*/
|
||||
export async function register(request: RegisterRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
||||
throw new Error(error.detail || "Registration failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the current user
|
||||
*/
|
||||
export async function logout(token: string): Promise<LogoutResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/logout`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to logout");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a client-side authentication secret so the raw password never leaves the client.
|
||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
||||
*/
|
||||
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
||||
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
||||
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
||||
return b64(derived);
|
||||
}
|
||||
|
||||
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(pair.publicKey, 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(pair.publicKey, token);
|
||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||
|
||||
saveKeys(pair.publicKey, pair.privateKey);
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the client already has a keypair (e.g. from localStorage) but the server has no public key row,
|
||||
* upload the public key. Covers failed uploads during login, DB resets, and legacy accounts.
|
||||
*/
|
||||
export async function syncPublicKeyToServerIfMissing(token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys?.publicKey?.length || !keys?.privateKey?.length) {
|
||||
return;
|
||||
}
|
||||
const serverPk = await fetchPublicKey(token);
|
||||
if (serverPk) {
|
||||
return;
|
||||
}
|
||||
await uploadPublicKey(keys.publicKey, token);
|
||||
}
|
||||
|
||||
export function restoreKeys() {
|
||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's password
|
||||
*/
|
||||
export async function changePassword(
|
||||
token: string,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
logoutAllExceptCurrent: boolean
|
||||
): Promise<void> {
|
||||
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
||||
const newDerived = await deriveAuthSecret(username, newPassword);
|
||||
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
currentPasswordDerived: currentDerived,
|
||||
newPasswordDerived: newDerived,
|
||||
logoutAllExceptCurrent
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to change password");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current user's account
|
||||
*/
|
||||
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
||||
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
||||
throw new Error(error.detail || "Failed to delete account");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
|
||||
export interface DeviceInfo {
|
||||
session_id: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
os_name?: string;
|
||||
os_version?: string;
|
||||
browser_name?: string;
|
||||
browser_version?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
created_at?: string;
|
||||
last_seen?: string;
|
||||
revoked?: boolean;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export async function list(token: string): Promise<DeviceInfo[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to fetch devices");
|
||||
const data = await res.json();
|
||||
return data.devices as DeviceInfo[];
|
||||
}
|
||||
|
||||
export async function revoke(token: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to revoke device");
|
||||
}
|
||||
|
||||
export async function revokeAll(token: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to logout all devices");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function get(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
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 uploadPicture(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 update(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
// Map frontend fields to backend fields
|
||||
const backendData = {
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(token, true),
|
||||
'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, true),
|
||||
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 fetchByUsername(token: string, username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by user ID
|
||||
*/
|
||||
export async function fetchById(token: string, userId: number): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import type { User } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Fetches a list of all users (excluding current user)
|
||||
*/
|
||||
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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for users by username query
|
||||
*/
|
||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a user by ID
|
||||
*/
|
||||
export async function get(userId: number, token: string): Promise<User | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/users/${userId}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { User } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Fetches a list of all users (excluding current user)
|
||||
*/
|
||||
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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for users by username query
|
||||
*/
|
||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Java [String.hashCode] for cross-platform parity with Android avatar gradients. */
|
||||
function javaStringHashCode(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function rgbFromHash(hash: number, offset: number): string {
|
||||
const r = Math.abs(hash % 256);
|
||||
const g = Math.abs(Math.floor(hash / 256) % 256);
|
||||
const b = Math.abs(Math.floor(hash / 65536) % 256);
|
||||
const clamp = (channel: number) => Math.min(255, Math.max(0, channel));
|
||||
return `rgb(${clamp(r + offset)}, ${clamp(g + offset)}, ${clamp(b + offset)})`;
|
||||
}
|
||||
|
||||
/** CSS linear-gradient matching [generateGradientFromName] on Android for a user id seed. */
|
||||
export function avatarGradientFromUserId(userId: number): string {
|
||||
const hash = javaStringHashCode(String(userId));
|
||||
return `linear-gradient(135deg, ${rgbFromHash(hash, 100)}, ${rgbFromHash(hash, 50)})`;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* E2EE Worker for WebRTC Insertable Streams
|
||||
* Encrypts/decrypts encoded audio and video frames using AES-GCM
|
||||
* Uses RTP timestamps for IVs to handle out-of-order and dropped frames
|
||||
*/
|
||||
|
||||
export interface FrameMetadata {
|
||||
contributingSources?: number[];
|
||||
mimeType?: string;
|
||||
payloadType?: number;
|
||||
rtpTimestamp: number;
|
||||
synchronizationSource: number;
|
||||
dependencies?: number[];
|
||||
frameId?: number;
|
||||
spatialIndex?: number;
|
||||
temporalIndex?: number;
|
||||
}
|
||||
|
||||
export interface EncodedFrame {
|
||||
data: Uint8Array | ArrayBuffer;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
getMetadata?: () => FrameMetadata;
|
||||
}
|
||||
|
||||
export interface WorkerOptions {
|
||||
key: CryptoKey;
|
||||
mode: 'encrypt' | 'decrypt';
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sequence number from encoded frame
|
||||
* For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available,
|
||||
* otherwise fall back to extracting from RTP header
|
||||
*/
|
||||
function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
|
||||
// Create IV using ONLY RTP metadata - this ensures sender and receiver use identical IVs
|
||||
// Frame data can differ between sender/receiver due to encoding differences
|
||||
const ivBuffer = new ArrayBuffer(12);
|
||||
const view = new DataView(ivBuffer);
|
||||
|
||||
if (encodedFrame.getMetadata) {
|
||||
try {
|
||||
const metadata = encodedFrame.getMetadata();
|
||||
if (metadata && typeof metadata.rtpTimestamp === 'number') {
|
||||
// Use ONLY RTP timestamp + sync source - these are identical on both sides
|
||||
view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes
|
||||
view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes
|
||||
view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV)
|
||||
|
||||
return ivBuffer;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get metadata:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use timestamp only (no random to avoid desync)
|
||||
view.setUint32(0, Date.now() & 0xFFFFFFFF, false);
|
||||
view.setUint32(4, 0, false);
|
||||
view.setUint32(8, 0, false);
|
||||
return ivBuffer;
|
||||
}
|
||||
|
||||
addEventListener("rtctransform", (event) => {
|
||||
const { transformer } = event;
|
||||
const { readable, writable } = transformer;
|
||||
const { key, mode } = transformer.options as WorkerOptions;
|
||||
|
||||
const isEncrypting = mode === 'encrypt';
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
||||
try {
|
||||
const data = new Uint8Array(encodedFrame.data);
|
||||
|
||||
// Increment frame counter
|
||||
frameCount++;
|
||||
|
||||
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
||||
const iv = makeIV(encodedFrame);
|
||||
|
||||
// Ensure IV is properly typed
|
||||
const ivArray = new Uint8Array(iv);
|
||||
const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray };
|
||||
|
||||
// COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility
|
||||
// This prevents most visual leakage while maintaining decodability
|
||||
let headerSize = 0;
|
||||
let payloadData: Uint8Array;
|
||||
|
||||
if (data.length > 20) {
|
||||
// For video frames, preserve first 8 bytes for better codec compatibility
|
||||
// This includes frame type, keyframe info, and basic header structure
|
||||
headerSize = Math.min(8, Math.floor(data.length / 10));
|
||||
payloadData = data.slice(headerSize);
|
||||
} else {
|
||||
// For small frames (likely audio), encrypt everything
|
||||
payloadData = data;
|
||||
}
|
||||
|
||||
// Encrypt the payload data
|
||||
const payloadBuffer = new ArrayBuffer(payloadData.byteLength);
|
||||
new Uint8Array(payloadBuffer).set(payloadData);
|
||||
|
||||
let encryptedPayload: ArrayBuffer;
|
||||
if (isEncrypting) {
|
||||
encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer);
|
||||
} else {
|
||||
try {
|
||||
encryptedPayload = await crypto.subtle.decrypt(params, key, payloadBuffer);
|
||||
} catch (error) {
|
||||
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, error);
|
||||
return; // Drop the frame
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct frame: minimal headers + encrypted payload
|
||||
const encryptedArray = new Uint8Array(encryptedPayload);
|
||||
const result = new Uint8Array(headerSize + encryptedArray.length);
|
||||
|
||||
if (headerSize > 0) {
|
||||
result.set(data.slice(0, headerSize), 0); // Copy minimal headers
|
||||
result.set(encryptedArray, headerSize); // Add encrypted payload
|
||||
} else {
|
||||
result.set(encryptedArray, 0);
|
||||
}
|
||||
|
||||
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
||||
encodedFrame.data = result.buffer;
|
||||
controller.enqueue(encodedFrame);
|
||||
} catch (e) {
|
||||
// FAIL SECURELY: Never send unencrypted frames
|
||||
const data = new Uint8Array(encodedFrame.data);
|
||||
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e);
|
||||
return; // Drop the frame completely
|
||||
}
|
||||
}
|
||||
|
||||
readable
|
||||
.pipeThrough(new TransformStream({ transform }))
|
||||
.pipeTo(writable);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import api from "@/core/api";
|
||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
||||
|
||||
export interface CallSessionKey {
|
||||
key: Uint8Array;
|
||||
hash: string; // For emoji display
|
||||
}
|
||||
|
||||
export interface CallKeyExchange {
|
||||
type: "call_key_exchange";
|
||||
sessionKeyHash: string;
|
||||
encryptedSessionKey: EncryptedCallMessage;
|
||||
}
|
||||
|
||||
export interface EncryptedCallMessage {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedSessionKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new call session key for end-to-end encryption
|
||||
* @returns Promise that resolves to a session key with its hash for display
|
||||
*/
|
||||
export async function generateCallSessionKey(): Promise<CallSessionKey> {
|
||||
// Generate session key material
|
||||
const sessionKeyMaterial = randomBytes(32);
|
||||
|
||||
// Generate hash for emoji display (first 4 bytes of SHA-256 hash)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer);
|
||||
const hash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
||||
|
||||
return {
|
||||
key: sessionKeyMaterial,
|
||||
hash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a session key by generating a completely new key
|
||||
* This provides forward secrecy for long-running calls
|
||||
*/
|
||||
export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
||||
// Generate new session key material (completely independent of current key)
|
||||
const newSessionKeyMaterial = randomBytes(32);
|
||||
|
||||
// Generate new hash for emoji display
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer);
|
||||
const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
||||
|
||||
return {
|
||||
key: newSessionKeyMaterial,
|
||||
hash: newHash
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Derive session key from ECDH shared secret and session key hash
|
||||
* This creates a deterministic but cryptographically secure key
|
||||
*/
|
||||
export async function deriveCallSessionKeyFromSharedSecret(
|
||||
sharedSecret: Uint8Array,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
// Use HKDF to derive the session key from the shared secret
|
||||
// Include the session key hash and role to ensure uniqueness
|
||||
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
|
||||
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
|
||||
|
||||
// Import the shared secret as a raw key for HKDF
|
||||
const sharedKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedSecret.buffer as ArrayBuffer,
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
// Derive the session key using HKDF
|
||||
const sessionKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: salt,
|
||||
info: info
|
||||
},
|
||||
sharedKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, // Make the key extractable so we can export it
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export the raw key material
|
||||
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
|
||||
|
||||
return {
|
||||
key: new Uint8Array(sessionKeyMaterial),
|
||||
hash: sessionKeyHash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a call signaling message with the session key
|
||||
*/
|
||||
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
|
||||
|
||||
return {
|
||||
iv: b64(encrypted.iv),
|
||||
ciphertext: b64(encrypted.ciphertext),
|
||||
salt: "", // Not used for message encryption, only for key wrapping
|
||||
iv2: "",
|
||||
wrappedSessionKey: ""
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a call signaling message
|
||||
*/
|
||||
export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise<Record<string, unknown>> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext));
|
||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 4 emojis representing the call session key
|
||||
*/
|
||||
export function generateCallEmojis(sessionKeyHash: string): string[] {
|
||||
// Convert hash to numbers and map to emoji ranges
|
||||
const hashBytes = new Uint8Array(ub64(sessionKeyHash));
|
||||
const emojis: string[] = [];
|
||||
|
||||
// Different emoji categories for variety
|
||||
const emojiSets = [
|
||||
["🎵", "🎶", "🎤", "🎧", "🎼", "🎹", "🥁", "🎺", "🎸", "🎻"], // Music
|
||||
["🔥", "💫", "⭐", "✨", "🌟", "💥", "⚡", "🌈", "🎆", "🎇"], // Energy
|
||||
["🚀", "🛸", "🛰️", "🌌", "🔭", "⚙️", "🔧", "⚡", "💡", "🔬"], // Tech/Space
|
||||
["🎭", "🎪", "🎨", "🎬", "📷", "🎥", "📺", "🎮", "🕹️", "🎯"] // Entertainment
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const set = emojiSets[i % emojiSets.length];
|
||||
const index = hashBytes[i % hashBytes.length] % set.length;
|
||||
emojis.push(set[index]);
|
||||
}
|
||||
|
||||
return emojis;
|
||||
}
|
||||
|
||||
// HKDF info for CALL key wrapping (distinct from DM's info)
|
||||
const CALL_INFO = new Uint8Array([2]);
|
||||
|
||||
/**
|
||||
* Wraps a call session key for a specific recipient using ECDH key exchange
|
||||
* @param recipientPublicKeyB64 - The recipient's public key in base64 format
|
||||
* @param sessionKey - The session key to wrap
|
||||
* @returns Promise that resolves to the wrapped session key payload
|
||||
*/
|
||||
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const wrap = await aesGcmEncrypt(wk, sessionKey);
|
||||
return {
|
||||
salt: b64(salt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrapped: b64(wrap.ciphertext)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shared secret and derive session key for the receiver
|
||||
*/
|
||||
export async function createSharedSecretAndDeriveSessionKey(
|
||||
senderPublicKeyB64: string,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Create shared secret using ECDH
|
||||
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
|
||||
// Derive the session key from the shared secret
|
||||
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a call session key received from a sender using ECDH key exchange
|
||||
* @param senderPublicKeyB64 - The sender's public key in base64 format
|
||||
* @param payload - The wrapped session key payload
|
||||
* @returns Promise that resolves to the unwrapped session key
|
||||
*/
|
||||
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const salt = ub64(payload.salt);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped));
|
||||
return new Uint8Array(sessionKey);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types";
|
||||
import * as WebRTC from "./webrtc";
|
||||
|
||||
export interface CallState {
|
||||
receiveCall: (userId: number, username: string) => void;
|
||||
endCall: () => void;
|
||||
setCallSessionKeyHash: (sessionKeyHash: string) => void;
|
||||
setRemoteVideoEnabled: (enabled: boolean) => void;
|
||||
setRemoteScreenSharing: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming WebSocket messages related to call signaling
|
||||
*/
|
||||
export class CallSignalingHandler {
|
||||
private getState: () => CallState;
|
||||
|
||||
constructor(getState: () => CallState) {
|
||||
this.getState = getState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes incoming call signaling messages to appropriate handlers
|
||||
*/
|
||||
handleWebSocketMessage(message: CallSignalingMessage) {
|
||||
const { data } = message;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "call_invite":
|
||||
this.handleCallInvite(message, data as CallInviteMessageData);
|
||||
break;
|
||||
case "call_accept":
|
||||
this.handleCallAccept(data as CallAcceptData);
|
||||
break;
|
||||
case "call_reject":
|
||||
this.handleCallReject(data as CallRejectData);
|
||||
break;
|
||||
case "call_offer":
|
||||
this.handleCallOffer(message, data as CallOfferData);
|
||||
break;
|
||||
case "call_answer":
|
||||
this.handleCallAnswer(message, data as CallAnswerData);
|
||||
break;
|
||||
case "call_ice_candidate":
|
||||
this.handleIceCandidate(message, data as CallIceCandidateData);
|
||||
break;
|
||||
case "call_end":
|
||||
this.handleCallEnd(data as CallEndData);
|
||||
break;
|
||||
case "call_session_key":
|
||||
this.handleCallSessionKey(message);
|
||||
break;
|
||||
case "call_video_toggle":
|
||||
this.handleVideoToggle(message, data as CallVideoToggleData);
|
||||
break;
|
||||
case "call_screen_share_toggle":
|
||||
this.handleScreenShareToggle(message, data as CallScreenShareToggleData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming call invitation
|
||||
*/
|
||||
private async handleCallInvite(message: CallSignalingMessage, data: CallInviteMessageData) {
|
||||
const { fromUsername } = data;
|
||||
const fromUserId = message.fromUserId;
|
||||
const state = this.getState();
|
||||
|
||||
// First, create the peer connection in WebRTC service
|
||||
await WebRTC.handleIncomingCall(fromUserId, fromUsername);
|
||||
|
||||
// Then show incoming call UI
|
||||
state.receiveCall(fromUserId, fromUsername);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles call acceptance from remote peer
|
||||
*/
|
||||
private async handleCallAccept(data: CallAcceptData) {
|
||||
const { fromUserId } = data;
|
||||
// Initiator should create and send offer now
|
||||
try {
|
||||
await WebRTC.onRemoteAccepted(fromUserId);
|
||||
} catch (error) {
|
||||
console.error("Failed to proceed after accept:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles call rejection from remote peer
|
||||
*/
|
||||
private handleCallReject(data: CallRejectData) {
|
||||
const state = this.getState();
|
||||
const { fromUserId } = data;
|
||||
|
||||
// Clean up WebRTC connection first
|
||||
if (fromUserId) {
|
||||
WebRTC.cleanupCall(fromUserId);
|
||||
}
|
||||
|
||||
// End the call
|
||||
state.endCall();
|
||||
}
|
||||
|
||||
private async handleCallOffer(message: CallSignalingMessage, data: CallOfferData) {
|
||||
await WebRTC.handleCallOffer(message.fromUserId, data);
|
||||
}
|
||||
|
||||
private async handleCallAnswer(message: CallSignalingMessage, data: CallAnswerData) {
|
||||
await WebRTC.handleCallAnswer(message.fromUserId, data);
|
||||
}
|
||||
|
||||
private async handleIceCandidate(message: CallSignalingMessage, data: CallIceCandidateData) {
|
||||
await WebRTC.handleIceCandidate(message.fromUserId, data);
|
||||
}
|
||||
|
||||
private handleCallEnd(data: CallEndData) {
|
||||
const state = this.getState();
|
||||
const { fromUserId } = data;
|
||||
|
||||
// Clean up WebRTC connection first
|
||||
if (fromUserId) {
|
||||
WebRTC.cleanupCall(fromUserId);
|
||||
}
|
||||
|
||||
// End the call
|
||||
state.endCall();
|
||||
}
|
||||
|
||||
private handleCallSessionKey(message: CallSignalingMessage) {
|
||||
const state = this.getState();
|
||||
const { sessionKeyHash, data } = message;
|
||||
if (sessionKeyHash) {
|
||||
state.setCallSessionKeyHash(sessionKeyHash);
|
||||
}
|
||||
if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) {
|
||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash);
|
||||
}
|
||||
}
|
||||
|
||||
private handleVideoToggle(message: CallSignalingMessage, data: CallVideoToggleData) {
|
||||
const state = this.getState();
|
||||
|
||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
||||
// Update Zustand state (for UI)
|
||||
state.setRemoteVideoEnabled(data.enabled);
|
||||
// Update WebRTC internal state (for track routing)
|
||||
WebRTC.setRemoteVideoEnabled(message.fromUserId, data.enabled);
|
||||
} else {
|
||||
console.warn("Invalid toggle data:", data);
|
||||
}
|
||||
}
|
||||
|
||||
private handleScreenShareToggle(message: CallSignalingMessage, data: CallScreenShareToggleData) {
|
||||
const state = this.getState();
|
||||
|
||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
||||
// Update Zustand state (for UI)
|
||||
state.setRemoteScreenSharing(data.enabled);
|
||||
// Update WebRTC internal state (for track routing)
|
||||
WebRTC.setRemoteScreenSharing(message.fromUserId, data.enabled);
|
||||
} else {
|
||||
console.warn("Invalid toggle data:", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { StyledDialog } from "./StyledDialog";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
import styles from "./css/alert-dialog.module.scss";
|
||||
|
||||
interface AlertDialogState {
|
||||
open: boolean;
|
||||
message: string;
|
||||
resolve: (() => void) | null;
|
||||
}
|
||||
|
||||
let alertState: AlertDialogState = {
|
||||
open: false,
|
||||
message: "",
|
||||
resolve: null
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners() {
|
||||
listeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for window.alert() using StyledDialog
|
||||
* @param message - The message to display
|
||||
* @returns Promise that resolves when the dialog is closed
|
||||
*/
|
||||
export function alert(message: string): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
alertState = {
|
||||
open: true,
|
||||
message,
|
||||
resolve: () => {
|
||||
alertState.open = false;
|
||||
alertState.message = "";
|
||||
alertState.resolve = null;
|
||||
notifyListeners();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal component that renders the alert dialog
|
||||
*/
|
||||
export function AlertDialogProvider() {
|
||||
const [, setUpdateKey] = useState(0);
|
||||
|
||||
const update = useCallback(() => {
|
||||
setUpdateKey(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(update);
|
||||
return () => {
|
||||
listeners.delete(update);
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (alertState.resolve) {
|
||||
alertState.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
open={alertState.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
onBackdropClick={handleClose}
|
||||
className={styles.alertDialog}
|
||||
contentClassName={styles.alertDialogContent}
|
||||
>
|
||||
<div className={styles.alertDialogMessage}>
|
||||
{alertState.message}
|
||||
</div>
|
||||
<div className={styles.alertDialogActions}>
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
onClick={handleClose}
|
||||
>
|
||||
OK
|
||||
</MaterialButton>
|
||||
</div>
|
||||
</StyledDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import useCombinedRefs from '@/core/hooks/useCombinedRefs';
|
||||
import { id } from '@/utils/utils';
|
||||
|
||||
interface AutoResizeInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
autoresizing?: true;
|
||||
placeholderMinWidth?: boolean;
|
||||
onAutosize?: (width: number) => void;
|
||||
}
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
autoresizing?: false;
|
||||
placeholderMinWidth?: false;
|
||||
onAutosize?: undefined;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
autoresizing = false,
|
||||
placeholderMinWidth = false,
|
||||
onAutosize,
|
||||
style: inputStyle,
|
||||
...inputProps
|
||||
}: AutoResizeInputProps | InputProps) {
|
||||
const [inputWidth, setInputWidth] = useState(0);
|
||||
|
||||
const sizerRef = useRef<HTMLDivElement>(null);
|
||||
const placeholderSizerRef = useRef<HTMLDivElement>(null);
|
||||
const [inputRef, inputElement] = useCombinedRefs<HTMLInputElement>();
|
||||
|
||||
const sizerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
visibility: 'hidden',
|
||||
height: 0,
|
||||
overflow: 'scroll',
|
||||
whiteSpace: 'pre',
|
||||
};
|
||||
|
||||
const copyStyles = useCallback((styles: CSSStyleDeclaration, node: HTMLElement) => {
|
||||
node.style.fontSize = styles.fontSize;
|
||||
node.style.fontFamily = styles.fontFamily;
|
||||
node.style.fontWeight = styles.fontWeight;
|
||||
node.style.fontStyle = styles.fontStyle;
|
||||
node.style.letterSpacing = styles.letterSpacing;
|
||||
node.style.textTransform = styles.textTransform;
|
||||
}, []);
|
||||
|
||||
const updateInputWidth = useCallback(() => {
|
||||
if (!sizerRef.current || typeof sizerRef.current.scrollWidth === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let newInputWidth: number;
|
||||
|
||||
if (inputProps.placeholder && (!inputProps.value || (inputProps.value && placeholderMinWidth))) {
|
||||
const sizerWidth = sizerRef.current.scrollWidth;
|
||||
const placeholderWidth = placeholderSizerRef.current?.scrollWidth || 0;
|
||||
newInputWidth = Math.max(sizerWidth, placeholderWidth) + 2;
|
||||
} else {
|
||||
newInputWidth = sizerRef.current.scrollWidth + 2;
|
||||
}
|
||||
|
||||
|
||||
if (newInputWidth !== inputWidth) {
|
||||
setInputWidth(newInputWidth);
|
||||
onAutosize?.(newInputWidth);
|
||||
}
|
||||
}, [inputProps.placeholder, inputProps.value, inputProps.type, placeholderMinWidth, inputWidth, onAutosize]);
|
||||
|
||||
const copyInputStyles = useCallback(() => {
|
||||
if (!inputElement.current || !window.getComputedStyle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputStyles = window.getComputedStyle(inputElement.current);
|
||||
if (!inputStyles) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyStyles(inputStyles, sizerRef.current!);
|
||||
if (placeholderSizerRef.current) {
|
||||
copyStyles(inputStyles, placeholderSizerRef.current);
|
||||
}
|
||||
}, [inputElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoresizing) {
|
||||
copyInputStyles();
|
||||
updateInputWidth();
|
||||
}
|
||||
}, [autoresizing, copyInputStyles, updateInputWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoresizing) {
|
||||
updateInputWidth();
|
||||
}
|
||||
}, [inputProps.value, inputProps.placeholder, autoresizing, updateInputWidth]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
width: autoresizing ? `${inputWidth}px` : undefined,
|
||||
...inputStyle,
|
||||
}}
|
||||
/>
|
||||
{autoresizing && createPortal(
|
||||
<>
|
||||
<div ref={sizerRef} style={sizerStyle}>
|
||||
{inputProps.defaultValue || inputProps.value || ''}
|
||||
</div>
|
||||
{inputProps.placeholder && (
|
||||
<div ref={placeholderSizerRef} style={sizerStyle}>
|
||||
{inputProps.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
id("root")
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface QuoteProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
background?: "surfaceContainer" | "primaryContainer"
|
||||
}
|
||||
|
||||
export default function Quote({ className, children, background = "primaryContainer" }: QuoteProps) {
|
||||
return (
|
||||
<div className={`quote bg-${background} ${className}`}>
|
||||
<div className="quote-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useCallback, useLayoutEffect } from "react";
|
||||
|
||||
interface RichTextAreaProps {
|
||||
text: string;
|
||||
onTextChange: (value: string) => void;
|
||||
onEnter?: "newLine" | null | ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void);
|
||||
onCtrlEnter?: ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void) | null;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
rows?: number;
|
||||
autoComplete?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function RichTextArea({
|
||||
text,
|
||||
onTextChange,
|
||||
onEnter = "newLine",
|
||||
onCtrlEnter = null,
|
||||
placeholder,
|
||||
className,
|
||||
rows = 1,
|
||||
autoComplete = "off",
|
||||
readOnly = false
|
||||
}: RichTextAreaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const heightRef = useRef<number | null>(null);
|
||||
|
||||
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
|
||||
const raw = computedStyle[prop] as string | number | undefined;
|
||||
if (raw == null) return 0;
|
||||
const str = String(raw);
|
||||
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
|
||||
}
|
||||
|
||||
const calculateTextareaStyles = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const hidden = hiddenTextareaRef.current;
|
||||
if (!textarea || !hidden) return undefined;
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
if (computedStyle.width === "0px") {
|
||||
return { outerHeightStyle: 0, overflowing: false };
|
||||
}
|
||||
|
||||
// Ensure hidden textarea copies width but not percentage-based anomalies from parents
|
||||
// Normalize hidden textarea to avoid inherited constraints and copy critical metrics
|
||||
hidden.style.position = "fixed";
|
||||
hidden.style.top = "-9999px";
|
||||
hidden.style.left = "-9999px";
|
||||
hidden.style.visibility = "hidden";
|
||||
hidden.style.height = "auto";
|
||||
hidden.style.minHeight = "0";
|
||||
hidden.style.maxHeight = "none";
|
||||
hidden.style.overflow = "hidden";
|
||||
hidden.style.boxSizing = computedStyle.boxSizing;
|
||||
// Avoid counting vertical padding twice: keep 0 for measurement
|
||||
hidden.style.paddingTop = "0";
|
||||
hidden.style.paddingBottom = "0";
|
||||
hidden.style.paddingLeft = computedStyle.paddingLeft;
|
||||
hidden.style.paddingRight = computedStyle.paddingRight;
|
||||
// Do not include borders in the inner scrollHeight measurement
|
||||
hidden.style.borderTopWidth = "0";
|
||||
hidden.style.borderBottomWidth = "0";
|
||||
hidden.style.borderLeftWidth = computedStyle.borderLeftWidth;
|
||||
hidden.style.borderRightWidth = computedStyle.borderRightWidth;
|
||||
hidden.style.fontFamily = computedStyle.fontFamily;
|
||||
hidden.style.fontSize = computedStyle.fontSize;
|
||||
hidden.style.fontWeight = computedStyle.fontWeight;
|
||||
hidden.style.lineHeight = computedStyle.lineHeight;
|
||||
hidden.style.letterSpacing = computedStyle.letterSpacing;
|
||||
hidden.style.whiteSpace = computedStyle.whiteSpace;
|
||||
hidden.style.wordSpacing = computedStyle.wordSpacing;
|
||||
hidden.style.textIndent = computedStyle.textIndent;
|
||||
hidden.style.textTransform = computedStyle.textTransform;
|
||||
hidden.style.textDecoration = computedStyle.textDecoration;
|
||||
hidden.style.width = computedStyle.width;
|
||||
hidden.style.maxWidth = computedStyle.width;
|
||||
hidden.value = textarea.value || placeholder || "x";
|
||||
if (hidden.value.slice(-1) === "\n") {
|
||||
hidden.value += " ";
|
||||
}
|
||||
|
||||
const boxSizing = computedStyle.boxSizing;
|
||||
const padding = getStyleValue(computedStyle, "paddingBottom") + getStyleValue(computedStyle, "paddingTop");
|
||||
const border = getStyleValue(computedStyle, "borderBottomWidth") + getStyleValue(computedStyle, "borderTopWidth");
|
||||
|
||||
const innerHeight = hidden.scrollHeight;
|
||||
|
||||
hidden.value = "x";
|
||||
const singleRowHeight = hidden.scrollHeight;
|
||||
|
||||
let outerHeight = innerHeight;
|
||||
const minRows = Number(rows || 1);
|
||||
if (minRows) {
|
||||
outerHeight = Math.max(minRows * singleRowHeight, outerHeight);
|
||||
}
|
||||
outerHeight = Math.max(outerHeight, singleRowHeight);
|
||||
|
||||
// Use ceil to avoid sub-pixel gaps and subtract a tiny epsilon to reduce visual gap
|
||||
let outerHeightStyle = outerHeight + (boxSizing === "border-box" ? padding + border : 0);
|
||||
outerHeightStyle = Math.round(outerHeightStyle); // snap to pixel to avoid half-line gaps
|
||||
const overflowing = Math.abs(outerHeight - innerHeight) <= 1;
|
||||
return { outerHeightStyle, overflowing };
|
||||
}, [rows, placeholder]);
|
||||
|
||||
const syncHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const styles = calculateTextareaStyles();
|
||||
if (!textarea || !styles) return;
|
||||
const { outerHeightStyle, overflowing } = styles;
|
||||
if (heightRef.current !== outerHeightStyle) {
|
||||
heightRef.current = outerHeightStyle;
|
||||
textarea.style.height = `${outerHeightStyle}px`;
|
||||
}
|
||||
textarea.style.overflowY = overflowing ? "hidden" : "";
|
||||
}, [calculateTextareaStyles]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncHeight();
|
||||
}, [syncHeight, text]);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const onResize = () => syncHeight();
|
||||
window.addEventListener("resize", onResize);
|
||||
let ro: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro = new ResizeObserver(() => {
|
||||
ro!.unobserve(textarea);
|
||||
syncHeight();
|
||||
requestAnimationFrame(() => ro && textarea && ro.observe(textarea));
|
||||
});
|
||||
ro.observe(textarea);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
if (ro) ro.disconnect();
|
||||
};
|
||||
}, [syncHeight]);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
// Keep height responsive during rapid uncontrolled input bursts
|
||||
syncHeight();
|
||||
onTextChange(e.target.value);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
const isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey);
|
||||
const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
|
||||
|
||||
if (isCtrlEnter) {
|
||||
e.preventDefault();
|
||||
if (typeof onCtrlEnter === "function") {
|
||||
onCtrlEnter(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainEnter) {
|
||||
if (onEnter === "newLine") {
|
||||
// allow default
|
||||
return;
|
||||
}
|
||||
if (onEnter === null) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (typeof onEnter === "function") {
|
||||
e.preventDefault();
|
||||
onEnter(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
className={`rich-text-area ${className}`}
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
autoComplete={readOnly ? "off" : autoComplete}
|
||||
onChange={readOnly ? undefined : handleChange}
|
||||
onKeyDown={readOnly ? undefined : handleKeyDown}
|
||||
readOnly={readOnly} />
|
||||
<textarea
|
||||
aria-hidden
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
ref={hiddenTextareaRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "-9999px",
|
||||
left: "-9999px",
|
||||
visibility: "hidden",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
height: "auto",
|
||||
minHeight: 0,
|
||||
maxHeight: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import styles from "./css/searchBar.module.scss";
|
||||
import { MaterialIcon, type MDUIBottomAppBar } from "@/utils/material";
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder: string;
|
||||
children?: React.ReactNode;
|
||||
searchQuery: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
leftIcon?: string | React.ReactNode;
|
||||
rightIcon?: string | React.ReactNode;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
headerRef?: React.RefObject<HTMLElement | null>;
|
||||
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
|
||||
}
|
||||
|
||||
export default function SearchBar({
|
||||
placeholder,
|
||||
children,
|
||||
searchQuery,
|
||||
onQueryChange,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
leftIcon = "search--outlined",
|
||||
rightIcon = null,
|
||||
containerRef,
|
||||
headerRef,
|
||||
bottomAppBarRef
|
||||
}: SearchBarProps) {
|
||||
const [dynamicHeight, setDynamicHeight] = useState<string>("48px");
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const parentContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
// Focus input when expanded and manage height
|
||||
useEffect(() => {
|
||||
if (isExpanded && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
// Set expanded height, subtracting both header and bottom app bar heights
|
||||
if (containerRef.current) {
|
||||
const panelHeight = containerRef.current.offsetHeight;
|
||||
let headerHeight = 0;
|
||||
let bottomBarHeight = 0;
|
||||
|
||||
// Get header height
|
||||
if (headerRef?.current) {
|
||||
headerHeight = headerRef.current.offsetHeight;
|
||||
}
|
||||
|
||||
// Get bottom app bar height
|
||||
if (bottomAppBarRef?.current) {
|
||||
bottomBarHeight = bottomAppBarRef.current.offsetHeight;
|
||||
}
|
||||
|
||||
// Calculate height by subtracting both header and bottom bar heights
|
||||
const availableHeight = panelHeight - headerHeight - bottomBarHeight;
|
||||
setDynamicHeight(`${availableHeight}px`);
|
||||
}
|
||||
} else {
|
||||
// Set collapsed height
|
||||
setDynamicHeight("48px");
|
||||
}
|
||||
|
||||
// Show/hide results and disable overflow during transition
|
||||
if (isExpanded) {
|
||||
setShowResults(true);
|
||||
}
|
||||
|
||||
setIsTransitioning(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
if (!isExpanded) {
|
||||
setShowResults(false);
|
||||
}
|
||||
}, 400); // Match transition duration (0.4s)
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isExpanded, containerRef, headerRef, bottomAppBarRef]);
|
||||
|
||||
function handleToggle() {
|
||||
onToggleExpanded();
|
||||
};
|
||||
|
||||
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const query = e.target.value;
|
||||
onQueryChange(query);
|
||||
};
|
||||
|
||||
// Helper function to render icon
|
||||
function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) {
|
||||
if (icon === null) return null;
|
||||
if (!icon) {
|
||||
return defaultIcon ? <MaterialIcon name={defaultIcon} /> : null;
|
||||
} else if (typeof icon === 'string') {
|
||||
return <MaterialIcon name={icon} />;
|
||||
} else {
|
||||
return icon;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentContainerRef}
|
||||
className={styles.searchParent}
|
||||
>
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={`${styles.searchBarContainer} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||
style={{ height: dynamicHeight }}
|
||||
onClick={!isExpanded ? handleToggle : undefined}
|
||||
>
|
||||
{/* Single Search Bar Element */}
|
||||
<div className={styles.searchBar}>
|
||||
{/* Left Icon */}
|
||||
<div className={styles.searchIcon}>
|
||||
{renderIcon(leftIcon, "search--outlined")}
|
||||
</div>
|
||||
|
||||
{/* Input/Placeholder */}
|
||||
<div className={styles.searchInputContainer}>
|
||||
{isExpanded ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
className={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChange={handleQueryChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.searchPlaceholder}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Icon */}
|
||||
<div className={styles.searchClear}>
|
||||
{renderIcon(rightIcon)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Section - Visible during expansion and collapse transition */}
|
||||
{showResults && (
|
||||
<div
|
||||
className={styles.searchResults}
|
||||
style={{ overflowY: isTransitioning ? "hidden" : "auto" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material";
|
||||
import useWindowSize from "@/core/hooks/useWindowSize";
|
||||
import styles from "./css/split-button.module.scss";
|
||||
|
||||
export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated";
|
||||
|
||||
interface SplitButtonProps {
|
||||
text: ReactNode;
|
||||
icon?: ReactNode | string;
|
||||
menu: ReactNode;
|
||||
menuOpen: boolean;
|
||||
onMenuOpen: (open: boolean) => void;
|
||||
onPrimaryClick?: () => void;
|
||||
variant?: SplitButtonVariant;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
menuAriaLabel?: string;
|
||||
}
|
||||
|
||||
export function SplitButton({
|
||||
text,
|
||||
icon,
|
||||
menu,
|
||||
menuOpen: open,
|
||||
onMenuOpen,
|
||||
onPrimaryClick,
|
||||
variant = "filled",
|
||||
disabled = false,
|
||||
className = "",
|
||||
menuAriaLabel,
|
||||
}: SplitButtonProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuSegmentRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const primaryRipple = useRippleHandlers(disabled);
|
||||
const menuRipple = useRippleHandlers(disabled);
|
||||
|
||||
const MENU_GAP = 16;
|
||||
const EDGE_PAD = 16;
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const anchor = menuSegmentRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const menuEl = menuRef.current;
|
||||
const menuWidth = menuEl?.offsetWidth ?? 220;
|
||||
const menuHeight = menuEl?.offsetHeight ?? 320;
|
||||
|
||||
const anchorCenterX = rect.left + rect.width / 2;
|
||||
let left: number;
|
||||
let top: number | undefined;
|
||||
let bottom: number | undefined;
|
||||
let maxHeight: number;
|
||||
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD;
|
||||
const availableAbove = rect.top - MENU_GAP - EDGE_PAD;
|
||||
const fitsBelow = menuHeight <= availableBelow;
|
||||
const fitsAbove = menuHeight <= availableAbove;
|
||||
const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow);
|
||||
|
||||
if (placeAbove) {
|
||||
bottom = vh - (rect.top - MENU_GAP);
|
||||
maxHeight = Math.max(100, availableAbove);
|
||||
} else {
|
||||
top = rect.bottom + MENU_GAP;
|
||||
maxHeight = Math.max(100, availableBelow);
|
||||
}
|
||||
|
||||
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
|
||||
left = EDGE_PAD;
|
||||
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
|
||||
left = vw - menuWidth - EDGE_PAD;
|
||||
} else {
|
||||
left = anchorCenterX - menuWidth / 2;
|
||||
}
|
||||
|
||||
setMenuPosition({ top, bottom, left, maxHeight });
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
onMenuOpen(false);
|
||||
setIsExiting(true);
|
||||
}, [onMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && !isExiting) {
|
||||
setMenuPosition(null);
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
|
||||
updateMenuPosition();
|
||||
window.addEventListener("scroll", updateMenuPosition, true);
|
||||
|
||||
function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
if (rootRef.current?.contains(target)) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeMenu();
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||
document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||
document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||
document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setIsExiting(true);
|
||||
}, [open]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open && menuRef.current) {
|
||||
updateMenuPosition();
|
||||
}
|
||||
}, [open, updateMenuPosition]);
|
||||
|
||||
const handlePrimaryClick = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onPrimaryClick?.();
|
||||
};
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
if (disabled) return;
|
||||
if (open) closeMenu();
|
||||
else onMenuOpen(true);
|
||||
};
|
||||
|
||||
const variantClass =
|
||||
variant === "tonal"
|
||||
? styles.variantTonal
|
||||
: variant === "outlined"
|
||||
? styles.variantOutlined
|
||||
: variant === "elevated"
|
||||
? styles.variantElevated
|
||||
: styles.variantFilled;
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof icon === "string") {
|
||||
return <MaterialIcon name={icon} className={styles.leadingIconIcon} />;
|
||||
}
|
||||
|
||||
return <span className={styles.leadingIconIcon}>{icon}</span>;
|
||||
};
|
||||
|
||||
const rootClasses = [
|
||||
styles.splitButton,
|
||||
variantClass,
|
||||
disabled ? styles.disabled : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={rootClasses}
|
||||
data-open={open ? "true" : "false"}
|
||||
aria-disabled={disabled ? "true" : "false"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primarySegment}
|
||||
onClick={handlePrimaryClick}
|
||||
onPointerDown={primaryRipple.onPointerDown}
|
||||
onPointerEnter={primaryRipple.onPointerEnter}
|
||||
onPointerLeave={primaryRipple.onPointerLeave}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MaterialRipple ref={primaryRipple.rippleRef} />
|
||||
<span className={styles.primaryContent}>
|
||||
{icon && <span className={styles.leadingIcon}>{renderIcon()}</span>}
|
||||
<span className={styles.label}>{text}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={menuSegmentRef}
|
||||
type="button"
|
||||
className={styles.menuSegment}
|
||||
onClick={handleMenuToggle}
|
||||
onPointerDown={menuRipple.onPointerDown}
|
||||
onPointerEnter={menuRipple.onPointerEnter}
|
||||
onPointerLeave={menuRipple.onPointerLeave}
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={menuAriaLabel}
|
||||
>
|
||||
<MaterialRipple ref={menuRipple.rippleRef} />
|
||||
<span className={styles.menuIcon}>
|
||||
<MaterialIcon name="expand_more" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(open || isExiting) &&
|
||||
menuPosition &&
|
||||
createPortal(
|
||||
<AnimatePresence onExitComplete={() => setIsExiting(false)}>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="menu"
|
||||
ref={menuRef}
|
||||
className={styles.menu}
|
||||
style={{
|
||||
position: "fixed",
|
||||
...(menuPosition.bottom != null
|
||||
? { bottom: menuPosition.bottom }
|
||||
: { top: menuPosition.top }),
|
||||
left: menuPosition.left,
|
||||
maxHeight: menuPosition.maxHeight,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
{menu}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
|
||||
export type VerificationStatus = "verified" | "warning" | "blocked" | "none";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
verificationStatus?: VerificationStatus | null;
|
||||
/** @deprecated Use verificationStatus instead */
|
||||
verified?: boolean;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
function resolveVerificationStatus(
|
||||
verificationStatus?: VerificationStatus | null,
|
||||
verified?: boolean,
|
||||
): VerificationStatus {
|
||||
if (verificationStatus) {
|
||||
return verificationStatus;
|
||||
}
|
||||
if (verified) {
|
||||
return "verified";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function StatusBadge({ verificationStatus, verified, size = "small" }: StatusBadgeProps) {
|
||||
const status = resolveVerificationStatus(verificationStatus, verified);
|
||||
const className = `status-badge ${size}`;
|
||||
|
||||
if (status === "verified") {
|
||||
return (
|
||||
<span className={`${className} verified`} title="Подтверждённый аккаунт">
|
||||
<MaterialIcon name="verified--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "warning") {
|
||||
return (
|
||||
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
|
||||
<MaterialIcon name="warning--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "blocked") {
|
||||
return (
|
||||
<span className={`${className} blocked`} title="Аккаунт заблокирован">
|
||||
<MaterialIcon name="block--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence, type Transition } from "motion/react";
|
||||
import styles from "./css/styled-dialog.module.scss";
|
||||
|
||||
interface StyledDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: ReactNode;
|
||||
onBackdropClick?: () => void;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
afterChildren?: ReactNode;
|
||||
}
|
||||
|
||||
export function StyledDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
onBackdropClick,
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
afterChildren
|
||||
}: StyledDialogProps) {
|
||||
const transition: Transition = { duration: 0.3, type: "tween", ease: "easeInOut" };
|
||||
|
||||
// Handle ESC key
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className={styles.styledDialogBackdrop}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
if (onBackdropClick) {
|
||||
onBackdropClick();
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={transition}>
|
||||
<motion.div
|
||||
className={`${styles.styledDialog} ${className}`}
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={transition}>
|
||||
<div className={`${styles.styledDialogContent} ${contentClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
{afterChildren}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.getElementById("root")!
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import api from "@/core/api";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
|
||||
interface VerifyButtonProps {
|
||||
userId: number;
|
||||
verified: boolean;
|
||||
onVerificationChange?: (verified: boolean) => void;
|
||||
}
|
||||
|
||||
export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const { user } = useUserStore();
|
||||
|
||||
// Only show for owner
|
||||
if (user.currentUser?.id !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleVerifyToggle() {
|
||||
if (!user.authToken || isVerifying) return;
|
||||
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const result = await api.moderation.users.verify(userId, user.authToken);
|
||||
if (result) {
|
||||
onVerificationChange?.(result.verified);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling verification:', error);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
loading={isVerifying}
|
||||
onClick={handleVerifyToggle}
|
||||
title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"}
|
||||
>
|
||||
{verified ? "Отменить подтверждение" : "Подтвердить"}
|
||||
</MaterialButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
|
||||
.alertDialog {
|
||||
.alertDialogContent {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.alertDialogMessage {
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.alertDialogActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
@use "../../../css/material" as *;
|
||||
|
||||
$font-size: 16px;
|
||||
|
||||
// Search container
|
||||
.searchParent {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// SearchBar component styles
|
||||
.searchBarContainer {
|
||||
position: absolute;
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// All properties animate together simultaneously
|
||||
transition:
|
||||
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
left 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
right 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// Initial background color for smooth transition
|
||||
background-color: $color-dark-surface-container-high;
|
||||
|
||||
&.collapsed {
|
||||
top: 8px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
border-radius: 24px;
|
||||
// Height will be set dynamically by React (48px)
|
||||
// background-color inherited from parent
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
// bottom will be set dynamically by React to account for bottom app bar
|
||||
border-radius: 0;
|
||||
background-color: $color-dark-surface-container;
|
||||
// Height will be set dynamically by React
|
||||
}
|
||||
|
||||
// Single search bar element
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
.searchIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
mdui-icon {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInputContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.searchPlaceholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: $font-size;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: $font-size;
|
||||
padding: 8px 0;
|
||||
pointer-events: auto;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.searchClear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
mdui-icon {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results section
|
||||
.searchResults {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
@use "../../../css/material" as *;
|
||||
|
||||
$height: 40px;
|
||||
$trailing-width: 48px; // 12 + 22 + 14 per spec
|
||||
$between-space: 2px;
|
||||
$outer-radius: calc(#{$height} / 2); // 20px
|
||||
$inner-radius: 4px;
|
||||
$inner-radius-hovered: 12px;
|
||||
|
||||
.splitButton {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
border-radius: $outer-radius;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.1px;
|
||||
background-color: transparent;
|
||||
color: $color-dark-on-primary;
|
||||
isolation: isolate;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.primarySegment,
|
||||
.menuSegment {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
min-height: $height;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $color-dark-primary;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
mdui-ripple {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.primarySegment {
|
||||
border-top-left-radius: $outer-radius;
|
||||
border-bottom-left-radius: $outer-radius;
|
||||
border-top-right-radius: $inner-radius;
|
||||
border-bottom-right-radius: $inner-radius;
|
||||
padding-inline: 16px 12px;
|
||||
transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
border-top-right-radius: $inner-radius-hovered;
|
||||
border-bottom-right-radius: $inner-radius-hovered;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-top-right-radius: $inner-radius-hovered;
|
||||
border-bottom-right-radius: $inner-radius-hovered;
|
||||
}
|
||||
|
||||
.primaryContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
.leadingIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.leadingIconIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menuSegment {
|
||||
width: $trailing-width;
|
||||
border-top-right-radius: $outer-radius;
|
||||
border-bottom-right-radius: $outer-radius;
|
||||
border-top-left-radius: $inner-radius;
|
||||
border-bottom-left-radius: $inner-radius;
|
||||
margin-left: $between-space;
|
||||
padding-inline: 12px 14px;
|
||||
transition:
|
||||
border-top-left-radius 0.18s ease-out,
|
||||
border-bottom-left-radius 0.18s ease-out,
|
||||
padding-inline 0.18s ease-out;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
border-top-left-radius: $inner-radius-hovered;
|
||||
border-bottom-left-radius: $inner-radius-hovered;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-top-left-radius: $inner-radius-hovered;
|
||||
border-bottom-left-radius: $inner-radius-hovered;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 22px;
|
||||
transition: transform 0.18s ease-out;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
mdui-icon {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-open="true"] .menuSegment {
|
||||
$size: calc($trailing-width / 2);
|
||||
border-radius: $size;
|
||||
padding-inline: 13px 13px;
|
||||
|
||||
.menuIcon {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&.variantFilled {
|
||||
.primarySegment, .menuSegment {
|
||||
background-color: $color-dark-primary;
|
||||
color: $color-dark-on-primary;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.variantTonal {
|
||||
.primarySegment, .menuSegment {
|
||||
background-color: $color-dark-primary-container;
|
||||
color: $color-dark-on-primary-container;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.variantOutlined {
|
||||
.primarySegment, .menuSegment {
|
||||
border: 1px solid rgba($color-dark-outline, 0.8);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
&.variantElevated {
|
||||
.primarySegment, .menuSegment {
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.3),
|
||||
0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
background-color: $color-dark-surface-container-low;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$menu-padding: 8px;
|
||||
|
||||
.menu {
|
||||
padding: $menu-padding;
|
||||
min-width: 220px;
|
||||
border-radius: 16px;
|
||||
background-color: rgba($color-dark-surface-container-high, 0.4);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
z-index: 100000000;
|
||||
|
||||
// Custom slim semi-transparent scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba($color-dark-on-surface, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba($color-dark-on-surface, 0.4);
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
|
||||
|
||||
mdui-list {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Dialog padding variable
|
||||
$dialog-padding: 30px;
|
||||
|
||||
// Base Styled Dialog Styles
|
||||
.styledDialogBackdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $dialog-padding;
|
||||
box-sizing: border-box;
|
||||
|
||||
.styledDialog {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: calc(100vh - #{$dialog-padding} * 2);
|
||||
background: rgba($color-dark-surface-container, 0.75);
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.3);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
|
||||
0 9px 46px 8px rgba(0, 0, 0, 0.12),
|
||||
0 11px 15px -7px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.styledDialogContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview Application configuration constants
|
||||
* @description Contains all configuration values used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
const DEFAULT_API_BASE_URL = import.meta.env.DEV
|
||||
? "http://localhost:8300"
|
||||
: "https://api.fromchat.ru";
|
||||
|
||||
function resolveApiBaseUrl(): string {
|
||||
return import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function stripUrlProtocol(value: string): string {
|
||||
return value.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function resolveWsHost(apiBaseUrl: string): string {
|
||||
const explicit = import.meta.env.VITE_API_WS_BASE_URL;
|
||||
if (explicit) {
|
||||
return stripUrlProtocol(explicit);
|
||||
}
|
||||
|
||||
if (apiBaseUrl.startsWith("/")) {
|
||||
const path = apiBaseUrl.replace(/\/$/, "");
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.host}${path}`;
|
||||
}
|
||||
return `localhost:8301${path}`;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(apiBaseUrl).host;
|
||||
} catch {
|
||||
return stripUrlProtocol(apiBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWsProtocol(apiBaseUrl: string): "ws:" | "wss:" {
|
||||
if (apiBaseUrl.startsWith("https:")) {
|
||||
return "wss:";
|
||||
}
|
||||
if (typeof window !== "undefined" && window.location.protocol === "https:") {
|
||||
return "wss:";
|
||||
}
|
||||
return "ws:";
|
||||
}
|
||||
|
||||
export const BASE_DOMAIN = "fromchat.ru";
|
||||
export const API_BASE_URL = resolveApiBaseUrl();
|
||||
export const API_WS_BASE_URL = resolveWsHost(API_BASE_URL);
|
||||
|
||||
export function getChatWebSocketUrl(): string {
|
||||
return `${resolveWsProtocol(API_BASE_URL)}//${API_WS_BASE_URL}/chat/ws`;
|
||||
}
|
||||
|
||||
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,34 @@
|
||||
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
|
||||
|
||||
// Определяем тип для ref, который может быть либо функцией, либо объектом
|
||||
type PossibleRef<T> = Ref<T> | undefined;
|
||||
|
||||
export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallback<T>, React.RefObject<T | null>] {
|
||||
const targetRef = useRef<T | null>(null);
|
||||
|
||||
const setRefs = useCallback((node: T | null) => {
|
||||
// Обновляем внутренний ref
|
||||
targetRef.current = node;
|
||||
|
||||
// Обновляем все переданные refs
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
// Если ref - это функция, вызываем её
|
||||
ref(node);
|
||||
} else {
|
||||
// Если ref - это объект, обновляем его свойство .current
|
||||
// Используем проверку, чтобы убедиться, что это действительно MutableRefObject
|
||||
// (хотя в реальном коде это почти всегда так)
|
||||
ref.current = node;
|
||||
}
|
||||
});
|
||||
},
|
||||
// Убедитесь, что массив зависимостей всегда актуален
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[...refs]
|
||||
);
|
||||
|
||||
return [setRefs, targetRef];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { MINIMUM_WIDTH } from "@/core/config";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
export default function useDownloadAppScreen() {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < MINIMUM_WIDTH;
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
navigate: isMobile ? <Navigate to="/download-app" replace /> : null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function useWindowSize(): WindowSize {
|
||||
const [width, setWidth] = useState(innerWidth);
|
||||
const [height, setHeight] = useState(innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
function listener() {
|
||||
setWidth(innerWidth);
|
||||
setHeight(innerHeight);
|
||||
}
|
||||
|
||||
addEventListener("resize", listener);
|
||||
|
||||
return () => {
|
||||
removeEventListener("resize", listener);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
width: width,
|
||||
height: height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @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";
|
||||
import type { Platform } from "../../electron/electron.d";
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (userAgent.includes("win")) return "win32";
|
||||
if (userAgent.includes("mac")) return "darwin";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
document.title = PRODUCT_NAME;
|
||||
enableMapSet();
|
||||
|
||||
// Add platform class to body
|
||||
const platform = detectPlatform();
|
||||
document.body.classList.add(`platform-${platform}`);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import legalStyles from "@/core/legal/legal.module.scss";
|
||||
|
||||
export function LegalInlineLinks() {
|
||||
return (
|
||||
<p className={legalStyles.legalInlineLinks}>
|
||||
Регистрируясь, вы соглашаетесь с{" "}
|
||||
<Link to="/terms">пользовательским соглашением</Link>
|
||||
<span className={legalStyles.legalInlineLinksSep}>·</span>
|
||||
<Link to="/privacy">политикой конфиденциальности</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { parse } from "marked";
|
||||
import { escape as escapeHtml } from "he";
|
||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||
import { fitPathToUnitSquare, getMaterialShapePath } from "./materialShapes";
|
||||
import { legalMaterialIconName, parseLegalMarkdown, type LegalSection } from "./fcDirective";
|
||||
import { rewriteLegalDocumentHref, rewriteLegalLinksInHtml } from "./legalLinks";
|
||||
import {
|
||||
loadLegalDocument,
|
||||
type LegalDocumentKind,
|
||||
} from "./legalDocumentLoader";
|
||||
import { LegalPageShell } from "./LegalPageShell";
|
||||
import styles from "./legal.module.scss";
|
||||
|
||||
const CACHED_BANNER_TEXT =
|
||||
"Показана сохранённая копия документа. Содержимое может быть устаревшим.";
|
||||
|
||||
function wrapMarkdownTables(html: string): string {
|
||||
return html.replace(
|
||||
/<table\b[^>]*>[\s\S]*?<\/table>/gi,
|
||||
(table) => `<div class="legalTableScroll">${table}</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarkdownBody(markdown: string): string {
|
||||
const html = parse(markdown, { breaks: true, gfm: true }) as string;
|
||||
return wrapMarkdownTables(rewriteLegalLinksInHtml(html));
|
||||
}
|
||||
|
||||
function ExpressiveSectionHeader({
|
||||
section,
|
||||
}: {
|
||||
section: LegalSection;
|
||||
}) {
|
||||
const shapePath = useMemo(
|
||||
() => getMaterialShapePath(section.directive.shape),
|
||||
[section.directive.shape],
|
||||
);
|
||||
const shapeFit = useMemo(
|
||||
() => fitPathToUnitSquare(shapePath),
|
||||
[shapePath],
|
||||
);
|
||||
const iconName = legalMaterialIconName(section.directive.icon);
|
||||
|
||||
return (
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionIconFrame}>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
className={styles.sectionIconShape}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g transform={shapeFit.transform}>
|
||||
<path d={shapePath} className={styles.sectionShapeFill} />
|
||||
</g>
|
||||
</svg>
|
||||
<MaterialIcon name={iconName} className={styles.sectionIconGlyph} />
|
||||
</div>
|
||||
<h2 className={styles.sectionTitle}>{section.title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LegalMarkdownPageProps {
|
||||
kind: LegalDocumentKind;
|
||||
}
|
||||
|
||||
export function LegalMarkdownPage({ kind }: LegalMarkdownPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [isCached, setIsCached] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleContentClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
const anchor = (event.target as HTMLElement).closest("a");
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href) return;
|
||||
|
||||
const clientRoute = rewriteLegalDocumentHref(href) ?? (
|
||||
href === "/terms" || href === "/privacy" ? href : null
|
||||
);
|
||||
if (!clientRoute) return;
|
||||
|
||||
event.preventDefault();
|
||||
navigate(clientRoute);
|
||||
}, [navigate]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const abortController = new AbortController();
|
||||
|
||||
setMarkdown(null);
|
||||
setError(null);
|
||||
setIsCached(false);
|
||||
setLoading(true);
|
||||
|
||||
loadLegalDocument(kind, abortController.signal)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.status === "error") {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setMarkdown(result.markdown);
|
||||
setIsCached(result.fromCache);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (cancelled || (e instanceof DOMException && e.name === "AbortError")) {
|
||||
return;
|
||||
}
|
||||
setError("Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abortController.abort();
|
||||
};
|
||||
}, [kind, loadAttempt]);
|
||||
|
||||
const content = (() => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.legalPage}>
|
||||
<p className={styles.loading}>Загрузка…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.legalPage}>
|
||||
<div className={styles.errorState}>
|
||||
<p className={styles.error}>{escapeHtml(error)}</p>
|
||||
<MaterialButton onClick={retry}>Повторить</MaterialButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!markdown) {
|
||||
return (
|
||||
<div className={styles.legalPage}>
|
||||
<p className={styles.loading}>Загрузка…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { preamble, sections } = parseLegalMarkdown(markdown);
|
||||
|
||||
return (
|
||||
<div className={styles.legalPage} onClick={handleContentClick}>
|
||||
{isCached ? (
|
||||
<div className={styles.cachedBanner} role="status">
|
||||
{CACHED_BANNER_TEXT}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preamble ? (
|
||||
<div
|
||||
className={styles.preamble}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdownBody(preamble) }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sections.map((section, index) => (
|
||||
<section key={`${section.title}-${index}`} className={styles.section}>
|
||||
<ExpressiveSectionHeader section={section} />
|
||||
<div
|
||||
className={styles.sectionBody}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdownBody(section.bodyMarkdown) }}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return <LegalPageShell>{content}</LegalPageShell>;
|
||||
}
|
||||
|
||||
export type { LegalDocumentKind };
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { HomeHeader } from "@/pages/home/HomeHeader";
|
||||
import { HomeFooter } from "@/pages/home/HomeFooter";
|
||||
import homeStyles from "@/pages/home/home.module.scss";
|
||||
import styles from "./legal.module.scss";
|
||||
|
||||
interface LegalPageShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LegalPageShell({ children }: LegalPageShellProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const scrollToDownload = () => {
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={homeStyles.homepage}>
|
||||
<HomeHeader onScrollToDownload={scrollToDownload} />
|
||||
<main className={styles.legalMain}>{children}</main>
|
||||
<HomeFooter onScrollToDownload={scrollToDownload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Parses `<!-- fc:shape=Cookie4Sided icon=shield -->` directives before section headers.
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
|
||||
export interface FcSectionDirective {
|
||||
shape: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const FC_DIRECTIVE_RE = /<!--\s*fc:([^>]+?)\s*-->/i;
|
||||
|
||||
function parseDirectiveBody(body: string): FcSectionDirective | null {
|
||||
const shapeMatch = body.match(/shape=([A-Za-z0-9_]+)/);
|
||||
const iconMatch = body.match(/icon=([A-Za-z0-9_-]+)/);
|
||||
if (!shapeMatch || !iconMatch) return null;
|
||||
return { shape: shapeMatch[1], icon: iconMatch[1] };
|
||||
}
|
||||
|
||||
export function parseFcDirective(line: string): FcSectionDirective | null {
|
||||
const match = line.match(FC_DIRECTIVE_RE);
|
||||
if (!match) return null;
|
||||
return parseDirectiveBody(match[1]);
|
||||
}
|
||||
|
||||
export interface LegalSection {
|
||||
directive: FcSectionDirective;
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split markdown into sections keyed by fc directives + `##` headings.
|
||||
*/
|
||||
export function parseLegalMarkdown(markdown: string): { preamble: string; sections: LegalSection[] } {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const preambleLines: string[] = [];
|
||||
const sections: LegalSection[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const directive = parseFcDirective(lines[i]);
|
||||
if (directive && i + 1 < lines.length && lines[i + 1].startsWith("## ")) {
|
||||
const title = lines[i + 1].slice(3).trim();
|
||||
i += 2;
|
||||
const bodyLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
if (parseFcDirective(lines[i]) && i + 1 < lines.length && lines[i + 1].startsWith("## ")) {
|
||||
break;
|
||||
}
|
||||
bodyLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
sections.push({
|
||||
directive,
|
||||
title,
|
||||
bodyMarkdown: bodyLines.join("\n").trim(),
|
||||
});
|
||||
} else if (sections.length === 0) {
|
||||
preambleLines.push(lines[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
preamble: preambleLines.join("\n").trim(),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
export function staticIconUrl(icon: string): string {
|
||||
return `${API_BASE_URL}/static/icons/${encodeURIComponent(icon)}.webp`;
|
||||
}
|
||||
|
||||
/** Maps legal-doc icon keys to Material Symbols names (Google Fonts). */
|
||||
const LEGAL_MATERIAL_ICON: Record<string, string> = {
|
||||
privacy: "privacy_tip",
|
||||
terms: "contract",
|
||||
};
|
||||
|
||||
export function legalMaterialIconName(icon: string): string {
|
||||
return LEGAL_MATERIAL_ICON[icon] ?? icon;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
.legalMain {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.legalPage {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 64px;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
font-size: 1rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $color-dark-error;
|
||||
}
|
||||
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cachedBanner {
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: $color-dark-secondary-container;
|
||||
color: $color-dark-on-secondary-container;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preamble {
|
||||
margin-bottom: 36px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
color: $color-dark-on-surface-variant;
|
||||
text-align: center;
|
||||
|
||||
:global(blockquote) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
|
||||
:global(.legalTableScroll) {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 0 0.75em;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:global(table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:global(th),
|
||||
:global(td) {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid $color-dark-outline-variant;
|
||||
}
|
||||
|
||||
:global(th) {
|
||||
font-weight: 600;
|
||||
background: $color-dark-surface-container-low;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
$expressive-hero-shape-size: 110px;
|
||||
$expressive-hero-icon-size: 50px;
|
||||
|
||||
.sectionIconFrame {
|
||||
width: $expressive-hero-shape-size;
|
||||
height: $expressive-hero-shape-size;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionIconShape {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: $expressive-hero-shape-size;
|
||||
height: $expressive-hero-shape-size;
|
||||
transform: translate(-50%, -50%);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sectionShapeFill {
|
||||
fill: $color-dark-primary-container;
|
||||
}
|
||||
|
||||
.sectionIconGlyph {
|
||||
font-size: $expressive-hero-icon-size !important;
|
||||
width: $expressive-hero-icon-size !important;
|
||||
height: $expressive-hero-icon-size !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: $color-dark-on-primary-container;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
|
||||
:global(h3) {
|
||||
font-size: calc((0.95rem + 1.25rem) / 2);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 1.25em 0 0.5em;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(h2) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
margin: 1.5em 0 0.5em;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
:global(li) {
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
|
||||
:global(.legalTableScroll) {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 0 0.75em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:global(table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:global(th),
|
||||
:global(td) {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid $color-dark-outline-variant;
|
||||
}
|
||||
|
||||
:global(th) {
|
||||
font-weight: 600;
|
||||
background: $color-dark-surface-container-low;
|
||||
}
|
||||
}
|
||||
|
||||
.legalInlineLinks {
|
||||
font-size: 0.875rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
margin-top: 12px;
|
||||
|
||||
a {
|
||||
color: $color-dark-primary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legalInlineLinksSep {
|
||||
margin: 0 6px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { delay } from "@/utils/utils";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
|
||||
export type LegalDocumentKind = "privacy" | "terms";
|
||||
|
||||
export const LEGAL_DOCUMENT_PATH: Record<LegalDocumentKind, string> = {
|
||||
privacy: `${API_BASE_URL}/static/PRIVACY.md`,
|
||||
terms: `${API_BASE_URL}/static/TERMS.md`,
|
||||
};
|
||||
|
||||
const RETRY_WINDOW_MS = 5000;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
const CACHE_KEY: Record<LegalDocumentKind, string> = {
|
||||
privacy: "fromchat:legal:privacy",
|
||||
terms: "fromchat:legal:terms",
|
||||
};
|
||||
|
||||
export type LegalDocumentLoadResult =
|
||||
| { status: "success"; markdown: string; fromCache: false }
|
||||
| { status: "cached"; markdown: string; fromCache: true }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
function readCache(kind: LegalDocumentKind): string | null {
|
||||
try {
|
||||
return localStorage.getItem(CACHE_KEY[kind]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(kind: LegalDocumentKind, markdown: string): void {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY[kind], markdown);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOnce(path: string): Promise<string> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export async function loadLegalDocument(
|
||||
kind: LegalDocumentKind,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LegalDocumentLoadResult> {
|
||||
const path = LEGAL_DOCUMENT_PATH[kind];
|
||||
const start = Date.now();
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
try {
|
||||
const markdown = await fetchOnce(path);
|
||||
writeCache(kind, markdown);
|
||||
return { status: "success", markdown, fromCache: false };
|
||||
} catch {
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed >= RETRY_WINDOW_MS) {
|
||||
break;
|
||||
}
|
||||
await delay(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const cached = readCache(kind);
|
||||
if (cached != null && cached.length > 0) {
|
||||
return { status: "cached", markdown: cached, fromCache: true };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
message: "Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const LEGAL_STATIC_LINK_RE = /(?:^|\/)?(?:api\/)?static\/(TERMS|PRIVACY)\.md$/i;
|
||||
|
||||
/**
|
||||
* Maps static legal markdown API paths to client routes.
|
||||
* Returns null when the href is not a legal document link.
|
||||
*/
|
||||
export function rewriteLegalDocumentHref(href: string): string | null {
|
||||
const path = href.replace(/\\/g, "/").split("?")[0].split("#")[0].replace(/\/+$/, "");
|
||||
const match = path.match(LEGAL_STATIC_LINK_RE);
|
||||
if (!match) return null;
|
||||
return match[1].toUpperCase() === "TERMS" ? "/terms" : "/privacy";
|
||||
}
|
||||
|
||||
export function rewriteLegalLinksInHtml(html: string): string {
|
||||
return html.replace(/href="([^"]+)"/g, (full, href: string) => {
|
||||
const rewritten = rewriteLegalDocumentHref(href);
|
||||
return rewritten ? `href="${rewritten}"` : full;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Auto-generated from MaterialShapes via Robolectric — do not edit. */
|
||||
|
||||
export const MATERIAL_SHAPE_PATHS: Record<string, string> = {
|
||||
"Arch": "M 0.146 0.146 L 0.181 0.114 L 0.22 0.085 L 0.261 0.06 L 0.305 0.039 L 0.351 0.022 L 0.399 0.01 L 0.448 0.002 L 0.5 0 L 0.551 0.002 L 0.6 0.01 L 0.648 0.022 L 0.694 0.039 L 0.738 0.06 L 0.779 0.085 L 0.818 0.114 L 0.853 0.146 L 0.885 0.181 L 0.914 0.22 L 0.939 0.261 L 0.96 0.305 L 0.977 0.351 L 0.989 0.399 L 0.997 0.448 L 0.999 0.5 L 1 0.858 L 0.997 0.887 L 0.988 0.913 L 0.975 0.937 L 0.958 0.958 L 0.937 0.975 L 0.913 0.988 L 0.887 0.997 L 0.858 1 L 0.141 0.999 L 0.112 0.997 L 0.086 0.988 L 0.062 0.975 L 0.041 0.958 L 0.024 0.937 L 0.011 0.913 L 0.002 0.887 L 0 0.858 L 0 0.5 L 0.002 0.448 L 0.01 0.399 L 0.022 0.351 L 0.039 0.305 L 0.06 0.261 L 0.085 0.22 L 0.114 0.181 L 0.146 0.146 L 0.146 0.146 Z",
|
||||
"Arrow": "M 0.499 0.836 L 0.468 0.838 L 0.438 0.843 L 0.277 0.878 L 0.249 0.882 L 0.221 0.882 L 0.194 0.878 L 0.169 0.87 L 0.146 0.858 L 0.125 0.844 L 0.106 0.827 L 0.09 0.807 L 0.077 0.785 L 0.066 0.762 L 0.059 0.738 L 0.055 0.712 L 0.055 0.686 L 0.059 0.659 L 0.068 0.633 L 0.081 0.607 L 0.172 0.452 L 0.269 0.291 L 0.311 0.227 L 0.349 0.175 L 0.386 0.135 L 0.422 0.106 L 0.459 0.089 L 0.498 0.083 L 0.537 0.089 L 0.575 0.106 L 0.611 0.135 L 0.648 0.175 L 0.686 0.227 L 0.728 0.29 L 0.825 0.451 L 0.916 0.602 L 0.929 0.629 L 0.938 0.656 L 0.942 0.683 L 0.942 0.71 L 0.938 0.737 L 0.931 0.762 L 0.92 0.785 L 0.906 0.808 L 0.889 0.827 L 0.87 0.845 L 0.848 0.86 L 0.824 0.871 L 0.799 0.879 L 0.772 0.884 L 0.743 0.884 L 0.713 0.879 L 0.56 0.843 L 0.529 0.838 L 0.499 0.836 L 0.499 0.836 Z",
|
||||
"Boom": "M 0.454 0.287 L 0.459 0.281 L 0.493 0.01 L 0.495 0.006 L 0.5 0.004 L 0.504 0.006 L 0.506 0.01 L 0.541 0.281 L 0.546 0.287 L 0.553 0.284 L 0.694 0.05 L 0.698 0.047 L 0.703 0.048 L 0.706 0.051 L 0.707 0.056 L 0.628 0.317 L 0.63 0.325 L 0.638 0.325 L 0.862 0.169 L 0.867 0.167 L 0.871 0.17 L 0.873 0.174 L 0.871 0.179 L 0.693 0.385 L 0.692 0.394 L 0.699 0.397 L 0.967 0.345 L 0.972 0.346 L 0.975 0.35 L 0.975 0.355 L 0.971 0.358 L 0.725 0.474 L 0.721 0.481 L 0.726 0.488 L 0.991 0.549 L 0.996 0.552 L 0.997 0.557 L 0.995 0.561 L 0.99 0.563 L 0.717 0.569 L 0.711 0.573 L 0.713 0.581 L 0.931 0.745 L 0.933 0.75 L 0.933 0.754 L 0.929 0.758 L 0.924 0.757 L 0.672 0.652 L 0.664 0.653 L 0.664 0.661 L 0.795 0.9 L 0.796 0.905 L 0.793 0.909 L 0.789 0.91 L 0.784 0.908 L 0.598 0.709 L 0.59 0.708 L 0.585 0.714 L 0.609 0.986 L 0.607 0.991 L 0.603 0.994 L 0.599 0.993 L 0.595 0.989 L 0.506 0.731 L 0.499 0.727 L 0.493 0.731 L 0.404 0.989 L 0.4 0.993 L 0.395 0.994 L 0.391 0.991 L 0.39 0.986 L 0.413 0.714 L 0.409 0.707 L 0.401 0.709 L 0.215 0.908 L 0.21 0.91 L 0.206 0.909 L 0.203 0.905 L 0.204 0.9 L 0.335 0.661 L 0.334 0.653 L 0.326 0.651 L 0.075 0.757 L 0.07 0.757 L 0.066 0.754 L 0.065 0.75 L 0.068 0.745 L 0.286 0.58 L 0.288 0.573 L 0.282 0.568 L 0.009 0.563 L 0.004 0.561 L 0.002 0.557 L 0.003 0.552 L 0.008 0.549 L 0.273 0.487 L 0.279 0.481 L 0.275 0.474 L 0.028 0.358 L 0.024 0.355 L 0.024 0.35 L 0.027 0.346 L 0.032 0.345 L 0.3 0.396 L 0.307 0.393 L 0.306 0.385 L 0.128 0.179 L 0.126 0.174 L 0.128 0.17 L 0.132 0.167 L 0.137 0.169 L 0.361 0.324 L 0.369 0.324 L 0.372 0.317 L 0.292 0.056 L 0.293 0.051 L 0.296 0.047 L 0.301 0.047 L 0.305 0.05 L 0.446 0.284 L 0.454 0.287 L 0.454 0.287 Z",
|
||||
"Bun": "M 0.796 0.5 L 0.806 0.503 L 0.85 0.522 L 0.89 0.548 L 0.912 0.569 L 0.932 0.592 L 0.949 0.617 L 0.962 0.643 L 0.973 0.671 L 0.98 0.7 L 0.983 0.731 L 0.983 0.761 L 0.983 0.762 L 0.975 0.81 L 0.958 0.855 L 0.934 0.896 L 0.903 0.931 L 0.866 0.96 L 0.824 0.981 L 0.778 0.995 L 0.729 1 L 0.27 1 L 0.221 0.995 L 0.175 0.981 L 0.133 0.96 L 0.096 0.931 L 0.065 0.896 L 0.041 0.855 L 0.024 0.81 L 0.016 0.762 L 0.016 0.761 L 0.016 0.731 L 0.019 0.7 L 0.026 0.671 L 0.037 0.643 L 0.05 0.617 L 0.067 0.592 L 0.087 0.569 L 0.109 0.548 L 0.149 0.522 L 0.193 0.503 L 0.203 0.5 L 0.193 0.496 L 0.149 0.477 L 0.109 0.451 L 0.087 0.43 L 0.067 0.407 L 0.05 0.382 L 0.037 0.356 L 0.026 0.328 L 0.019 0.299 L 0.016 0.268 L 0.016 0.238 L 0.016 0.237 L 0.024 0.189 L 0.041 0.144 L 0.065 0.103 L 0.096 0.068 L 0.133 0.039 L 0.175 0.018 L 0.221 0.004 L 0.27 0 L 0.729 0 L 0.778 0.004 L 0.824 0.018 L 0.866 0.039 L 0.903 0.068 L 0.934 0.103 L 0.958 0.144 L 0.975 0.189 L 0.983 0.237 L 0.983 0.238 L 0.983 0.268 L 0.98 0.299 L 0.973 0.328 L 0.962 0.356 L 0.949 0.382 L 0.932 0.407 L 0.912 0.43 L 0.89 0.451 L 0.85 0.477 L 0.806 0.496 L 0.796 0.5 L 0.796 0.5 Z",
|
||||
"Burst": "M 0.5 0 L 0.505 0.003 L 0.588 0.152 L 0.592 0.155 L 0.597 0.154 L 0.743 0.067 L 0.749 0.067 L 0.752 0.072 L 0.75 0.243 L 0.752 0.247 L 0.756 0.249 L 0.926 0.247 L 0.932 0.25 L 0.932 0.256 L 0.844 0.403 L 0.844 0.407 L 0.847 0.411 L 0.995 0.494 L 0.998 0.499 L 0.995 0.504 L 0.846 0.588 L 0.844 0.592 L 0.844 0.596 L 0.932 0.742 L 0.932 0.748 L 0.926 0.751 L 0.755 0.749 L 0.751 0.751 L 0.749 0.755 L 0.752 0.926 L 0.749 0.931 L 0.743 0.931 L 0.596 0.844 L 0.591 0.843 L 0.588 0.846 L 0.505 0.995 L 0.499 0.998 L 0.494 0.995 L 0.411 0.846 L 0.407 0.843 L 0.402 0.844 L 0.256 0.931 L 0.25 0.931 L 0.247 0.926 L 0.249 0.755 L 0.247 0.751 L 0.243 0.749 L 0.073 0.751 L 0.067 0.748 L 0.067 0.742 L 0.155 0.595 L 0.155 0.591 L 0.152 0.587 L 0.004 0.504 L 0.001 0.499 L 0.004 0.494 L 0.153 0.41 L 0.155 0.406 L 0.155 0.402 L 0.067 0.256 L 0.067 0.249 L 0.073 0.246 L 0.244 0.249 L 0.248 0.247 L 0.25 0.243 L 0.247 0.072 L 0.25 0.067 L 0.256 0.067 L 0.403 0.154 L 0.408 0.155 L 0.411 0.152 L 0.494 0.003 L 0.5 0 L 0.5 0 Z",
|
||||
"Circle": "M 1 0.5 L 0.998 0.538 L 0.993 0.577 L 0.986 0.615 L 0.975 0.653 L 0.962 0.689 L 0.945 0.725 L 0.926 0.759 L 0.905 0.791 L 0.881 0.821 L 0.854 0.85 L 0.826 0.876 L 0.795 0.901 L 0.763 0.922 L 0.728 0.942 L 0.693 0.958 L 0.657 0.971 L 0.619 0.982 L 0.581 0.989 L 0.543 0.994 L 0.504 0.995 L 0.464 0.994 L 0.426 0.989 L 0.388 0.982 L 0.35 0.971 L 0.314 0.958 L 0.279 0.942 L 0.245 0.922 L 0.212 0.901 L 0.181 0.876 L 0.153 0.85 L 0.126 0.821 L 0.102 0.791 L 0.081 0.759 L 0.062 0.725 L 0.045 0.689 L 0.032 0.653 L 0.021 0.615 L 0.014 0.577 L 0.009 0.538 L 0.008 0.499 L 0.009 0.461 L 0.014 0.422 L 0.021 0.384 L 0.032 0.346 L 0.045 0.31 L 0.062 0.274 L 0.081 0.24 L 0.102 0.208 L 0.126 0.178 L 0.153 0.149 L 0.181 0.123 L 0.212 0.098 L 0.245 0.077 L 0.279 0.057 L 0.314 0.041 L 0.35 0.028 L 0.388 0.017 L 0.426 0.01 L 0.464 0.005 L 0.504 0.004 L 0.543 0.005 L 0.581 0.01 L 0.619 0.017 L 0.657 0.028 L 0.693 0.041 L 0.728 0.057 L 0.763 0.077 L 0.795 0.098 L 0.826 0.123 L 0.854 0.149 L 0.881 0.178 L 0.905 0.208 L 0.926 0.24 L 0.945 0.274 L 0.962 0.31 L 0.975 0.346 L 0.986 0.384 L 0.993 0.422 L 0.998 0.461 L 1 0.5 L 1 0.5 Z",
|
||||
"ClamShell": "M 0.187 0.815 L 0.154 0.79 L 0.129 0.756 L 0.023 0.567 L 0.01 0.534 L 0.005 0.499 L 0.01 0.465 L 0.023 0.432 L 0.128 0.243 L 0.153 0.209 L 0.186 0.184 L 0.224 0.168 L 0.266 0.162 L 0.733 0.162 L 0.774 0.168 L 0.812 0.184 L 0.845 0.209 L 0.87 0.243 L 0.976 0.432 L 0.989 0.465 L 0.994 0.5 L 0.989 0.534 L 0.976 0.567 L 0.871 0.756 L 0.846 0.79 L 0.813 0.815 L 0.775 0.831 L 0.733 0.837 L 0.266 0.837 L 0.225 0.831 L 0.187 0.815 L 0.187 0.815 Z",
|
||||
"Clover4Leaf": "M 0.5 0.098 L 0.514 0.086 L 0.558 0.058 L 0.606 0.039 L 0.655 0.029 L 0.706 0.028 L 0.755 0.036 L 0.803 0.052 L 0.848 0.077 L 0.888 0.111 L 0.922 0.151 L 0.947 0.196 L 0.963 0.244 L 0.971 0.293 L 0.97 0.344 L 0.96 0.393 L 0.941 0.441 L 0.913 0.485 L 0.901 0.5 L 0.913 0.514 L 0.941 0.558 L 0.96 0.606 L 0.97 0.655 L 0.971 0.706 L 0.963 0.755 L 0.947 0.803 L 0.922 0.848 L 0.888 0.888 L 0.848 0.922 L 0.803 0.947 L 0.755 0.963 L 0.706 0.971 L 0.655 0.97 L 0.606 0.96 L 0.558 0.941 L 0.514 0.913 L 0.5 0.901 L 0.485 0.913 L 0.441 0.941 L 0.393 0.96 L 0.344 0.97 L 0.293 0.971 L 0.244 0.963 L 0.196 0.947 L 0.151 0.922 L 0.111 0.888 L 0.077 0.848 L 0.052 0.803 L 0.036 0.755 L 0.028 0.706 L 0.029 0.655 L 0.039 0.606 L 0.058 0.558 L 0.086 0.514 L 0.098 0.5 L 0.086 0.485 L 0.058 0.441 L 0.039 0.393 L 0.029 0.344 L 0.028 0.293 L 0.036 0.244 L 0.052 0.196 L 0.077 0.151 L 0.111 0.111 L 0.151 0.077 L 0.196 0.052 L 0.244 0.036 L 0.293 0.028 L 0.344 0.029 L 0.393 0.039 L 0.441 0.058 L 0.485 0.086 L 0.5 0.098 L 0.5 0.098 Z",
|
||||
"Clover8Leaf": "M 0.499 0.071 L 0.521 0.059 L 0.564 0.043 L 0.607 0.037 L 0.649 0.04 L 0.69 0.053 L 0.726 0.074 L 0.758 0.103 L 0.783 0.139 L 0.799 0.182 L 0.803 0.196 L 0.826 0.204 L 0.868 0.222 L 0.903 0.248 L 0.93 0.281 L 0.95 0.318 L 0.961 0.359 L 0.962 0.402 L 0.954 0.445 L 0.936 0.487 L 0.928 0.499 L 0.94 0.521 L 0.956 0.564 L 0.962 0.607 L 0.959 0.649 L 0.946 0.69 L 0.925 0.726 L 0.896 0.758 L 0.86 0.783 L 0.817 0.799 L 0.803 0.803 L 0.795 0.826 L 0.777 0.868 L 0.751 0.903 L 0.718 0.93 L 0.681 0.95 L 0.64 0.961 L 0.597 0.962 L 0.554 0.954 L 0.512 0.936 L 0.499 0.928 L 0.478 0.94 L 0.435 0.956 L 0.392 0.962 L 0.35 0.959 L 0.309 0.946 L 0.273 0.925 L 0.241 0.896 L 0.216 0.86 L 0.2 0.817 L 0.196 0.803 L 0.173 0.795 L 0.131 0.777 L 0.096 0.751 L 0.069 0.718 L 0.049 0.681 L 0.038 0.64 L 0.037 0.597 L 0.045 0.554 L 0.063 0.512 L 0.071 0.499 L 0.059 0.478 L 0.043 0.435 L 0.037 0.392 L 0.04 0.35 L 0.053 0.309 L 0.074 0.273 L 0.103 0.241 L 0.139 0.216 L 0.182 0.2 L 0.196 0.196 L 0.204 0.173 L 0.222 0.131 L 0.248 0.096 L 0.281 0.069 L 0.318 0.049 L 0.359 0.038 L 0.402 0.037 L 0.445 0.045 L 0.487 0.063 L 0.499 0.071 L 0.499 0.071 Z",
|
||||
"Cookie12Sided": "M 0.5 0.005 L 0.519 0.007 L 0.537 0.012 L 0.554 0.022 L 0.57 0.036 L 0.59 0.053 L 0.615 0.063 L 0.641 0.066 L 0.668 0.062 L 0.688 0.058 L 0.708 0.058 L 0.727 0.062 L 0.744 0.07 L 0.76 0.081 L 0.773 0.096 L 0.783 0.113 L 0.79 0.132 L 0.799 0.157 L 0.815 0.179 L 0.836 0.195 L 0.862 0.204 L 0.881 0.211 L 0.898 0.221 L 0.912 0.234 L 0.924 0.25 L 0.931 0.267 L 0.936 0.286 L 0.936 0.306 L 0.932 0.326 L 0.927 0.352 L 0.931 0.379 L 0.941 0.403 L 0.958 0.424 L 0.972 0.44 L 0.981 0.457 L 0.987 0.475 L 0.989 0.494 L 0.987 0.513 L 0.981 0.532 L 0.972 0.549 L 0.958 0.564 L 0.941 0.585 L 0.931 0.61 L 0.927 0.636 L 0.932 0.663 L 0.936 0.683 L 0.936 0.703 L 0.931 0.722 L 0.924 0.739 L 0.912 0.755 L 0.898 0.768 L 0.881 0.778 L 0.862 0.784 L 0.836 0.794 L 0.815 0.81 L 0.799 0.831 L 0.79 0.857 L 0.783 0.876 L 0.773 0.893 L 0.76 0.907 L 0.744 0.918 L 0.727 0.926 L 0.708 0.931 L 0.688 0.931 L 0.668 0.927 L 0.641 0.922 L 0.615 0.925 L 0.59 0.936 L 0.57 0.953 L 0.554 0.967 L 0.537 0.976 L 0.519 0.982 L 0.499 0.984 L 0.48 0.982 L 0.462 0.976 L 0.445 0.967 L 0.429 0.953 L 0.409 0.936 L 0.384 0.925 L 0.358 0.922 L 0.331 0.927 L 0.311 0.931 L 0.291 0.931 L 0.272 0.926 L 0.255 0.918 L 0.239 0.907 L 0.226 0.893 L 0.216 0.876 L 0.209 0.857 L 0.2 0.831 L 0.184 0.81 L 0.163 0.794 L 0.137 0.784 L 0.118 0.778 L 0.101 0.768 L 0.087 0.755 L 0.075 0.739 L 0.068 0.722 L 0.063 0.703 L 0.063 0.683 L 0.067 0.663 L 0.072 0.636 L 0.068 0.61 L 0.058 0.585 L 0.041 0.564 L 0.027 0.549 L 0.018 0.532 L 0.012 0.513 L 0.01 0.494 L 0.012 0.475 L 0.018 0.457 L 0.027 0.44 L 0.041 0.424 L 0.058 0.403 L 0.068 0.379 L 0.072 0.352 L 0.067 0.326 L 0.063 0.306 L 0.063 0.286 L 0.068 0.267 L 0.075 0.25 L 0.087 0.234 L 0.101 0.221 L 0.118 0.211 L 0.137 0.204 L 0.163 0.195 L 0.184 0.179 L 0.2 0.157 L 0.209 0.132 L 0.216 0.113 L 0.226 0.096 L 0.239 0.081 L 0.255 0.07 L 0.272 0.062 L 0.291 0.058 L 0.311 0.058 L 0.331 0.062 L 0.358 0.066 L 0.384 0.063 L 0.409 0.053 L 0.429 0.036 L 0.445 0.022 L 0.462 0.012 L 0.48 0.007 L 0.5 0.005 L 0.5 0.005 Z",
|
||||
"Cookie4Sided": "M 0.871 0.87 L 0.847 0.892 L 0.819 0.909 L 0.79 0.923 L 0.759 0.932 L 0.726 0.937 L 0.692 0.936 L 0.657 0.93 L 0.621 0.918 L 0.581 0.9 L 0.541 0.888 L 0.5 0.884 L 0.459 0.888 L 0.419 0.901 L 0.378 0.918 L 0.343 0.93 L 0.308 0.936 L 0.274 0.937 L 0.241 0.932 L 0.21 0.923 L 0.18 0.91 L 0.153 0.892 L 0.129 0.871 L 0.108 0.846 L 0.09 0.819 L 0.076 0.79 L 0.067 0.758 L 0.062 0.725 L 0.063 0.691 L 0.069 0.657 L 0.081 0.621 L 0.099 0.581 L 0.111 0.541 L 0.115 0.5 L 0.111 0.458 L 0.099 0.419 L 0.081 0.378 L 0.069 0.343 L 0.063 0.308 L 0.062 0.274 L 0.067 0.241 L 0.076 0.21 L 0.09 0.18 L 0.107 0.153 L 0.128 0.129 L 0.153 0.107 L 0.18 0.09 L 0.209 0.076 L 0.241 0.067 L 0.274 0.062 L 0.308 0.063 L 0.343 0.069 L 0.378 0.081 L 0.419 0.099 L 0.459 0.111 L 0.5 0.115 L 0.541 0.111 L 0.581 0.098 L 0.621 0.081 L 0.656 0.069 L 0.691 0.063 L 0.725 0.062 L 0.758 0.067 L 0.789 0.076 L 0.819 0.089 L 0.846 0.107 L 0.87 0.128 L 0.892 0.153 L 0.909 0.18 L 0.923 0.209 L 0.932 0.241 L 0.937 0.274 L 0.936 0.308 L 0.93 0.342 L 0.918 0.378 L 0.901 0.418 L 0.888 0.458 L 0.884 0.499 L 0.888 0.541 L 0.901 0.58 L 0.918 0.621 L 0.93 0.656 L 0.937 0.691 L 0.937 0.725 L 0.933 0.758 L 0.923 0.789 L 0.91 0.819 L 0.892 0.846 L 0.871 0.87 L 0.871 0.87 Z",
|
||||
"Cookie6Sided": "M 0.716 0.872 L 0.692 0.889 L 0.669 0.908 L 0.668 0.909 L 0.63 0.939 L 0.589 0.96 L 0.545 0.973 L 0.5 0.977 L 0.454 0.972 L 0.41 0.96 L 0.369 0.938 L 0.331 0.909 L 0.309 0.89 L 0.285 0.873 L 0.259 0.86 L 0.231 0.851 L 0.229 0.85 L 0.185 0.832 L 0.145 0.807 L 0.112 0.775 L 0.086 0.738 L 0.067 0.697 L 0.056 0.652 L 0.054 0.606 L 0.061 0.559 L 0.066 0.53 L 0.068 0.501 L 0.067 0.471 L 0.061 0.443 L 0.061 0.441 L 0.054 0.393 L 0.056 0.347 L 0.067 0.302 L 0.086 0.261 L 0.112 0.224 L 0.146 0.192 L 0.185 0.167 L 0.229 0.149 L 0.257 0.14 L 0.283 0.127 L 0.307 0.11 L 0.33 0.091 L 0.331 0.09 L 0.369 0.06 L 0.41 0.039 L 0.454 0.026 L 0.499 0.022 L 0.545 0.027 L 0.589 0.039 L 0.63 0.061 L 0.668 0.09 L 0.69 0.109 L 0.714 0.126 L 0.74 0.139 L 0.768 0.148 L 0.77 0.149 L 0.814 0.167 L 0.854 0.192 L 0.887 0.224 L 0.913 0.261 L 0.932 0.302 L 0.943 0.347 L 0.945 0.393 L 0.938 0.44 L 0.933 0.469 L 0.931 0.498 L 0.932 0.528 L 0.938 0.556 L 0.938 0.558 L 0.945 0.606 L 0.943 0.652 L 0.932 0.697 L 0.913 0.738 L 0.887 0.775 L 0.853 0.807 L 0.814 0.832 L 0.77 0.85 L 0.742 0.859 L 0.716 0.872 L 0.716 0.872 Z",
|
||||
"Cookie7Sided": "M 0.5 0.021 L 0.536 0.025 L 0.571 0.035 L 0.604 0.053 L 0.634 0.077 L 0.659 0.098 L 0.686 0.114 L 0.716 0.125 L 0.748 0.132 L 0.785 0.14 L 0.82 0.155 L 0.85 0.176 L 0.875 0.202 L 0.895 0.233 L 0.909 0.267 L 0.916 0.304 L 0.916 0.342 L 0.915 0.374 L 0.919 0.406 L 0.929 0.436 L 0.944 0.465 L 0.961 0.499 L 0.97 0.536 L 0.973 0.572 L 0.968 0.609 L 0.956 0.643 L 0.938 0.676 L 0.914 0.704 L 0.884 0.728 L 0.858 0.747 L 0.836 0.77 L 0.818 0.797 L 0.805 0.826 L 0.789 0.861 L 0.767 0.891 L 0.739 0.916 L 0.708 0.935 L 0.674 0.947 L 0.637 0.953 L 0.6 0.952 L 0.562 0.943 L 0.531 0.935 L 0.5 0.932 L 0.468 0.935 L 0.437 0.943 L 0.399 0.952 L 0.362 0.953 L 0.325 0.947 L 0.291 0.935 L 0.26 0.916 L 0.232 0.891 L 0.21 0.861 L 0.194 0.826 L 0.181 0.797 L 0.163 0.77 L 0.141 0.747 L 0.115 0.728 L 0.085 0.704 L 0.061 0.676 L 0.043 0.643 L 0.031 0.609 L 0.026 0.572 L 0.029 0.536 L 0.038 0.499 L 0.055 0.465 L 0.07 0.436 L 0.08 0.406 L 0.084 0.374 L 0.083 0.342 L 0.083 0.304 L 0.09 0.267 L 0.104 0.233 L 0.124 0.202 L 0.149 0.176 L 0.179 0.155 L 0.214 0.14 L 0.251 0.132 L 0.283 0.125 L 0.313 0.114 L 0.34 0.098 L 0.365 0.077 L 0.395 0.053 L 0.428 0.035 L 0.463 0.025 L 0.5 0.021 L 0.5 0.021 Z",
|
||||
"Cookie9Sided": "M 0.5 0.014 L 0.527 0.016 L 0.553 0.023 L 0.578 0.036 L 0.601 0.053 L 0.625 0.071 L 0.651 0.083 L 0.68 0.09 L 0.709 0.092 L 0.738 0.094 L 0.765 0.101 L 0.79 0.112 L 0.812 0.128 L 0.832 0.147 L 0.847 0.17 L 0.859 0.195 L 0.865 0.223 L 0.872 0.252 L 0.884 0.278 L 0.901 0.302 L 0.923 0.322 L 0.943 0.342 L 0.96 0.365 L 0.972 0.39 L 0.979 0.416 L 0.981 0.443 L 0.979 0.471 L 0.971 0.497 L 0.958 0.523 L 0.945 0.549 L 0.937 0.578 L 0.935 0.607 L 0.938 0.636 L 0.941 0.665 L 0.939 0.692 L 0.933 0.719 L 0.921 0.744 L 0.905 0.766 L 0.886 0.786 L 0.863 0.801 L 0.836 0.812 L 0.809 0.824 L 0.785 0.841 L 0.764 0.862 L 0.748 0.886 L 0.733 0.91 L 0.713 0.93 L 0.691 0.946 L 0.666 0.958 L 0.64 0.965 L 0.612 0.967 L 0.584 0.964 L 0.557 0.956 L 0.529 0.947 L 0.499 0.945 L 0.47 0.947 L 0.442 0.956 L 0.415 0.964 L 0.387 0.967 L 0.359 0.965 L 0.333 0.958 L 0.308 0.946 L 0.286 0.93 L 0.266 0.91 L 0.251 0.886 L 0.235 0.862 L 0.214 0.841 L 0.19 0.824 L 0.163 0.812 L 0.136 0.801 L 0.113 0.786 L 0.094 0.766 L 0.078 0.744 L 0.066 0.719 L 0.06 0.692 L 0.058 0.665 L 0.061 0.636 L 0.064 0.607 L 0.062 0.578 L 0.054 0.549 L 0.041 0.523 L 0.028 0.497 L 0.02 0.471 L 0.018 0.443 L 0.02 0.416 L 0.027 0.39 L 0.039 0.365 L 0.056 0.342 L 0.076 0.322 L 0.098 0.302 L 0.115 0.278 L 0.127 0.252 L 0.134 0.223 L 0.14 0.195 L 0.152 0.17 L 0.167 0.147 L 0.187 0.128 L 0.209 0.112 L 0.234 0.101 L 0.261 0.094 L 0.29 0.092 L 0.319 0.09 L 0.348 0.083 L 0.374 0.071 L 0.398 0.053 L 0.421 0.036 L 0.446 0.023 L 0.472 0.016 L 0.5 0.014 L 0.5 0.014 Z",
|
||||
"Diamond": "M 0.499 1 L 0.459 0.994 L 0.421 0.977 L 0.402 0.962 L 0.381 0.939 L 0.319 0.861 L 0.117 0.6 L 0.103 0.577 L 0.093 0.554 L 0.086 0.529 L 0.084 0.503 L 0.086 0.478 L 0.093 0.453 L 0.103 0.429 L 0.117 0.407 L 0.319 0.146 L 0.381 0.067 L 0.402 0.044 L 0.421 0.029 L 0.459 0.013 L 0.5 0.007 L 0.54 0.013 L 0.578 0.029 L 0.597 0.044 L 0.618 0.067 L 0.68 0.146 L 0.882 0.407 L 0.896 0.429 L 0.906 0.453 L 0.913 0.478 L 0.915 0.503 L 0.913 0.529 L 0.906 0.554 L 0.896 0.577 L 0.882 0.6 L 0.68 0.861 L 0.618 0.939 L 0.597 0.962 L 0.578 0.977 L 0.54 0.994 L 0.499 1 L 0.499 1 Z",
|
||||
"Fan": "M 0.957 0.955 L 0.926 0.979 L 0.889 0.995 L 0.852 0.999 L 0.788 1 L 0.151 1 L 0.12 0.996 L 0.092 0.988 L 0.066 0.974 L 0.044 0.955 L 0.026 0.933 L 0.012 0.907 L 0.003 0.879 L 0 0.849 L 0 0.149 L 0.003 0.119 L 0.012 0.091 L 0.026 0.065 L 0.044 0.043 L 0.067 0.025 L 0.093 0.012 L 0.121 0.004 L 0.151 0.001 L 0.214 0.003 L 0.293 0.009 L 0.37 0.022 L 0.444 0.042 L 0.515 0.069 L 0.583 0.102 L 0.646 0.142 L 0.706 0.187 L 0.761 0.237 L 0.812 0.292 L 0.857 0.351 L 0.896 0.415 L 0.93 0.483 L 0.957 0.554 L 0.977 0.628 L 0.991 0.704 L 0.997 0.783 L 0.997 0.785 L 0.998 0.849 L 0.995 0.886 L 0.98 0.923 L 0.957 0.955 L 0.957 0.955 Z",
|
||||
"Flower": "M 0.369 0.186 L 0.396 0.107 L 0.407 0.079 L 0.423 0.053 L 0.442 0.03 L 0.465 0.01 L 0.479 0.002 L 0.495 0 L 0.503 0 L 0.519 0.002 L 0.533 0.01 L 0.556 0.03 L 0.575 0.053 L 0.591 0.079 L 0.603 0.107 L 0.629 0.186 L 0.704 0.148 L 0.732 0.137 L 0.761 0.13 L 0.791 0.127 L 0.821 0.129 L 0.837 0.134 L 0.85 0.143 L 0.855 0.148 L 0.865 0.161 L 0.87 0.177 L 0.871 0.207 L 0.869 0.237 L 0.862 0.267 L 0.85 0.295 L 0.813 0.369 L 0.892 0.396 L 0.92 0.407 L 0.946 0.423 L 0.969 0.442 L 0.989 0.465 L 0.997 0.479 L 0.999 0.495 L 0.999 0.503 L 0.997 0.519 L 0.989 0.533 L 0.969 0.556 L 0.946 0.575 L 0.92 0.591 L 0.892 0.603 L 0.813 0.629 L 0.851 0.704 L 0.862 0.732 L 0.869 0.761 L 0.872 0.791 L 0.87 0.821 L 0.865 0.837 L 0.856 0.85 L 0.851 0.855 L 0.838 0.865 L 0.822 0.87 L 0.792 0.871 L 0.762 0.869 L 0.732 0.862 L 0.704 0.85 L 0.63 0.813 L 0.603 0.892 L 0.592 0.92 L 0.576 0.946 L 0.557 0.969 L 0.534 0.989 L 0.52 0.997 L 0.504 0.999 L 0.496 0.999 L 0.48 0.997 L 0.466 0.989 L 0.443 0.969 L 0.424 0.946 L 0.408 0.92 L 0.396 0.892 L 0.37 0.813 L 0.295 0.851 L 0.267 0.862 L 0.238 0.869 L 0.208 0.872 L 0.178 0.87 L 0.162 0.865 L 0.149 0.856 L 0.144 0.851 L 0.134 0.838 L 0.129 0.822 L 0.128 0.792 L 0.13 0.762 L 0.137 0.732 L 0.149 0.704 L 0.186 0.63 L 0.107 0.603 L 0.079 0.592 L 0.053 0.576 L 0.03 0.557 L 0.01 0.534 L 0.002 0.52 L 0 0.504 L 0 0.496 L 0.002 0.48 L 0.01 0.466 L 0.03 0.443 L 0.053 0.424 L 0.079 0.408 L 0.107 0.396 L 0.186 0.37 L 0.148 0.295 L 0.137 0.267 L 0.13 0.238 L 0.127 0.208 L 0.129 0.178 L 0.134 0.162 L 0.143 0.149 L 0.148 0.144 L 0.161 0.134 L 0.177 0.129 L 0.207 0.128 L 0.237 0.13 L 0.267 0.137 L 0.295 0.149 L 0.369 0.186 L 0.369 0.186 Z",
|
||||
"Gem": "M 0.499 0.999 L 0.475 0.998 L 0.445 0.993 L 0.412 0.982 L 0.321 0.942 L 0.136 0.857 L 0.106 0.84 L 0.08 0.82 L 0.058 0.795 L 0.04 0.767 L 0.027 0.737 L 0.018 0.705 L 0.015 0.672 L 0.017 0.638 L 0.059 0.354 L 0.07 0.309 L 0.089 0.268 L 0.117 0.232 L 0.151 0.201 L 0.378 0.039 L 0.406 0.022 L 0.436 0.01 L 0.468 0.002 L 0.501 0 L 0.534 0.002 L 0.566 0.01 L 0.596 0.022 L 0.624 0.04 L 0.85 0.203 L 0.884 0.233 L 0.911 0.27 L 0.931 0.311 L 0.942 0.355 L 0.982 0.64 L 0.984 0.674 L 0.981 0.707 L 0.972 0.739 L 0.959 0.769 L 0.941 0.797 L 0.918 0.821 L 0.892 0.842 L 0.862 0.859 L 0.677 0.943 L 0.586 0.982 L 0.553 0.993 L 0.523 0.998 L 0.499 0.999 L 0.499 0.999 Z",
|
||||
"Ghostish": "M 0.5 0 L 0.548 0.002 L 0.596 0.009 L 0.641 0.021 L 0.685 0.037 L 0.727 0.057 L 0.766 0.081 L 0.803 0.108 L 0.837 0.139 L 0.867 0.173 L 0.895 0.21 L 0.919 0.249 L 0.939 0.291 L 0.955 0.334 L 0.966 0.38 L 0.974 0.427 L 0.976 0.476 L 0.976 0.76 L 0.974 0.786 L 0.969 0.812 L 0.961 0.836 L 0.95 0.858 L 0.936 0.878 L 0.92 0.896 L 0.881 0.926 L 0.837 0.945 L 0.813 0.95 L 0.789 0.953 L 0.764 0.952 L 0.739 0.948 L 0.714 0.94 L 0.69 0.929 L 0.624 0.892 L 0.597 0.88 L 0.569 0.871 L 0.54 0.865 L 0.51 0.863 L 0.489 0.863 L 0.459 0.865 L 0.43 0.871 L 0.402 0.88 L 0.375 0.892 L 0.309 0.929 L 0.285 0.94 L 0.26 0.948 L 0.235 0.952 L 0.21 0.953 L 0.186 0.95 L 0.162 0.945 L 0.118 0.926 L 0.079 0.896 L 0.063 0.878 L 0.049 0.858 L 0.038 0.836 L 0.03 0.812 L 0.025 0.786 L 0.023 0.76 L 0.023 0.476 L 0.025 0.427 L 0.033 0.38 L 0.044 0.334 L 0.06 0.291 L 0.08 0.249 L 0.104 0.21 L 0.132 0.173 L 0.162 0.139 L 0.196 0.108 L 0.233 0.081 L 0.272 0.057 L 0.314 0.037 L 0.358 0.021 L 0.403 0.009 L 0.451 0.002 L 0.5 0 L 0.5 0 Z",
|
||||
"Heart": "M 0.5 0.285 L 0.504 0.283 L 0.619 0.151 L 0.654 0.12 L 0.693 0.097 L 0.736 0.084 L 0.779 0.081 L 0.823 0.087 L 0.865 0.101 L 0.903 0.125 L 0.936 0.159 L 0.957 0.19 L 0.971 0.224 L 0.98 0.259 L 0.983 0.295 L 0.979 0.331 L 0.969 0.367 L 0.954 0.4 L 0.932 0.431 L 0.501 0.944 L 0.5 0.945 L 0.498 0.944 L 0.067 0.431 L 0.045 0.4 L 0.03 0.367 L 0.02 0.331 L 0.016 0.295 L 0.019 0.259 L 0.028 0.224 L 0.042 0.19 L 0.063 0.159 L 0.096 0.125 L 0.134 0.101 L 0.176 0.087 L 0.22 0.081 L 0.263 0.084 L 0.306 0.097 L 0.345 0.12 L 0.38 0.151 L 0.495 0.283 L 0.5 0.285 L 0.5 0.285 Z",
|
||||
"Oval": "M 0.908 0.091 L 0.931 0.118 L 0.951 0.15 L 0.966 0.184 L 0.977 0.222 L 0.983 0.263 L 0.984 0.306 L 0.981 0.35 L 0.973 0.396 L 0.961 0.442 L 0.944 0.489 L 0.923 0.537 L 0.897 0.585 L 0.868 0.631 L 0.835 0.677 L 0.799 0.72 L 0.761 0.761 L 0.72 0.799 L 0.677 0.835 L 0.631 0.868 L 0.585 0.897 L 0.537 0.923 L 0.489 0.944 L 0.442 0.961 L 0.396 0.973 L 0.35 0.981 L 0.306 0.984 L 0.263 0.983 L 0.222 0.977 L 0.184 0.966 L 0.15 0.951 L 0.118 0.931 L 0.091 0.908 L 0.068 0.881 L 0.048 0.849 L 0.033 0.815 L 0.022 0.777 L 0.016 0.736 L 0.015 0.693 L 0.018 0.649 L 0.026 0.603 L 0.038 0.557 L 0.055 0.51 L 0.076 0.462 L 0.102 0.414 L 0.131 0.368 L 0.164 0.322 L 0.2 0.279 L 0.238 0.238 L 0.279 0.2 L 0.322 0.164 L 0.368 0.131 L 0.414 0.102 L 0.462 0.076 L 0.51 0.055 L 0.557 0.038 L 0.603 0.026 L 0.649 0.018 L 0.693 0.015 L 0.736 0.016 L 0.777 0.022 L 0.815 0.033 L 0.849 0.048 L 0.881 0.068 L 0.908 0.091 L 0.908 0.091 Z",
|
||||
"Pentagon": "M 0.499 0.042 L 0.525 0.044 L 0.55 0.05 L 0.573 0.06 L 0.596 0.073 L 0.918 0.3 L 0.938 0.317 L 0.955 0.336 L 0.968 0.358 L 0.977 0.381 L 0.983 0.405 L 0.985 0.43 L 0.983 0.456 L 0.977 0.481 L 0.856 0.844 L 0.846 0.868 L 0.832 0.89 L 0.815 0.909 L 0.796 0.926 L 0.774 0.939 L 0.751 0.949 L 0.726 0.955 L 0.7 0.957 L 0.299 0.957 L 0.273 0.955 L 0.248 0.949 L 0.225 0.939 L 0.203 0.926 L 0.184 0.909 L 0.167 0.89 L 0.153 0.868 L 0.143 0.844 L 0.022 0.481 L 0.016 0.456 L 0.014 0.43 L 0.016 0.405 L 0.022 0.381 L 0.031 0.358 L 0.044 0.336 L 0.061 0.317 L 0.081 0.3 L 0.403 0.073 L 0.426 0.06 L 0.449 0.05 L 0.474 0.044 L 0.499 0.042 L 0.499 0.042 Z",
|
||||
"Pill": "M 0.873 0.126 L 0.919 0.181 L 0.938 0.211 L 0.955 0.243 L 0.969 0.276 L 0.981 0.31 L 0.99 0.346 L 0.995 0.383 L 1 0.428 L 0.997 0.471 L 0.991 0.513 L 0.98 0.554 L 0.966 0.595 L 0.947 0.633 L 0.925 0.67 L 0.9 0.704 L 0.871 0.736 L 0.736 0.871 L 0.704 0.9 L 0.67 0.925 L 0.633 0.947 L 0.595 0.966 L 0.554 0.98 L 0.513 0.991 L 0.471 0.997 L 0.428 1 L 0.383 0.995 L 0.346 0.99 L 0.31 0.981 L 0.276 0.969 L 0.243 0.955 L 0.211 0.938 L 0.181 0.919 L 0.126 0.873 L 0.08 0.818 L 0.061 0.788 L 0.044 0.756 L 0.03 0.723 L 0.018 0.689 L 0.009 0.653 L 0.004 0.616 L 0 0.571 L 0.002 0.528 L 0.008 0.486 L 0.019 0.445 L 0.033 0.404 L 0.052 0.366 L 0.074 0.329 L 0.099 0.295 L 0.128 0.263 L 0.263 0.128 L 0.295 0.099 L 0.329 0.074 L 0.366 0.052 L 0.404 0.033 L 0.445 0.019 L 0.486 0.008 L 0.528 0.002 L 0.571 0 L 0.616 0.004 L 0.653 0.009 L 0.689 0.018 L 0.723 0.03 L 0.756 0.044 L 0.788 0.061 L 0.818 0.08 L 0.873 0.126 L 0.873 0.126 Z",
|
||||
"PixelCircle": "M 0.499 0 L 0.704 0 L 0.704 0.065 L 0.843 0.065 L 0.843 0.148 L 0.926 0.148 L 0.926 0.296 L 1 0.296 L 1 0.704 L 0.926 0.704 L 0.926 0.852 L 0.843 0.852 L 0.843 0.935 L 0.704 0.934 L 0.704 1 L 0.499 1 L 0.295 0.999 L 0.295 0.934 L 0.157 0.935 L 0.156 0.851 L 0.073 0.851 L 0.074 0.704 L 0 0.704 L 0 0.295 L 0.074 0.295 L 0.074 0.148 L 0.157 0.147 L 0.157 0.064 L 0.296 0.065 L 0.295 0 L 0.499 0 L 0.499 0 Z",
|
||||
"PixelTriangle": "M 0.111 0.499 L 0.114 0 L 0.288 0 L 0.288 0.087 L 0.422 0.087 L 0.422 0.17 L 0.561 0.17 L 0.561 0.265 L 0.674 0.265 L 0.676 0.343 L 0.789 0.343 L 0.789 0.438 L 0.888 0.438 L 0.888 0.561 L 0.789 0.561 L 0.789 0.655 L 0.675 0.656 L 0.674 0.735 L 0.561 0.734 L 0.56 0.829 L 0.422 0.829 L 0.422 0.912 L 0.288 0.912 L 0.288 1 L 0.114 1 L 0.111 0.499 L 0.111 0.499 Z",
|
||||
"Puffy": "M 0.5 0.17 L 0.517 0.143 L 0.533 0.126 L 0.554 0.113 L 0.579 0.105 L 0.607 0.103 L 0.634 0.107 L 0.659 0.116 L 0.679 0.129 L 0.694 0.146 L 0.702 0.158 L 0.713 0.18 L 0.718 0.203 L 0.72 0.225 L 0.732 0.21 L 0.748 0.199 L 0.767 0.191 L 0.787 0.186 L 0.809 0.185 L 0.83 0.188 L 0.85 0.195 L 0.868 0.206 L 0.871 0.209 L 0.889 0.225 L 0.902 0.244 L 0.911 0.263 L 0.916 0.284 L 0.917 0.291 L 0.916 0.316 L 0.91 0.341 L 0.897 0.364 L 0.878 0.386 L 0.884 0.386 L 0.908 0.387 L 0.931 0.393 L 0.95 0.403 L 0.966 0.417 L 0.981 0.435 L 0.991 0.455 L 0.997 0.476 L 1 0.497 L 1 0.502 L 0.997 0.523 L 0.991 0.544 L 0.981 0.564 L 0.966 0.582 L 0.95 0.596 L 0.931 0.606 L 0.908 0.612 L 0.884 0.613 L 0.878 0.613 L 0.897 0.635 L 0.91 0.658 L 0.916 0.683 L 0.917 0.708 L 0.916 0.715 L 0.911 0.736 L 0.902 0.755 L 0.889 0.774 L 0.871 0.79 L 0.868 0.793 L 0.85 0.804 L 0.83 0.811 L 0.809 0.814 L 0.787 0.813 L 0.767 0.808 L 0.748 0.8 L 0.732 0.789 L 0.72 0.774 L 0.718 0.796 L 0.713 0.819 L 0.702 0.841 L 0.694 0.853 L 0.679 0.87 L 0.659 0.883 L 0.634 0.892 L 0.607 0.896 L 0.579 0.894 L 0.554 0.886 L 0.533 0.873 L 0.517 0.856 L 0.5 0.829 L 0.482 0.856 L 0.466 0.873 L 0.445 0.886 L 0.42 0.894 L 0.392 0.896 L 0.365 0.892 L 0.34 0.883 L 0.32 0.87 L 0.305 0.853 L 0.297 0.841 L 0.286 0.819 L 0.281 0.796 L 0.279 0.774 L 0.267 0.789 L 0.251 0.8 L 0.232 0.808 L 0.212 0.813 L 0.19 0.814 L 0.169 0.811 L 0.149 0.804 L 0.131 0.793 L 0.128 0.79 L 0.11 0.774 L 0.097 0.755 L 0.088 0.736 L 0.083 0.715 L 0.082 0.708 L 0.083 0.683 L 0.089 0.658 L 0.102 0.635 L 0.121 0.613 L 0.115 0.613 L 0.091 0.612 L 0.068 0.606 L 0.049 0.596 L 0.033 0.582 L 0.018 0.564 L 0.008 0.544 L 0.002 0.523 L 0 0.502 L 0 0.497 L 0.002 0.476 L 0.008 0.455 L 0.018 0.435 L 0.033 0.417 L 0.049 0.403 L 0.068 0.393 L 0.091 0.387 L 0.115 0.386 L 0.121 0.386 L 0.102 0.364 L 0.089 0.341 L 0.083 0.316 L 0.082 0.291 L 0.083 0.284 L 0.088 0.263 L 0.097 0.244 L 0.11 0.225 L 0.128 0.209 L 0.131 0.206 L 0.149 0.195 L 0.169 0.188 L 0.19 0.185 L 0.212 0.186 L 0.232 0.191 L 0.251 0.199 L 0.267 0.21 L 0.279 0.225 L 0.281 0.203 L 0.286 0.18 L 0.297 0.158 L 0.305 0.146 L 0.32 0.129 L 0.34 0.116 L 0.365 0.107 L 0.392 0.103 L 0.42 0.105 L 0.445 0.113 L 0.466 0.126 L 0.482 0.143 L 0.5 0.17 L 0.5 0.17 Z",
|
||||
"PuffyDiamond": "M 0.778 0.221 L 0.8 0.249 L 0.815 0.281 L 0.821 0.318 L 0.818 0.356 L 0.818 0.356 L 0.833 0.354 L 0.865 0.353 L 0.896 0.359 L 0.924 0.372 L 0.949 0.389 L 0.97 0.411 L 0.986 0.438 L 0.996 0.467 L 1 0.499 L 0.996 0.532 L 0.986 0.561 L 0.97 0.588 L 0.949 0.61 L 0.924 0.627 L 0.896 0.64 L 0.865 0.646 L 0.833 0.645 L 0.818 0.643 L 0.818 0.643 L 0.821 0.681 L 0.815 0.718 L 0.8 0.75 L 0.778 0.778 L 0.75 0.8 L 0.718 0.815 L 0.681 0.821 L 0.643 0.818 L 0.643 0.818 L 0.645 0.833 L 0.646 0.865 L 0.64 0.896 L 0.627 0.924 L 0.61 0.949 L 0.588 0.97 L 0.561 0.986 L 0.532 0.996 L 0.499 1 L 0.467 0.996 L 0.438 0.986 L 0.411 0.97 L 0.389 0.949 L 0.372 0.924 L 0.359 0.896 L 0.353 0.865 L 0.354 0.833 L 0.356 0.818 L 0.356 0.818 L 0.318 0.821 L 0.281 0.815 L 0.249 0.8 L 0.221 0.778 L 0.199 0.75 L 0.184 0.718 L 0.178 0.681 L 0.181 0.643 L 0.181 0.642 L 0.166 0.645 L 0.134 0.646 L 0.103 0.64 L 0.075 0.627 L 0.05 0.61 L 0.029 0.588 L 0.013 0.561 L 0.003 0.532 L 0 0.499 L 0.003 0.467 L 0.013 0.438 L 0.029 0.411 L 0.05 0.389 L 0.075 0.372 L 0.103 0.359 L 0.134 0.353 L 0.166 0.354 L 0.181 0.356 L 0.181 0.356 L 0.178 0.318 L 0.184 0.281 L 0.199 0.249 L 0.221 0.221 L 0.249 0.199 L 0.281 0.184 L 0.318 0.178 L 0.356 0.181 L 0.357 0.181 L 0.354 0.166 L 0.353 0.134 L 0.359 0.103 L 0.372 0.075 L 0.389 0.05 L 0.411 0.029 L 0.438 0.013 L 0.467 0.003 L 0.5 0 L 0.532 0.003 L 0.561 0.013 L 0.588 0.029 L 0.61 0.05 L 0.627 0.075 L 0.64 0.103 L 0.646 0.134 L 0.645 0.166 L 0.643 0.181 L 0.643 0.181 L 0.681 0.178 L 0.718 0.184 L 0.75 0.199 L 0.778 0.221 L 0.778 0.221 Z",
|
||||
"SemiCircle": "M 0.969 0.781 L 0.954 0.794 L 0.936 0.804 L 0.916 0.81 L 0.895 0.812 L 0.104 0.812 L 0.083 0.81 L 0.063 0.804 L 0.045 0.794 L 0.03 0.781 L 0.017 0.766 L 0.008 0.748 L 0.002 0.729 L 0 0.708 L 0 0.687 L 0.002 0.636 L 0.01 0.586 L 0.022 0.538 L 0.039 0.492 L 0.06 0.449 L 0.085 0.407 L 0.114 0.369 L 0.146 0.333 L 0.181 0.301 L 0.22 0.272 L 0.261 0.247 L 0.305 0.226 L 0.351 0.209 L 0.399 0.197 L 0.448 0.19 L 0.5 0.187 L 0.551 0.19 L 0.6 0.197 L 0.648 0.209 L 0.694 0.226 L 0.738 0.247 L 0.779 0.272 L 0.818 0.301 L 0.853 0.333 L 0.885 0.369 L 0.914 0.407 L 0.939 0.449 L 0.96 0.492 L 0.977 0.538 L 0.989 0.586 L 0.997 0.636 L 1 0.687 L 1 0.708 L 0.997 0.729 L 0.991 0.748 L 0.982 0.766 L 0.969 0.781 L 0.969 0.781 Z",
|
||||
"Slanted": "M 0.875 0.914 L 0.85 0.933 L 0.832 0.942 L 0.812 0.949 L 0.762 0.958 L 0.698 0.961 L 0.613 0.961 L 0.201 0.96 L 0.185 0.959 L 0.147 0.954 L 0.112 0.942 L 0.08 0.923 L 0.054 0.899 L 0.032 0.87 L 0.017 0.837 L 0.008 0.801 L 0.007 0.762 L 0.009 0.746 L 0.05 0.341 L 0.059 0.257 L 0.068 0.193 L 0.082 0.145 L 0.091 0.125 L 0.102 0.108 L 0.124 0.085 L 0.149 0.066 L 0.167 0.057 L 0.187 0.05 L 0.237 0.041 L 0.301 0.038 L 0.386 0.038 L 0.798 0.039 L 0.814 0.04 L 0.852 0.045 L 0.887 0.057 L 0.919 0.076 L 0.945 0.1 L 0.967 0.129 L 0.982 0.162 L 0.991 0.198 L 0.992 0.237 L 0.99 0.253 L 0.949 0.658 L 0.94 0.742 L 0.931 0.806 L 0.917 0.854 L 0.908 0.874 L 0.897 0.891 L 0.875 0.914 L 0.875 0.914 Z",
|
||||
"SoftBoom": "M 0.733 0.453 L 0.793 0.444 L 0.84 0.439 L 0.887 0.441 L 0.923 0.445 L 0.949 0.451 L 0.974 0.463 L 0.98 0.466 L 0.994 0.48 L 0.999 0.5 L 0.994 0.52 L 0.98 0.535 L 0.974 0.538 L 0.949 0.549 L 0.922 0.555 L 0.887 0.559 L 0.84 0.561 L 0.793 0.556 L 0.733 0.546 L 0.792 0.56 L 0.837 0.574 L 0.88 0.594 L 0.911 0.611 L 0.934 0.627 L 0.952 0.647 L 0.956 0.652 L 0.964 0.671 L 0.961 0.691 L 0.949 0.708 L 0.93 0.716 L 0.923 0.717 L 0.896 0.717 L 0.869 0.713 L 0.835 0.703 L 0.791 0.686 L 0.749 0.664 L 0.698 0.632 L 0.746 0.667 L 0.783 0.698 L 0.815 0.733 L 0.837 0.76 L 0.852 0.783 L 0.861 0.809 L 0.863 0.815 L 0.863 0.836 L 0.853 0.854 L 0.835 0.864 L 0.814 0.864 L 0.808 0.862 L 0.782 0.853 L 0.759 0.838 L 0.732 0.816 L 0.697 0.783 L 0.667 0.747 L 0.632 0.698 L 0.663 0.749 L 0.685 0.791 L 0.702 0.836 L 0.712 0.87 L 0.716 0.897 L 0.715 0.924 L 0.714 0.93 L 0.706 0.949 L 0.69 0.962 L 0.67 0.964 L 0.651 0.957 L 0.646 0.953 L 0.626 0.934 L 0.61 0.911 L 0.593 0.88 L 0.573 0.837 L 0.559 0.792 L 0.546 0.733 L 0.555 0.793 L 0.56 0.84 L 0.558 0.887 L 0.554 0.923 L 0.548 0.949 L 0.536 0.974 L 0.533 0.98 L 0.519 0.994 L 0.499 0.999 L 0.479 0.994 L 0.464 0.98 L 0.461 0.974 L 0.45 0.949 L 0.444 0.922 L 0.44 0.887 L 0.438 0.84 L 0.443 0.793 L 0.453 0.733 L 0.439 0.792 L 0.425 0.837 L 0.405 0.88 L 0.388 0.911 L 0.372 0.934 L 0.352 0.952 L 0.347 0.956 L 0.328 0.964 L 0.308 0.961 L 0.291 0.949 L 0.283 0.93 L 0.282 0.923 L 0.282 0.896 L 0.286 0.869 L 0.296 0.835 L 0.313 0.791 L 0.335 0.749 L 0.367 0.698 L 0.332 0.746 L 0.301 0.783 L 0.266 0.815 L 0.239 0.837 L 0.216 0.852 L 0.19 0.861 L 0.184 0.863 L 0.163 0.863 L 0.145 0.853 L 0.135 0.835 L 0.135 0.814 L 0.137 0.808 L 0.146 0.782 L 0.161 0.759 L 0.183 0.732 L 0.216 0.697 L 0.252 0.667 L 0.301 0.632 L 0.25 0.663 L 0.208 0.685 L 0.163 0.702 L 0.129 0.712 L 0.102 0.716 L 0.075 0.715 L 0.069 0.714 L 0.05 0.706 L 0.037 0.69 L 0.035 0.67 L 0.042 0.651 L 0.046 0.646 L 0.065 0.626 L 0.088 0.61 L 0.119 0.593 L 0.162 0.573 L 0.207 0.559 L 0.266 0.546 L 0.206 0.555 L 0.159 0.56 L 0.112 0.558 L 0.076 0.554 L 0.05 0.548 L 0.025 0.536 L 0.019 0.533 L 0.005 0.519 L 0 0.499 L 0.005 0.479 L 0.019 0.464 L 0.025 0.461 L 0.05 0.45 L 0.077 0.444 L 0.112 0.44 L 0.159 0.438 L 0.206 0.443 L 0.266 0.453 L 0.207 0.439 L 0.162 0.425 L 0.119 0.405 L 0.088 0.388 L 0.065 0.372 L 0.047 0.352 L 0.043 0.347 L 0.035 0.328 L 0.038 0.308 L 0.05 0.291 L 0.069 0.283 L 0.076 0.282 L 0.103 0.282 L 0.13 0.286 L 0.164 0.296 L 0.208 0.313 L 0.25 0.335 L 0.301 0.367 L 0.253 0.332 L 0.216 0.301 L 0.184 0.266 L 0.162 0.239 L 0.147 0.216 L 0.138 0.19 L 0.136 0.184 L 0.136 0.163 L 0.146 0.145 L 0.164 0.135 L 0.185 0.135 L 0.191 0.137 L 0.217 0.146 L 0.24 0.161 L 0.267 0.183 L 0.302 0.216 L 0.332 0.252 L 0.367 0.301 L 0.336 0.25 L 0.314 0.208 L 0.297 0.163 L 0.287 0.129 L 0.283 0.102 L 0.284 0.075 L 0.285 0.069 L 0.293 0.05 L 0.309 0.037 L 0.329 0.035 L 0.348 0.042 L 0.353 0.046 L 0.373 0.065 L 0.389 0.088 L 0.406 0.119 L 0.426 0.162 L 0.44 0.207 L 0.453 0.266 L 0.444 0.206 L 0.439 0.159 L 0.441 0.112 L 0.445 0.076 L 0.451 0.05 L 0.463 0.025 L 0.466 0.019 L 0.48 0.005 L 0.5 0 L 0.52 0.005 L 0.535 0.019 L 0.538 0.025 L 0.549 0.05 L 0.555 0.077 L 0.559 0.112 L 0.561 0.159 L 0.556 0.206 L 0.546 0.266 L 0.56 0.207 L 0.574 0.162 L 0.594 0.119 L 0.611 0.088 L 0.627 0.065 L 0.647 0.047 L 0.652 0.043 L 0.671 0.035 L 0.691 0.038 L 0.708 0.05 L 0.716 0.069 L 0.717 0.076 L 0.717 0.103 L 0.713 0.13 L 0.703 0.164 L 0.686 0.208 L 0.664 0.25 L 0.632 0.301 L 0.667 0.253 L 0.698 0.216 L 0.733 0.184 L 0.76 0.162 L 0.783 0.147 L 0.809 0.138 L 0.815 0.136 L 0.836 0.136 L 0.854 0.146 L 0.864 0.164 L 0.864 0.185 L 0.862 0.191 L 0.853 0.217 L 0.838 0.24 L 0.816 0.267 L 0.783 0.302 L 0.747 0.332 L 0.698 0.367 L 0.749 0.336 L 0.791 0.314 L 0.836 0.297 L 0.87 0.287 L 0.897 0.283 L 0.924 0.284 L 0.93 0.285 L 0.949 0.293 L 0.962 0.309 L 0.964 0.329 L 0.957 0.348 L 0.953 0.353 L 0.934 0.373 L 0.911 0.389 L 0.88 0.406 L 0.837 0.426 L 0.792 0.44 L 0.733 0.453 L 0.733 0.453 Z",
|
||||
"SoftBurst": "M 0.186 0.272 L 0.194 0.256 L 0.196 0.238 L 0.189 0.148 L 0.19 0.134 L 0.194 0.121 L 0.201 0.111 L 0.21 0.102 L 0.221 0.096 L 0.234 0.092 L 0.247 0.092 L 0.26 0.096 L 0.344 0.13 L 0.362 0.134 L 0.38 0.131 L 0.396 0.123 L 0.408 0.109 L 0.455 0.032 L 0.464 0.022 L 0.474 0.014 L 0.486 0.009 L 0.499 0.008 L 0.512 0.009 L 0.524 0.014 L 0.534 0.021 L 0.543 0.032 L 0.591 0.109 L 0.603 0.123 L 0.619 0.131 L 0.637 0.134 L 0.655 0.13 L 0.738 0.095 L 0.751 0.092 L 0.765 0.092 L 0.777 0.095 L 0.788 0.101 L 0.798 0.11 L 0.805 0.121 L 0.809 0.133 L 0.81 0.147 L 0.803 0.237 L 0.805 0.256 L 0.813 0.272 L 0.826 0.284 L 0.842 0.292 L 0.93 0.313 L 0.943 0.318 L 0.954 0.326 L 0.962 0.336 L 0.967 0.347 L 0.97 0.36 L 0.969 0.372 L 0.965 0.385 L 0.957 0.397 L 0.899 0.466 L 0.89 0.482 L 0.887 0.499 L 0.89 0.517 L 0.899 0.533 L 0.957 0.602 L 0.965 0.613 L 0.969 0.626 L 0.97 0.639 L 0.967 0.651 L 0.962 0.663 L 0.954 0.673 L 0.943 0.68 L 0.93 0.685 L 0.842 0.707 L 0.826 0.715 L 0.813 0.727 L 0.805 0.743 L 0.803 0.761 L 0.81 0.851 L 0.809 0.865 L 0.805 0.878 L 0.798 0.888 L 0.789 0.897 L 0.778 0.903 L 0.765 0.907 L 0.752 0.907 L 0.739 0.903 L 0.655 0.869 L 0.637 0.865 L 0.619 0.868 L 0.603 0.876 L 0.591 0.89 L 0.544 0.967 L 0.535 0.977 L 0.525 0.985 L 0.513 0.99 L 0.5 0.991 L 0.487 0.99 L 0.475 0.985 L 0.465 0.978 L 0.456 0.967 L 0.408 0.89 L 0.396 0.876 L 0.38 0.868 L 0.362 0.865 L 0.344 0.869 L 0.261 0.904 L 0.248 0.907 L 0.234 0.907 L 0.222 0.904 L 0.211 0.898 L 0.201 0.889 L 0.194 0.878 L 0.19 0.866 L 0.189 0.852 L 0.196 0.762 L 0.194 0.743 L 0.186 0.727 L 0.173 0.715 L 0.157 0.707 L 0.069 0.686 L 0.056 0.681 L 0.045 0.673 L 0.037 0.663 L 0.032 0.652 L 0.029 0.639 L 0.03 0.627 L 0.034 0.614 L 0.042 0.602 L 0.1 0.533 L 0.109 0.517 L 0.112 0.5 L 0.109 0.482 L 0.1 0.466 L 0.042 0.397 L 0.034 0.386 L 0.03 0.373 L 0.029 0.36 L 0.032 0.348 L 0.037 0.336 L 0.045 0.326 L 0.056 0.319 L 0.069 0.314 L 0.157 0.292 L 0.173 0.284 L 0.186 0.272 L 0.186 0.272 Z",
|
||||
"Square": "M 0.912 0.912 L 0.867 0.948 L 0.816 0.976 L 0.76 0.993 L 0.73 0.998 L 0.7 1 L 0.3 1 L 0.269 0.998 L 0.239 0.993 L 0.183 0.976 L 0.132 0.948 L 0.087 0.912 L 0.051 0.867 L 0.023 0.816 L 0.006 0.76 L 0.001 0.73 L 0 0.7 L 0 0.3 L 0.001 0.269 L 0.006 0.239 L 0.023 0.183 L 0.051 0.132 L 0.087 0.087 L 0.132 0.051 L 0.183 0.023 L 0.239 0.006 L 0.269 0.001 L 0.3 0 L 0.7 0 L 0.73 0.001 L 0.76 0.006 L 0.816 0.023 L 0.867 0.051 L 0.912 0.087 L 0.948 0.132 L 0.976 0.183 L 0.993 0.239 L 0.998 0.269 L 1 0.3 L 1 0.7 L 0.998 0.73 L 0.993 0.76 L 0.976 0.816 L 0.948 0.867 L 0.912 0.912 L 0.912 0.912 Z",
|
||||
"Sunny": "M 0.996 0.5 L 0.992 0.526 L 0.978 0.55 L 0.902 0.639 L 0.889 0.66 L 0.884 0.683 L 0.874 0.8 L 0.867 0.827 L 0.852 0.849 L 0.83 0.864 L 0.803 0.871 L 0.686 0.881 L 0.663 0.886 L 0.642 0.899 L 0.553 0.975 L 0.529 0.989 L 0.503 0.993 L 0.476 0.989 L 0.452 0.975 L 0.363 0.899 L 0.342 0.886 L 0.319 0.881 L 0.202 0.871 L 0.175 0.864 L 0.153 0.849 L 0.138 0.827 L 0.131 0.8 L 0.122 0.683 L 0.116 0.66 L 0.103 0.639 L 0.027 0.55 L 0.013 0.526 L 0.009 0.499 L 0.013 0.473 L 0.027 0.449 L 0.103 0.36 L 0.116 0.339 L 0.122 0.316 L 0.131 0.199 L 0.138 0.172 L 0.153 0.15 L 0.175 0.135 L 0.202 0.128 L 0.319 0.118 L 0.342 0.113 L 0.363 0.1 L 0.452 0.024 L 0.476 0.01 L 0.503 0.006 L 0.529 0.01 L 0.553 0.024 L 0.642 0.1 L 0.663 0.113 L 0.686 0.118 L 0.803 0.128 L 0.83 0.135 L 0.852 0.15 L 0.867 0.172 L 0.874 0.199 L 0.884 0.316 L 0.889 0.339 L 0.902 0.36 L 0.978 0.449 L 0.992 0.473 L 0.996 0.5 L 0.996 0.5 Z",
|
||||
"Triangle": "M 0.5 0.077 L 0.532 0.081 L 0.563 0.094 L 0.59 0.114 L 0.612 0.142 L 0.95 0.727 L 0.963 0.76 L 0.967 0.794 L 0.962 0.827 L 0.95 0.857 L 0.93 0.883 L 0.904 0.903 L 0.873 0.917 L 0.837 0.922 L 0.162 0.922 L 0.126 0.917 L 0.095 0.903 L 0.069 0.883 L 0.049 0.857 L 0.037 0.827 L 0.032 0.794 L 0.036 0.76 L 0.049 0.727 L 0.387 0.142 L 0.409 0.114 L 0.436 0.094 L 0.467 0.081 L 0.5 0.077 L 0.5 0.077 Z",
|
||||
"VerySunny": "M 0.5 0.993 L 0.479 0.99 L 0.46 0.983 L 0.443 0.97 L 0.429 0.953 L 0.393 0.893 L 0.376 0.873 L 0.353 0.859 L 0.328 0.853 L 0.302 0.855 L 0.234 0.872 L 0.212 0.875 L 0.191 0.871 L 0.172 0.863 L 0.155 0.85 L 0.143 0.834 L 0.134 0.815 L 0.131 0.794 L 0.134 0.772 L 0.151 0.704 L 0.153 0.678 L 0.147 0.652 L 0.133 0.63 L 0.113 0.613 L 0.053 0.577 L 0.036 0.563 L 0.023 0.546 L 0.015 0.527 L 0.013 0.506 L 0.015 0.486 L 0.023 0.466 L 0.036 0.449 L 0.053 0.435 L 0.113 0.399 L 0.133 0.382 L 0.147 0.36 L 0.153 0.335 L 0.151 0.308 L 0.134 0.241 L 0.131 0.218 L 0.134 0.197 L 0.143 0.178 L 0.155 0.162 L 0.172 0.149 L 0.191 0.141 L 0.212 0.138 L 0.234 0.14 L 0.302 0.157 L 0.328 0.16 L 0.353 0.153 L 0.375 0.14 L 0.393 0.12 L 0.428 0.06 L 0.442 0.042 L 0.46 0.03 L 0.479 0.022 L 0.499 0.02 L 0.52 0.022 L 0.539 0.03 L 0.556 0.042 L 0.57 0.06 L 0.606 0.12 L 0.623 0.14 L 0.646 0.153 L 0.671 0.16 L 0.697 0.157 L 0.765 0.14 L 0.787 0.138 L 0.808 0.141 L 0.827 0.149 L 0.844 0.162 L 0.856 0.178 L 0.865 0.197 L 0.868 0.218 L 0.865 0.241 L 0.848 0.308 L 0.846 0.335 L 0.852 0.36 L 0.866 0.382 L 0.886 0.399 L 0.946 0.435 L 0.963 0.449 L 0.976 0.466 L 0.984 0.486 L 0.986 0.506 L 0.984 0.527 L 0.976 0.546 L 0.963 0.563 L 0.946 0.577 L 0.886 0.613 L 0.866 0.63 L 0.852 0.652 L 0.846 0.678 L 0.848 0.704 L 0.865 0.772 L 0.868 0.794 L 0.865 0.815 L 0.856 0.834 L 0.844 0.85 L 0.827 0.863 L 0.808 0.871 L 0.787 0.875 L 0.765 0.872 L 0.697 0.855 L 0.671 0.853 L 0.646 0.859 L 0.624 0.872 L 0.606 0.893 L 0.571 0.953 L 0.557 0.97 L 0.539 0.983 L 0.52 0.99 L 0.5 0.993 L 0.5 0.993 Z",
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Material expressive shape SVG paths — generated from Compose MaterialShapes. */
|
||||
|
||||
export { MATERIAL_SHAPE_PATHS } from "./materialShapes.generated";
|
||||
|
||||
import { MATERIAL_SHAPE_PATHS } from "./materialShapes.generated";
|
||||
|
||||
export type MaterialShapeName = keyof typeof MATERIAL_SHAPE_PATHS;
|
||||
|
||||
export function getMaterialShapePath(name: string): string {
|
||||
return MATERIAL_SHAPE_PATHS[name] ?? MATERIAL_SHAPE_PATHS.Circle;
|
||||
}
|
||||
|
||||
interface PathBounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface PathUnitSquareFit {
|
||||
transform: string;
|
||||
}
|
||||
|
||||
const pathFitCache = new Map<string, PathUnitSquareFit>();
|
||||
|
||||
/**
|
||||
* Parses M/L/Z path commands and returns axis-aligned bounds of all vertices.
|
||||
* Generated Material shape paths use only these commands.
|
||||
*/
|
||||
function computePathBounds(pathD: string): PathBounds {
|
||||
const tokens = pathD.trim().match(/[MLZmlz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?/g);
|
||||
if (!tokens?.length) {
|
||||
return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
|
||||
}
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
let curX = 0;
|
||||
let curY = 0;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let cmd = "";
|
||||
let i = 0;
|
||||
|
||||
const extend = (x: number, y: number) => {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
curX = x;
|
||||
curY = y;
|
||||
};
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (/^[A-Za-z]$/.test(token)) {
|
||||
cmd = token;
|
||||
i++;
|
||||
if (cmd === "Z" || cmd === "z") {
|
||||
extend(startX, startY);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = Number(tokens[i++]);
|
||||
const y = Number(tokens[i++]);
|
||||
|
||||
let absX = x;
|
||||
let absY = y;
|
||||
switch (cmd) {
|
||||
case "M":
|
||||
absX = x;
|
||||
absY = y;
|
||||
startX = absX;
|
||||
startY = absY;
|
||||
cmd = "L";
|
||||
break;
|
||||
case "m":
|
||||
absX = curX + x;
|
||||
absY = curY + y;
|
||||
startX = absX;
|
||||
startY = absY;
|
||||
cmd = "l";
|
||||
break;
|
||||
case "L":
|
||||
absX = x;
|
||||
absY = y;
|
||||
break;
|
||||
case "l":
|
||||
absX = curX + x;
|
||||
absY = curY + y;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
extend(absX, absY);
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a normalized Material shape path to fill viewBox `0 0 1 1` edge-to-edge.
|
||||
*/
|
||||
export function fitPathToUnitSquare(pathD: string): PathUnitSquareFit {
|
||||
const cached = pathFitCache.get(pathD);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const { minX, minY, maxX, maxY } = computePathBounds(pathD);
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
const fallback = { transform: "" };
|
||||
pathFitCache.set(pathD, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const sx = 1 / width;
|
||||
const sy = 1 / height;
|
||||
const fit: PathUnitSquareFit = {
|
||||
transform: `scale(${sx}, ${sy}) translate(${-minX}, ${-minY})`,
|
||||
};
|
||||
pathFitCache.set(pathD, fit);
|
||||
return fit;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @fileoverview Online status manager for real-time user status tracking
|
||||
* @description Handles subscription to user online statuses via WebSocket
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
StatusUpdateWebSocketMessage,
|
||||
SubscribeStatusWebSocketMessage,
|
||||
UnsubscribeStatusWebSocketMessage
|
||||
} from "./types";
|
||||
import { usePresenceStore } from "@/state/presence";
|
||||
|
||||
export interface UserStatus {
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages online status subscriptions and updates
|
||||
*/
|
||||
export class OnlineStatusManager {
|
||||
private subscribedUsers: Set<number> = new Set();
|
||||
private statusCache: Map<number, UserStatus> = new Map();
|
||||
private authToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the authentication token for WebSocket requests
|
||||
*/
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a user's online status
|
||||
*/
|
||||
async subscribe(userId: number): Promise<void> {
|
||||
if (!this.authToken || this.subscribedUsers.has(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message: SubscribeStatusWebSocketMessage = {
|
||||
type: "subscribeStatus",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
userId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.subscribedUsers.add(userId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to subscribe to user ${userId} status:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a user's online status
|
||||
*/
|
||||
async unsubscribe(userId: number): Promise<void> {
|
||||
if (!this.authToken || !this.subscribedUsers.has(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message: UnsubscribeStatusWebSocketMessage = {
|
||||
type: "unsubscribeStatus",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
userId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.subscribedUsers.delete(userId);
|
||||
this.statusCache.delete(userId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to unsubscribe from user ${userId} status:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming status update from WebSocket
|
||||
*/
|
||||
handleStatusUpdate(message: StatusUpdateWebSocketMessage): void {
|
||||
const { userId, online, lastSeen } = message.data;
|
||||
this.statusCache.set(userId, { online, lastSeen });
|
||||
|
||||
// Update the global state
|
||||
const { updateOnlineStatus } = usePresenceStore.getState();
|
||||
updateOnlineStatus(userId, online, lastSeen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached status for a user
|
||||
*/
|
||||
getStatus(userId: number): UserStatus | undefined {
|
||||
return this.statusCache.get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached statuses
|
||||
*/
|
||||
getAllStatuses(): Map<number, UserStatus> {
|
||||
return new Map(this.statusCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscribed to a user's status
|
||||
*/
|
||||
isSubscribed(userId: number): boolean {
|
||||
return this.subscribedUsers.has(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscribed user IDs
|
||||
*/
|
||||
getSubscribedUsers(): Set<number> {
|
||||
return new Set(this.subscribedUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all users and clear cache
|
||||
*/
|
||||
async unsubscribeAll(): Promise<void> {
|
||||
const unsubscribePromises = Array.from(this.subscribedUsers).map(userId =>
|
||||
this.unsubscribe(userId)
|
||||
);
|
||||
await Promise.all(unsubscribePromises);
|
||||
this.subscribedUsers.clear();
|
||||
this.statusCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when component unmounts
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const onlineStatusManager = new OnlineStatusManager();
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @fileoverview Utility functions for handling profile links
|
||||
* @description Functions to parse and handle profile links in markdown content.
|
||||
* Supports two formats:
|
||||
* - fromchat.ru/@username (e.g., fromchat.ru/@john_doe)
|
||||
* - fromchat.ru/?u=<userId> (e.g., fromchat.ru/?u=123)
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
|
||||
/**
|
||||
* Parses a profile link URL and extracts user information
|
||||
* @param url - The URL to parse
|
||||
* @returns Object with user ID and username if it's a valid profile link, null otherwise
|
||||
*/
|
||||
export function parseProfileLink(url: string = location.pathname): { userId?: number; username?: string } | null {
|
||||
try {
|
||||
let host: string = url.startsWith("@") ? "" : !url.startsWith("/") ? "https://fromchat.ru/" : "/";
|
||||
|
||||
// Handle fromchat.ru/@username format
|
||||
const usernameMatch = url.match(new RegExp(`${escapeStringRegexp(host)}@([a-zA-Z0-9_-]+)`));
|
||||
if (usernameMatch) {
|
||||
return { username: usernameMatch[1] };
|
||||
}
|
||||
|
||||
// Handle fromchat.ru/?u=<userId> format
|
||||
const userIdMatch = url.match(new RegExp(`${escapeStringRegexp(host)}\\?u=(\\d+)`));
|
||||
if (userIdMatch) {
|
||||
return { userId: Number(userIdMatch[1]) };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error parsing profile link:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import api from "@/core/api";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
|
||||
import serviceWorker from "./service-worker?worker&url";
|
||||
import logo from "@/images/logo.svg";
|
||||
|
||||
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 {
|
||||
await api.push.subscription.subscribe(subscriptionData, token);
|
||||
return true;
|
||||
} 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,
|
||||
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;
|
||||
}
|
||||
|
||||
// If subscription doesn't exist, try to get it from the push manager or create a new one
|
||||
if (!subscription && registration) {
|
||||
try {
|
||||
// Try to get existing subscription first
|
||||
subscription = await registration.pushManager.getSubscription();
|
||||
// If no existing subscription, create a new one
|
||||
if (!subscription) {
|
||||
subscription = await subscribeToWebPush();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get or create subscription:", error);
|
||||
subscription = await subscribeToWebPush();
|
||||
}
|
||||
}
|
||||
|
||||
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,91 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import logo from "@/images/logo.svg";
|
||||
|
||||
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,
|
||||
badge: logo,
|
||||
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
+643
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* @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
|
||||
|
||||
export type VerificationStatus = "verified" | "warning" | "blocked" | "none";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
user_id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
is_edited: boolean;
|
||||
timestamp: string;
|
||||
profile_picture?: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
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;
|
||||
display_name: string;
|
||||
admin?: boolean;
|
||||
bio?: string;
|
||||
profile_picture: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
suspended?: boolean;
|
||||
suspension_reason?: string | null;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
display_name: string;
|
||||
profile_picture?: string;
|
||||
bio?: string;
|
||||
online: boolean;
|
||||
last_seen: string;
|
||||
created_at: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
deleted?: boolean;
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
// ----------
|
||||
// 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;
|
||||
display_name: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
export interface UploadPublicKeyRequest {
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export interface SendDMRequest {
|
||||
recipientId: number;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: 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_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export interface DmEnvelope extends BaseDmEnvelope {
|
||||
id: number;
|
||||
senderId: number;
|
||||
files?: DmFile[];
|
||||
timestamp: string;
|
||||
reactions?: Reaction[];
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
export interface DmFile {
|
||||
name: string;
|
||||
id: number;
|
||||
path: string;
|
||||
dm_envelope_id?: number;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: 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;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: string;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// WebSocket message types
|
||||
// -----------------------
|
||||
|
||||
// Utils
|
||||
export interface DMEditPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
timestamp: 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;
|
||||
}
|
||||
|
||||
// Call types
|
||||
export interface CallSignalingData {
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
}
|
||||
|
||||
export interface CallInviteData {
|
||||
fromUsername: string;
|
||||
}
|
||||
|
||||
export interface CallInviteMessageData {
|
||||
fromUsername: string;
|
||||
}
|
||||
|
||||
export type CallSignalingDataType = "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject" | "call_session_key" | "call_signaling" | "call_video_toggle" | "call_screen_share_toggle";
|
||||
|
||||
export interface CallSignalingMessage extends WebSocketMessage {
|
||||
type: CallSignalingDataType;
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
sessionKeyHash?: string;
|
||||
data: CallSignalingMessageData;
|
||||
}
|
||||
|
||||
export type CallSignalingMessageData =
|
||||
| CallInviteMessageData
|
||||
| CallAcceptData
|
||||
| CallRejectData
|
||||
| CallOfferData
|
||||
| CallAnswerData
|
||||
| CallIceCandidateData
|
||||
| CallEndData
|
||||
| CallSessionKeyData
|
||||
| CallVideoToggleData
|
||||
| CallScreenShareToggleData;
|
||||
|
||||
export interface CallAcceptData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallRejectData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallOfferData extends RTCSessionDescriptionInit {
|
||||
}
|
||||
|
||||
export interface CallAnswerData extends RTCSessionDescriptionInit {
|
||||
}
|
||||
|
||||
export interface CallIceCandidateData extends RTCIceCandidateInit {
|
||||
}
|
||||
|
||||
export interface CallEndData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallSessionKeyData {
|
||||
wrappedSessionKey?: WrappedSessionKeyPayload;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessageData {
|
||||
fromUserId: number;
|
||||
data: CallVideoToggleData;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleMessageData {
|
||||
fromUserId: number;
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
export interface WrappedSessionKeyPayload {
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrapped: string;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||
type: "call_video_toggle";
|
||||
data: CallVideoToggleData;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleMessage extends CallSignalingMessage {
|
||||
type: "call_screen_share_toggle";
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// Online Status & Typing WebSocket Messages
|
||||
// -----------
|
||||
|
||||
export interface StatusUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "statusUpdate";
|
||||
data: {
|
||||
userId: number;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "subscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnsubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "unsubscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "typing";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Request types for sending typing/status messages
|
||||
export interface TypingRequest extends WebSocketMessage {
|
||||
type: "typing";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface StopTypingRequest extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface DmTypingRequest extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingRequest extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// -------------
|
||||
// Utility types
|
||||
// -------------
|
||||
|
||||
export type Override<TBase, TExt> = Omit<TBase, keyof TExt> & TExt;
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @fileoverview Typing indicator manager for real-time typing status
|
||||
* @description Handles typing indicators for public chat and DMs via WebSocket
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
TypingWebSocketMessage,
|
||||
StopTypingWebSocketMessage,
|
||||
DmTypingWebSocketMessage,
|
||||
StopDmTypingWebSocketMessage,
|
||||
TypingRequest,
|
||||
StopTypingRequest,
|
||||
DmTypingRequest,
|
||||
StopDmTypingRequest
|
||||
} from "./types";
|
||||
import { usePresenceStore } from "@/state/presence";
|
||||
|
||||
/**
|
||||
* Manages typing indicators for public chat and DMs
|
||||
*/
|
||||
export class TypingManager {
|
||||
private authToken: string | null = null;
|
||||
private typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
private readonly TYPING_TIMEOUT = 3000; // 3 seconds
|
||||
|
||||
/**
|
||||
* Set the authentication token for WebSocket requests
|
||||
*/
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send typing indicator for public chat
|
||||
*/
|
||||
async sendTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: TypingRequest = {
|
||||
type: "typing",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopTyping("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stop typing indicator for public chat
|
||||
*/
|
||||
async sendStopTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopTypingRequest = {
|
||||
type: "stopTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send typing indicator for DM
|
||||
*/
|
||||
async sendDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: DmTypingRequest = {
|
||||
type: "dmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopDmTyping(recipientId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stop typing indicator for DM
|
||||
*/
|
||||
async sendStopDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopDmTypingRequest = {
|
||||
type: "stopDmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop DM typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming typing indicator from WebSocket
|
||||
*/
|
||||
handleTyping(message: TypingWebSocketMessage): void {
|
||||
const { addTypingUser } = usePresenceStore.getState();
|
||||
addTypingUser(message.data.userId, message.data.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stop typing indicator from WebSocket
|
||||
*/
|
||||
handleStopTyping(message: StopTypingWebSocketMessage): void {
|
||||
const { removeTypingUser } = usePresenceStore.getState();
|
||||
removeTypingUser(message.data.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming DM typing indicator from WebSocket
|
||||
*/
|
||||
handleDmTyping(message: DmTypingWebSocketMessage): void {
|
||||
const { setDmTypingUser } = usePresenceStore.getState();
|
||||
setDmTypingUser(message.data.userId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stop DM typing indicator from WebSocket
|
||||
*/
|
||||
handleStopDmTyping(message: StopDmTypingWebSocketMessage): void {
|
||||
const { setDmTypingUser } = usePresenceStore.getState();
|
||||
setDmTypingUser(message.data.userId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic stop typing after timeout
|
||||
*/
|
||||
private scheduleStopTyping(context: string): void {
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (context === "public") {
|
||||
await this.sendStopTyping();
|
||||
}
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic stop DM typing after timeout
|
||||
*/
|
||||
private scheduleStopDmTyping(recipientId: number): void {
|
||||
const context = `dm_${recipientId}`;
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
await this.sendStopDmTyping(recipientId);
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stop typing timeout
|
||||
*/
|
||||
private clearStopTypingTimeout(context: string): void {
|
||||
const timeout = this.typingTimeouts.get(context);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.typingTimeouts.delete(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop typing for public chat (called when message is sent)
|
||||
*/
|
||||
async stopTypingOnMessage(): Promise<void> {
|
||||
this.clearStopTypingTimeout("public");
|
||||
await this.sendStopTyping();
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop DM typing (called when message is sent)
|
||||
*/
|
||||
async stopDmTypingOnMessage(recipientId: number): Promise<void> {
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
await this.sendStopDmTyping(recipientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all timeouts
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.typingTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
this.typingTimeouts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const typingManager = new TypingManager();
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @fileoverview Update Manager for Telegram-like update system
|
||||
* @description Handles update sequence numbers, batching, and gap detection
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { openDB, type IDBPDatabase } from "idb";
|
||||
import type { WebSocketCredentials, WebSocketMessage } from "./types";
|
||||
|
||||
interface UpdateMessage<T = any> {
|
||||
type: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface BatchedUpdatesMessage {
|
||||
type: "updates";
|
||||
seq: number;
|
||||
updates: UpdateMessage[];
|
||||
}
|
||||
|
||||
const DB_NAME = "fromchat-updates";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "lastSequence";
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
|
||||
/**
|
||||
* Initialize IndexedDB for storing last sequence number
|
||||
*/
|
||||
async function initDB(): Promise<IDBPDatabase> {
|
||||
if (db) return db;
|
||||
|
||||
db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last received sequence number from IndexedDB
|
||||
*/
|
||||
export async function getLastSequence(): Promise<number> {
|
||||
try {
|
||||
return (await initDB())
|
||||
.transaction(STORE_NAME, "readonly")
|
||||
.objectStore(STORE_NAME)
|
||||
.get("lastSeq") || 0;
|
||||
} catch (error) {
|
||||
console.error("Failed to get last sequence:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the last received sequence number in IndexedDB
|
||||
*/
|
||||
export async function setLastSequence(seq: number): Promise<void> {
|
||||
try {
|
||||
(await initDB()).transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(seq, "lastSeq");
|
||||
} catch (error) {
|
||||
console.error("Failed to set last sequence:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batched updates message
|
||||
* @param message - The batched updates message from the server
|
||||
* @param handler - Function to handle individual updates
|
||||
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
|
||||
*/
|
||||
export async function processBatchedUpdates(
|
||||
message: BatchedUpdatesMessage,
|
||||
handler: (update: UpdateMessage) => void,
|
||||
requestMissedFn?: (lastSeq: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
const { seq, updates } = message;
|
||||
const lastSeq = await getLastSequence();
|
||||
|
||||
// Check for gap
|
||||
if (seq !== lastSeq + 1 && lastSeq > 0) {
|
||||
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
|
||||
|
||||
// Request missing updates if function provided
|
||||
if (requestMissedFn) {
|
||||
try {
|
||||
await requestMissedFn(lastSeq);
|
||||
} catch (error) {
|
||||
console.error("Failed to request missed updates for gap:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process all updates in the batch
|
||||
for (const update of updates) {
|
||||
handler(update);
|
||||
}
|
||||
|
||||
// Update last sequence number
|
||||
await setLastSequence(seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request missed updates from the server
|
||||
* @param lastSeq - The last sequence number we received
|
||||
* @param requestFn - Function to send the request to the server
|
||||
* @param credentials - Optional WebSocket credentials for authentication
|
||||
*/
|
||||
export async function requestMissedUpdates(
|
||||
lastSeq: number,
|
||||
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
|
||||
credentials?: WebSocketCredentials
|
||||
): Promise<void> {
|
||||
if (lastSeq > 0) {
|
||||
await requestFn({
|
||||
type: "getUpdates",
|
||||
data: { lastSeq },
|
||||
credentials
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseApiTimestamp } from "@/utils/utils";
|
||||
|
||||
const DELETED_USERNAME_PREFIX = "#deleted";
|
||||
|
||||
export function isDeletedUser(user: { deleted?: boolean }): boolean {
|
||||
return Boolean(user.deleted);
|
||||
}
|
||||
|
||||
export function isSuspendedUser(user: { suspended?: boolean; deleted?: boolean }): boolean {
|
||||
return Boolean(user.suspended) && !user.deleted;
|
||||
}
|
||||
|
||||
export function isDeletedAccountUsername(username: string | undefined | null): boolean {
|
||||
return Boolean(username?.startsWith(DELETED_USERNAME_PREFIX));
|
||||
}
|
||||
|
||||
export function isDeletedPeer(user: {
|
||||
id?: number;
|
||||
deleted?: boolean;
|
||||
username?: string | null;
|
||||
}): boolean {
|
||||
return isDeletedUser(user) || isDeletedAccountUsername(user.username);
|
||||
}
|
||||
|
||||
export const DELETED_ACCOUNT_LABEL = "Deleted account";
|
||||
|
||||
export function deletedUserLabel(): string {
|
||||
return DELETED_ACCOUNT_LABEL;
|
||||
}
|
||||
|
||||
export function displayNameForUser(user: {
|
||||
id?: number;
|
||||
display_name?: string | null;
|
||||
username?: string | null;
|
||||
deleted?: boolean;
|
||||
}): string {
|
||||
if (isDeletedPeer(user)) {
|
||||
return deletedUserLabel();
|
||||
}
|
||||
return user.display_name?.trim() || user.username?.trim() || "";
|
||||
}
|
||||
|
||||
export function isEpochLastSeen(lastSeen: string | undefined | null): boolean {
|
||||
if (!lastSeen) return false;
|
||||
const time = parseApiTimestamp(lastSeen).getTime();
|
||||
return !Number.isNaN(time) && time <= 0;
|
||||
}
|
||||
|
||||
export function formatDeletedUserLastSeen(): string {
|
||||
return "был(а) давно";
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @fileoverview WebSocket connection management for real-time chat
|
||||
* @description Handles WebSocket connections, message processing, and auto-reconnection
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { getChatWebSocketUrl } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { delay } from "@/utils/utils";
|
||||
import { CallSignalingHandler } from "./calls/signaling";
|
||||
import { onlineStatusManager } from "./onlineStatusManager";
|
||||
import { typingManager } from "./typingManager";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
|
||||
import { getAuthToken } from "@/core/api/user/auth";
|
||||
|
||||
interface HttpError extends Error {
|
||||
status?: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection to the chat server
|
||||
* @returns {WebSocket} New WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function create(): WebSocket {
|
||||
return new WebSocket(getChatWebSocketUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Call signaling handler
|
||||
*/
|
||||
let callSignalingHandler: CallSignalingHandler | null = null;
|
||||
|
||||
/**
|
||||
* Reconnection state
|
||||
*/
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_DELAY = 30000; // 30 seconds max delay
|
||||
const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
|
||||
let isReconnecting = false;
|
||||
let messageHandler: ((e: MessageEvent) => void) | null = null;
|
||||
let errorHandler: ((e: Event) => void) | null = null;
|
||||
let closeHandler: ((e: CloseEvent) => void) | null = null;
|
||||
let openHandler: ((e: Event) => 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the call signaling handler
|
||||
* @param handler - Call signaling handler instance
|
||||
*/
|
||||
export function setCallSignalingHandler(handler: CallSignalingHandler | null): void {
|
||||
callSignalingHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all event listeners from the current WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function cleanupWebSocket(): void {
|
||||
if (websocket) {
|
||||
if (messageHandler) {
|
||||
websocket.removeEventListener("message", messageHandler);
|
||||
}
|
||||
if (errorHandler) {
|
||||
websocket.removeEventListener("error", errorHandler);
|
||||
}
|
||||
if (closeHandler) {
|
||||
websocket.removeEventListener("close", closeHandler);
|
||||
}
|
||||
if (openHandler) {
|
||||
websocket.removeEventListener("open", openHandler);
|
||||
}
|
||||
|
||||
// Close if still connected
|
||||
if (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) {
|
||||
try {
|
||||
websocket.close();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
* @param attempt - Current reconnection attempt number
|
||||
* @returns Delay in milliseconds
|
||||
* @private
|
||||
*/
|
||||
function getReconnectDelay(attempt: number): number {
|
||||
const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt);
|
||||
return Math.min(delay, MAX_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket reconnection with exponential backoff
|
||||
* @private
|
||||
*/
|
||||
async function reconnect(): Promise<void> {
|
||||
if (isReconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isReconnecting = true;
|
||||
|
||||
// Clean up old connection
|
||||
cleanupWebSocket();
|
||||
|
||||
const delayMs = getReconnectDelay(reconnectAttempts);
|
||||
reconnectAttempts++;
|
||||
|
||||
await delay(delayMs);
|
||||
|
||||
try {
|
||||
websocket = create();
|
||||
setupEventHandlers();
|
||||
} catch (error) {
|
||||
// If creation fails, try again
|
||||
isReconnecting = false;
|
||||
reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for the WebSocket connection
|
||||
* @private
|
||||
*/
|
||||
function setupEventHandlers(): void {
|
||||
// Message handler
|
||||
messageHandler = async (e: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(e.data);
|
||||
|
||||
// Handle batched updates
|
||||
if (response.type === "updates" && "seq" in response && "updates" in response) {
|
||||
// Create function to request missed updates with credentials
|
||||
const token = getAuthToken();
|
||||
const requestMissedFn = token ? async (lastSeq: number) => {
|
||||
await requestMissedUpdates(lastSeq, async (req) => {
|
||||
await request(req);
|
||||
}, {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
});
|
||||
} : undefined;
|
||||
|
||||
await processBatchedUpdates(response as any, (update) => {
|
||||
// Route individual updates to appropriate handlers
|
||||
handleUpdate(update);
|
||||
}, requestMissedFn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle call signaling messages
|
||||
if (callSignalingHandler && response.type === "call_signaling" && response.data) {
|
||||
callSignalingHandler.handleWebSocketMessage(response.data);
|
||||
}
|
||||
|
||||
// Handle status and typing messages (these may come as immediate messages or in batches)
|
||||
handleUpdate(response);
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to handle individual updates
|
||||
function handleUpdate(response: WebSocketMessage<any>): void {
|
||||
if (response.type === "statusUpdate") {
|
||||
onlineStatusManager.handleStatusUpdate(response as any);
|
||||
} else if (response.type === "typing") {
|
||||
typingManager.handleTyping(response as any);
|
||||
} else if (response.type === "stopTyping") {
|
||||
typingManager.handleStopTyping(response as any);
|
||||
} else if (response.type === "dmTyping") {
|
||||
typingManager.handleDmTyping(response as any);
|
||||
} else if (response.type === "stopDmTyping") {
|
||||
typingManager.handleStopDmTyping(response as any);
|
||||
} else if (response.type === "suspended") {
|
||||
// Handle account suspension
|
||||
const { setSuspended } = useUserStore.getState();
|
||||
const reason = response.data?.reason || "No reason provided";
|
||||
setSuspended(reason);
|
||||
// Close WebSocket connection
|
||||
websocket.close();
|
||||
} else if (response.type === "account_deleted") {
|
||||
// Handle account deletion - silent logout
|
||||
const { logout } = useUserStore.getState();
|
||||
logout();
|
||||
// Close WebSocket connection
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
globalMessageHandler(response);
|
||||
}
|
||||
}
|
||||
websocket.addEventListener("message", messageHandler);
|
||||
|
||||
// Open handler
|
||||
openHandler = async () => {
|
||||
reconnectAttempts = 0; // Reset on successful connection
|
||||
isReconnecting = false;
|
||||
|
||||
// Authenticate by sending ping with credentials and request missed updates
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
const credentials = {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
};
|
||||
|
||||
// Send ping to authenticate and set user_by_ws on the server
|
||||
try {
|
||||
await request({
|
||||
type: "ping",
|
||||
credentials,
|
||||
data: {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send ping on reconnect:", error);
|
||||
}
|
||||
|
||||
// Send last sequence number and request missed updates on reconnect
|
||||
// Wait a bit for ping to complete authentication
|
||||
await delay(100);
|
||||
|
||||
try {
|
||||
const lastSeq = await getLastSequence();
|
||||
if (lastSeq > 0) {
|
||||
await requestMissedUpdates(lastSeq, async (req) => {
|
||||
await request(req);
|
||||
}, credentials);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to request missed updates:", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to authenticate on reconnect:", error);
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("open", openHandler);
|
||||
|
||||
// Error handler
|
||||
errorHandler = () => {
|
||||
// Don't reconnect immediately on error - let close handler handle it
|
||||
// This prevents double reconnection attempts
|
||||
};
|
||||
websocket.addEventListener("error", errorHandler);
|
||||
|
||||
// Close handler
|
||||
closeHandler = (e: CloseEvent) => {
|
||||
// Don't reconnect if it was a clean close (e.g., logout, suspension)
|
||||
if (e.code === 1000 || e.code === 1001) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect for unexpected closes
|
||||
if (!isReconnecting) {
|
||||
reconnect();
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("close", closeHandler);
|
||||
}
|
||||
|
||||
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
console.log("WebSocket request:", payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error("Request timed out"));
|
||||
}, 10000);
|
||||
|
||||
function requestInner() {
|
||||
if (websocket.readyState !== WebSocket.OPEN) {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error("WebSocket is not open"));
|
||||
return;
|
||||
}
|
||||
|
||||
const listener = (e: MessageEvent) => {
|
||||
try {
|
||||
const response = JSON.parse(e.data);
|
||||
// Only handle responses that match our request type or have an error
|
||||
if (response.type === payload.type || response.error) {
|
||||
clearTimeout(timeoutId);
|
||||
websocket.removeEventListener("message", listener);
|
||||
|
||||
// Check if the response contains an error field
|
||||
if (response.error) {
|
||||
const error = new Error(response.error.detail || "WebSocket request failed");
|
||||
(error as HttpError).status = response.error.code;
|
||||
(error as HttpError).detail = response.error.detail || "";
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
}
|
||||
// If it doesn't match, let other handlers process it
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
websocket.removeEventListener("message", listener);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", listener);
|
||||
|
||||
try {
|
||||
websocket.send(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
websocket.removeEventListener("message", listener);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (websocket.readyState === WebSocket.CONNECTING) {
|
||||
const openListener = () => {
|
||||
websocket.removeEventListener("open", openListener);
|
||||
requestInner();
|
||||
};
|
||||
websocket.addEventListener("open", openListener);
|
||||
} else if (websocket.readyState === WebSocket.OPEN) {
|
||||
requestInner();
|
||||
} else {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error("WebSocket is closed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Initialization
|
||||
// --------------
|
||||
|
||||
/**
|
||||
* Ensure WebSocket is connected and authenticated after login
|
||||
* This should be called after successful authentication
|
||||
*/
|
||||
export async function ensureAuthenticated(): Promise<void> {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If WebSocket is not connected, wait for it to connect
|
||||
if (websocket.readyState === WebSocket.CONNECTING) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkConnection = () => {
|
||||
if (websocket.readyState === WebSocket.OPEN) {
|
||||
resolve();
|
||||
} else if (websocket.readyState === WebSocket.CLOSED) {
|
||||
// Connection failed, try to reconnect
|
||||
reconnect().then(() => {
|
||||
setTimeout(checkConnection, 100);
|
||||
});
|
||||
} else {
|
||||
setTimeout(checkConnection, 100);
|
||||
}
|
||||
};
|
||||
checkConnection();
|
||||
});
|
||||
} else if (websocket.readyState === WebSocket.CLOSED) {
|
||||
// Reconnect if closed
|
||||
await reconnect();
|
||||
}
|
||||
|
||||
// If WebSocket is open, send ping to authenticate
|
||||
if (websocket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
const credentials = {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
};
|
||||
|
||||
await request({
|
||||
type: "ping",
|
||||
credentials,
|
||||
data: {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send ping after login:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupEventHandlers();
|
||||
Reference in New Issue
Block a user