Backend moved to a separate repository

This commit is contained in:
2026-07-14 09:59:03 +03:00
Unverified
parent 1cc5294d52
commit 5f9d9a78d3
340 changed files with 198 additions and 21988 deletions
+23
View File
@@ -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>
);
}
+36
View File
@@ -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");
}
+218
View File
@@ -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();
}
+236
View File
@@ -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;
}
}
+323
View File
@@ -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 };
+99
View File
@@ -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");
}
+76
View File
@@ -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");
}
+37
View File
@@ -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");
}
+45
View File
@@ -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;
}
+14
View File
@@ -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");
}
+237
View File
@@ -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;
}
+53
View File
@@ -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 || [];
}
+43
View File
@@ -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();
}
};
+47
View File
@@ -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;
+116
View File
@@ -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");
}
+54
View File
@@ -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();
}
+55
View File
@@ -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();
}
+89
View File
@@ -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;
}
}
+235
View File
@@ -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;
}
}
+52
View File
@@ -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();
}
};
+235
View File
@@ -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();
}
+37
View File
@@ -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");
}
+152
View File
@@ -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;
}
}
+40
View File
@@ -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();
}
+28
View File
@@ -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 || [];
}
+22
View File
@@ -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)})`;
}
+145
View File
@@ -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);
});
+217
View File
@@ -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);
}
+170
View File
@@ -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
+94
View File
@@ -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>
);
}
+127
View File
@@ -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")
)}
</>
);
}
+17
View File
@@ -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>
)
}
+213
View File
@@ -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}
/>
</>
);
}
+159
View File
@@ -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>
);
}
+260
View File
@@ -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>
);
}
+54
View File
@@ -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;
}
+75
View File
@@ -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")!
);
}
+47
View File
@@ -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;
}
}
}
+60
View File
@@ -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;
+41
View File
@@ -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;
}
}
+17
View File
@@ -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");
}
+34
View File
@@ -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
};
}
+29
View File
@@ -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
}
}
+24
View File
@@ -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}`);
+13
View File
@@ -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>
);
}
+197
View File
@@ -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 };
+26
View File
@@ -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>
);
}
+86
View File
@@ -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;
}
+236
View File
@@ -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: "Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.",
};
}
+19
View File
@@ -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",
};
+130
View File
@@ -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;
}
+152
View File
@@ -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();
+39
View File
@@ -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
});
+643
View File
@@ -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;
+232
View File
@@ -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();
+126
View File
@@ -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
});
}
}
+51
View File
@@ -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 "был(а) давно";
}
+412
View File
@@ -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();