Backend moved to a separate repository
@@ -0,0 +1,150 @@
|
||||
import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ElectronTitleBar } from "./Electron";
|
||||
import { useUserStore } from "./state/user";
|
||||
import { lazy, useEffect, useRef, useState } from "react";
|
||||
import { parseProfileLink } from "./core/profileLinks";
|
||||
import NotFoundPage from "./pages/not-found/NotFoundPage";
|
||||
import ProtectedRoute from "./pages/ProtectedRoute";
|
||||
import DownloadAppPage from "./pages/download-app/DownloadAppPage";
|
||||
import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog";
|
||||
import { AlertDialogProvider } from "./core/components/AlertDialog";
|
||||
import { delay } from "./utils/utils";
|
||||
|
||||
// Lazy load route components
|
||||
const HomePage = lazy(() => import("./pages/home/HomePage"));
|
||||
const AuthPage = lazy(() => import("./pages/auth/AuthPage"));
|
||||
const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage"));
|
||||
const PrivacyPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.PrivacyPage })));
|
||||
const TermsPage = lazy(() => import("./pages/legal/LegalPages").then(m => ({ default: m.TermsPage })));
|
||||
|
||||
const routeConfig: RouteObject[] = [
|
||||
{ path: "/", element: <HomePage /> },
|
||||
{ path: "/auth", element: <AuthPage /> },
|
||||
{ path: "/login", element: <Navigate to="/auth?mode=login" replace /> },
|
||||
{ path: "/register", element: <Navigate to="/auth?mode=register" replace /> },
|
||||
{ path: "/download-app", element: <DownloadAppPage /> },
|
||||
{ path: "/privacy", element: <PrivacyPage /> },
|
||||
{ path: "/terms", element: <TermsPage /> },
|
||||
{
|
||||
path: "/chat",
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<ChatPage />
|
||||
</ProtectedRoute>
|
||||
)
|
||||
},
|
||||
{ path: "*", element: <SmartCatchAll /> }
|
||||
];
|
||||
|
||||
function SmartCatchAll() {
|
||||
const navigate = useNavigate();
|
||||
const [showNotFound, setShowNotFound] = useState(false);
|
||||
|
||||
function isValidRoute(path: string): boolean {
|
||||
const validRoutes = routeConfig.filter(route => route.path !== "*");
|
||||
const matches = matchRoutes(validRoutes, path);
|
||||
|
||||
return Boolean(matches && matches.length > 0);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidRoute(location.pathname)) {
|
||||
setShowNotFound(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const profileInfo = parseProfileLink(); // No URL specified intentionally to let it use the current URL
|
||||
|
||||
if (profileInfo) {
|
||||
setShowNotFound(false);
|
||||
navigate("/chat", {
|
||||
replace: true,
|
||||
state: { profileInfo }
|
||||
});
|
||||
} else {
|
||||
setShowNotFound(true);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
// Show 404 page
|
||||
if (showNotFound) {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
}
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
const prevPathnameRef = useRef(location.pathname);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="sync" initial={false}>
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
onAnimationStart={() => {
|
||||
if (prevPathnameRef.current !== location.pathname) {
|
||||
prevPathnameRef.current = location.pathname;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
}}
|
||||
onAnimationComplete={async () => {
|
||||
await delay(500);
|
||||
document.body.style.overflow = "";
|
||||
}}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 1, scale: 1.1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
mass: 0.8
|
||||
}}
|
||||
style={{
|
||||
transformOrigin: "center center",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0
|
||||
}}
|
||||
>
|
||||
<Routes location={location}>
|
||||
{routeConfig.map((route, index) => (
|
||||
<Route key={index} path={route.path} element={route.element} />
|
||||
))}
|
||||
</Routes>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { restoreFromStorage, user } = useUserStore();
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
restoreFromStorage().finally(() => {
|
||||
setAuthReady(true);
|
||||
});
|
||||
}, [restoreFromStorage]);
|
||||
|
||||
return authReady && (
|
||||
<BrowserRouter>
|
||||
<ElectronTitleBar />
|
||||
<AlertDialogProvider />
|
||||
<div id="main-wrapper">
|
||||
<AnimatedRoutes />
|
||||
</div>
|
||||
{user.isSuspended && (
|
||||
<SuspensionDialog
|
||||
reason={user.suspensionReason || "No reason provided"}
|
||||
open={true}
|
||||
onOpenChange={() => {}} // Suspended users can't close the dialog
|
||||
/>
|
||||
)}
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { PRODUCT_NAME } from "./core/config";
|
||||
import { isElectron } from "./core/electron/electron";
|
||||
|
||||
export function ElectronTitleBar() {
|
||||
return isElectron && (
|
||||
<div id="electron-title-bar">
|
||||
{window.electronInterface.platform == "darwin" && <div className="macos-padding"></div>}
|
||||
<div id="window-title">{PRODUCT_NAME}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
import { avatarGradientFromUserId } from "@/core/avatarGradient";
|
||||
import styles from "@/pages/chat/css/deleted-user-avatar.module.scss";
|
||||
|
||||
interface DeletedUserAvatarProps {
|
||||
userId: number;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function DeletedUserAvatar({ userId, className, iconClassName }: DeletedUserAvatarProps) {
|
||||
return (
|
||||
<div
|
||||
className={className ?? styles.deletedUserAvatar}
|
||||
style={{ background: avatarGradientFromUserId(userId) }}
|
||||
>
|
||||
<MaterialIcon
|
||||
name="account_circle_off--outlined"
|
||||
className={iconClassName ?? styles.deletedUserAvatarIcon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./index";
|
||||
|
||||
export interface DeviceInfo {
|
||||
session_id: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
os_name?: string;
|
||||
os_version?: string;
|
||||
browser_name?: string;
|
||||
browser_version?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
created_at?: string;
|
||||
last_seen?: string;
|
||||
revoked?: boolean;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export async function listDevices(token: string): Promise<DeviceInfo[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to fetch devices");
|
||||
const data = await res.json();
|
||||
return data.devices as DeviceInfo[];
|
||||
}
|
||||
|
||||
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to revoke device");
|
||||
}
|
||||
|
||||
export async function logoutAllOtherDevices(token: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to logout all devices");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
||||
import type { Headers } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {string | null} token - Authentication token
|
||||
* @param {boolean} json - Whether to include JSON content type header
|
||||
* @returns {Headers} Headers object with authentication and content type
|
||||
*/
|
||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export interface CheckAuthResponse {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveKeys(
|
||||
publicKey: Uint8Array<ArrayBufferLike>,
|
||||
privateKey: Uint8Array<ArrayBufferLike>
|
||||
) {
|
||||
const encodedPublicKey = b64(publicKey);
|
||||
const encodedPrivateKey = b64(privateKey);
|
||||
|
||||
localStorage.setItem("publicKey", encodedPublicKey);
|
||||
localStorage.setItem("privateKey", encodedPrivateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user is authenticated
|
||||
*/
|
||||
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to check auth");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in a user with username and password
|
||||
*/
|
||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||
throw new Error(error.detail || "Login failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new user
|
||||
*/
|
||||
export async function register(request: RegisterRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
||||
throw new Error(error.detail || "Registration failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the current user
|
||||
*/
|
||||
export async function logout(token: string): Promise<LogoutResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/logout`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to logout");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a client-side authentication secret so the raw password never leaves the client.
|
||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
||||
*/
|
||||
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
||||
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
||||
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
||||
return b64(derived);
|
||||
}
|
||||
|
||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob(token);
|
||||
if (blobJson) {
|
||||
const blob = decodeBlob(blobJson);
|
||||
const bundle = await decryptBackupWithPassword(password, blob);
|
||||
currentPrivateKey = bundle.privateKey;
|
||||
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
||||
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
||||
const serverPub = await fetchPublicKey(token);
|
||||
if (serverPub) {
|
||||
currentPublicKey = serverPub;
|
||||
} else {
|
||||
// We don't have the corresponding public key from server; regenerate pair to resync
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||
}
|
||||
|
||||
saveKeys(currentPublicKey!, currentPrivateKey!);
|
||||
|
||||
return {
|
||||
publicKey: currentPublicKey!,
|
||||
privateKey: currentPrivateKey!
|
||||
};
|
||||
}
|
||||
|
||||
// First-time setup: generate keys and upload
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||
|
||||
saveKeys(pair.publicKey, pair.privateKey);
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
export function restoreKeys() {
|
||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's password
|
||||
*/
|
||||
export async function changePassword(
|
||||
token: string,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
logoutAllExceptCurrent: boolean
|
||||
): Promise<void> {
|
||||
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
||||
const newDerived = await deriveAuthSecret(username, newPassword);
|
||||
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
currentPasswordDerived: currentDerived,
|
||||
newPasswordDerived: newDerived,
|
||||
logoutAllExceptCurrent
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to change password");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current user's account
|
||||
*/
|
||||
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
||||
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
||||
throw new Error(error.detail || "Failed to delete account");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { getAuthHeaders } from ".";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.bio
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile picture to the server
|
||||
*/
|
||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile information
|
||||
*/
|
||||
export async function updateProfile(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
// Map frontend fields to backend fields
|
||||
const backendData = {
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(token, true),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(backendData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user bio
|
||||
*/
|
||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by username
|
||||
*/
|
||||
export async function fetchUserProfile(token: string, username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by user ID
|
||||
*/
|
||||
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles verification status for a user (owner only)
|
||||
*/
|
||||
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Suspends a user account (admin only)
|
||||
*/
|
||||
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error suspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuspends a user account (admin only)
|
||||
*/
|
||||
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error unsuspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (admin only)
|
||||
*/
|
||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import { getCurrentKeys } from "../user/auth";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { ub64 } from "@/utils/utils";
|
||||
import { fetchUserPublicKey } from "../crypto/identity";
|
||||
import { fetchUsers, searchUsers } from "../user/search";
|
||||
import { deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import tweetnacl from "tweetnacl";
|
||||
|
||||
/**
|
||||
* Unwrap a MEK using the appropriate wrapping key for the current user
|
||||
*/
|
||||
export async function unwrapMek(wrappedMekB64: string, envelope: DmEnvelope, userId?: number): Promise<Uint8Array> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Determine context based on whether we're sender or recipient
|
||||
const currentUserId = userId || parseInt(localStorage.getItem('userId') || '0');
|
||||
const isRecipient = envelope.recipientId === currentUserId;
|
||||
const context = isRecipient ? "recipient_wrap_key" : "sender_wrap_key";
|
||||
|
||||
// Derive wrapping key from our public key
|
||||
const salt = new Uint8Array(16).fill(0); // 16 zero bytes salt
|
||||
const wrappingKeyRaw = await deriveWrappingKey(keys.publicKey, salt, new TextEncoder().encode(context));
|
||||
const wrappingKey = await importAesGcmKey(wrappingKeyRaw);
|
||||
|
||||
// Unwrap the MEK using AES-256-GCM
|
||||
const wrappedMekBytes = ub64(wrappedMekB64);
|
||||
const mekNonce = wrappedMekBytes.slice(0, 12);
|
||||
const mekCiphertext = wrappedMekBytes.slice(12);
|
||||
|
||||
return await aesGcmDecrypt(wrappingKey, mekNonce, mekCiphertext);
|
||||
}
|
||||
|
||||
export async function decrypt(envelope: DmEnvelope, userId?: number): Promise<string> {
|
||||
try {
|
||||
// Use the wrapped MEK provided for this user
|
||||
const wrappedMekB64 = envelope.wrapped_mek_b64;
|
||||
if (!wrappedMekB64) throw new Error("No wrapped MEK available for decryption");
|
||||
|
||||
// Unwrap the MEK using shared logic
|
||||
const mek = await unwrapMek(wrappedMekB64, envelope, userId);
|
||||
|
||||
// Decrypt the message using the unwrapped MEK
|
||||
// Server encrypts with AES-GCM, so client decrypts with AES-GCM
|
||||
// envelope.iv_b64 and envelope.ciphertext_b64 are base64-encoded separately
|
||||
const messageKey = await importAesGcmKey(mek);
|
||||
const messageNonce = ub64(envelope.iv_b64 || "");
|
||||
const messageCiphertext = ub64(envelope.ciphertext_b64);
|
||||
|
||||
const plaintext = await aesGcmDecrypt(messageKey, messageNonce, messageCiphertext);
|
||||
const result = new TextDecoder().decode(plaintext);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to decrypt DM envelope:", error);
|
||||
console.error("Error details:", {
|
||||
envelope: envelope,
|
||||
userId: userId,
|
||||
localStorageUserId: localStorage.getItem('userId')
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transport public key from the server
|
||||
*/
|
||||
async function getTransportPublicKey(): Promise<string> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch transport key: HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.public_key_b64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt message using transport key (client-side only)
|
||||
*/
|
||||
function encryptWithTransportKey(plaintext: string, transportPublicKeyB64: string): { client_public_key_b64: string; nonce_b64: string; ciphertext_b64: string } {
|
||||
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
return {
|
||||
client_public_key_b64: btoa(String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])),
|
||||
nonce_b64: btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[])),
|
||||
ciphertext_b64: btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]))
|
||||
};
|
||||
}
|
||||
|
||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number, attachments?: Array<{name:string,path:string,wrapped_mek_b64?:string,nonce_b64?:string}>): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption only
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key (from current keys)
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send to server (server will handle envelope encryption)
|
||||
const bodyPayload: any = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId
|
||||
};
|
||||
if (attachments && attachments.length > 0) bodyPayload["files"] = attachments;
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(bodyPayload)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function sendWithFiles(
|
||||
recipientId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
files: File[],
|
||||
plaintext: string,
|
||||
authToken: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
if (!files || files.length === 0) {
|
||||
throw new Error("No files provided");
|
||||
}
|
||||
|
||||
// Get transport key for encryption (shared across message + files)
|
||||
const transportKeyResponse = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!transportKeyResponse.ok) {
|
||||
throw new Error("Failed to get transport key");
|
||||
}
|
||||
const transportKeyData = await transportKeyResponse.json();
|
||||
const transportPublicKeyB64 = transportKeyData.public_key_b64;
|
||||
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const transportPublicKey = ub64(transportPublicKeyB64);
|
||||
|
||||
// One ephemeral keypair for message + all files (must match messaging service decrypt_transport_blob).
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
const messagePlaintextBytes = new TextEncoder().encode(plaintext || "");
|
||||
const messageNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const messageCiphertext = tweetnacl.box(
|
||||
messagePlaintextBytes,
|
||||
messageNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageNonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(messageCiphertext) as number[]));
|
||||
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Base64 encode helper (chunked)
|
||||
const uint8ToB64 = (uint8: Uint8Array): string => {
|
||||
const CHUNK = 0x8000;
|
||||
let binary = "";
|
||||
for (let i = 0; i < uint8.length; i += CHUNK) {
|
||||
binary += String.fromCharCode.apply(null, Array.from(uint8.subarray(i, i + CHUNK)) as number[]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
// Transport-encrypt files; server will envelope-encrypt them with the SAME MEK as the message.
|
||||
const transport_files: Array<{ encrypted_file_data_b64: string; filename: string; file_size: number }> = [];
|
||||
for (const file of files) {
|
||||
const fileData = await file.arrayBuffer();
|
||||
const transportNonce = tweetnacl.randomBytes(tweetnacl.box.nonceLength);
|
||||
const transportEncrypted = tweetnacl.box(
|
||||
new Uint8Array(fileData),
|
||||
transportNonce,
|
||||
transportPublicKey,
|
||||
ephemeralKeypair.secretKey
|
||||
);
|
||||
const transportEncryptedWithNonce = new Uint8Array(transportNonce.length + transportEncrypted.length);
|
||||
transportEncryptedWithNonce.set(transportNonce);
|
||||
transportEncryptedWithNonce.set(transportEncrypted, transportNonce.length);
|
||||
|
||||
transport_files.push({
|
||||
encrypted_file_data_b64: uint8ToB64(transportEncryptedWithNonce),
|
||||
filename: file.name,
|
||||
file_size: file.size
|
||||
});
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64,
|
||||
reply_to_id: replyToId,
|
||||
transport_files
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to send DM: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface ConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function conversations(token: string): Promise<ConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a DM as read
|
||||
*/
|
||||
export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmMarkRead",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function editMessage(
|
||||
messageId: number,
|
||||
recipientPublicKeyB64: string,
|
||||
plaintext: string,
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
// Get keys
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Get transport key for initial encryption
|
||||
const transportPublicKeyB64 = await getTransportPublicKey();
|
||||
|
||||
// Client-side transport encryption (same as sending)
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } = encryptWithTransportKey(plaintext, transportPublicKeyB64);
|
||||
|
||||
// Get sender's public key
|
||||
const senderPublicKeyB64 = keys.publicKey ? btoa(String.fromCharCode.apply(null, Array.from(keys.publicKey) as number[])) : "";
|
||||
|
||||
// Send transport-encrypted data to the edit endpoint (it will handle envelope encryption)
|
||||
const editResponse = await fetch(`${API_BASE_URL}/dm/edit/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(authToken, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
sender_public_key_b64: senderPublicKeyB64,
|
||||
recipient_public_key_b64: recipientPublicKeyB64
|
||||
})
|
||||
});
|
||||
|
||||
if (!editResponse.ok) throw new Error(`Failed to edit DM: HTTP ${editResponse.status}`);
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
@@ -0,0 +1,99 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
|
||||
/**
|
||||
* Fetches public chat messages
|
||||
*/
|
||||
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> {
|
||||
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await globalThis.fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return { messages: [], has_more: false };
|
||||
const data: Messages & { has_more?: boolean } = await response.json();
|
||||
return { messages: data.messages || [], has_more: data.has_more ?? false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message via WebSocket
|
||||
*/
|
||||
export async function send(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
||||
await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message with files via HTTP
|
||||
*/
|
||||
export async function sendWithFiles(
|
||||
content: string,
|
||||
replyToId: number | null,
|
||||
files: File[],
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append("payload", JSON.stringify({
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
} satisfies SendMessageRequest["data"]));
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/send_message`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || "Failed to send message with files");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a public chat message
|
||||
*/
|
||||
export async function edit(messageId: number, newContent: string, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ content: newContent })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to edit message");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a public chat message
|
||||
*/
|
||||
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(authToken, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete message");
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a message as read
|
||||
*/
|
||||
export async function markRead(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ message_id: messageId })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to mark message as read");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
*/
|
||||
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.publicKey) return null;
|
||||
return ub64(data.publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's public key
|
||||
*/
|
||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches another user's public key by user ID
|
||||
*/
|
||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const response: BackupBlob = await res.json();
|
||||
return response.blob;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's backup blob
|
||||
*/
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { BackupBlob } from "@/core/types";
|
||||
import api from "@/core/api";
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "GET",
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
const response: BackupBlob = await res.json();
|
||||
return response.blob;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's backup blob
|
||||
*/
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
const payload: BackupBlob = { blob: blobJson }
|
||||
|
||||
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload backup blob");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
import type { UploadPublicKeyRequest } from "@/core/types";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
|
||||
/**
|
||||
* Fetches the current user's public key
|
||||
*/
|
||||
export async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.publicKey) return null;
|
||||
return ub64(data.publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current user's public key
|
||||
*/
|
||||
export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
|
||||
const payload: UploadPublicKeyRequest = {
|
||||
publicKey: b64(publicKey)
|
||||
}
|
||||
|
||||
const headers = getAuthHeaders(token, true);
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to upload public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches another user's public key by user ID
|
||||
*/
|
||||
export async function fetchUserPublicKey(userId: number, token: string): Promise<string | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.publicKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Placeholder for Signal Protocol pre-key management
|
||||
// Will be implemented when Signal Protocol is added
|
||||
|
||||
export async function upload(_bundle: unknown, _token: string): Promise<void> {
|
||||
// TODO: Implement Signal Protocol pre-key upload
|
||||
throw new Error("Not implemented yet");
|
||||
}
|
||||
|
||||
export async function fetch(_userId: number, _token: string): Promise<unknown> {
|
||||
// TODO: Implement Signal Protocol pre-key fetch
|
||||
throw new Error("Not implemented yet");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
/**
|
||||
* Decrypt a DM envelope using client-side MEK unwrapping.
|
||||
* This delegates to the chats/dm module which has the updated implementation.
|
||||
*/
|
||||
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
/**
|
||||
* Send DM via WebSocket using transport encryption.
|
||||
* This delegates to the HTTP endpoint which handles envelope encryption on server.
|
||||
*/
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
// Import and use the updated implementation from chats/dm
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Envelope Encryption (Private DMs with compliance support)
|
||||
// ============================================================================
|
||||
|
||||
interface TransportKey {
|
||||
key_id: string;
|
||||
public_key_b64: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface TransportEncryptedMessage {
|
||||
client_public_key_b64: string;
|
||||
nonce_b64: string;
|
||||
ciphertext_b64: string;
|
||||
}
|
||||
|
||||
let cachedTransportKey: TransportKey | null = null;
|
||||
|
||||
/**
|
||||
* Fetch current transport public key from messaging service.
|
||||
* Caches result with validation.
|
||||
*/
|
||||
export async function getTransportPublicKey(): Promise<TransportKey> {
|
||||
if (cachedTransportKey) {
|
||||
return cachedTransportKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/key/transport/public`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const data: TransportKey = await response.json();
|
||||
cachedTransportKey = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transport public key:", error);
|
||||
}
|
||||
|
||||
throw new Error("Failed to fetch transport public key");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a message using the transport public key (X25519 + ChaCha20).
|
||||
*/
|
||||
function encryptMessageWithTransportKey(
|
||||
plaintext: string | Uint8Array,
|
||||
transportPublicKeyB64: string
|
||||
): { nonce_b64: string; ciphertext_b64: string; client_public_key_b64: string } {
|
||||
const tweetnacl = require("tweetnacl");
|
||||
|
||||
// Convert plaintext to bytes if string
|
||||
const plaintextBytes = typeof plaintext === "string" ? new TextEncoder().encode(plaintext) : plaintext;
|
||||
|
||||
// Generate ephemeral keypair for this message
|
||||
const ephemeralKeypair = tweetnacl.box.keyPair();
|
||||
|
||||
// Decode transport public key
|
||||
const transportPublicKeyBytes = new Uint8Array(
|
||||
atob(transportPublicKeyB64)
|
||||
.split("")
|
||||
.map((c: string) => c.charCodeAt(0))
|
||||
);
|
||||
|
||||
// Perform ECDH (shared secret via tweetnacl's box)
|
||||
const nonce = tweetnacl.randomBytes(24);
|
||||
const ciphertext = tweetnacl.box(plaintextBytes, nonce, transportPublicKeyBytes, ephemeralKeypair.secretKey);
|
||||
|
||||
// Encode to base64
|
||||
const nonce_b64 = btoa(String.fromCharCode.apply(null, Array.from(nonce) as number[]));
|
||||
const ciphertext_b64 = btoa(String.fromCharCode.apply(null, Array.from(ciphertext) as number[]));
|
||||
const client_public_key_b64 = btoa(
|
||||
String.fromCharCode.apply(null, Array.from(ephemeralKeypair.publicKey) as number[])
|
||||
);
|
||||
|
||||
return { nonce_b64, ciphertext_b64, client_public_key_b64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with transport public key for sending to server.
|
||||
* Server will handle envelope encryption (MEK generation and wrapping).
|
||||
*/
|
||||
export async function encryptMessageForTransport(plaintext: string): Promise<TransportEncryptedMessage> {
|
||||
const transportKey = await getTransportPublicKey();
|
||||
return encryptMessageWithTransportKey(plaintext, transportKey.public_key_b64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an encrypted DM message using envelope encryption.
|
||||
* Client encrypts with transport key, server handles envelope encryption.
|
||||
*/
|
||||
export async function sendEncryptedDM(
|
||||
recipientId: number,
|
||||
plaintext: string,
|
||||
token: string,
|
||||
replyToId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Client-side transport encryption
|
||||
const { client_public_key_b64, nonce_b64, ciphertext_b64 } =
|
||||
await encryptMessageForTransport(plaintext);
|
||||
|
||||
// Send to server
|
||||
const response = await fetch(`${API_BASE_URL}/dm/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(token, true)
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipient_id: recipientId,
|
||||
client_public_key_b64,
|
||||
transport_nonce_b64: nonce_b64,
|
||||
transport_ciphertext_b64: ciphertext_b64,
|
||||
reply_to_id: replyToId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send encrypted DM:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get encrypted conversation history with another user.
|
||||
*/
|
||||
export async function getEncryptedConversation(
|
||||
otherUserId: number,
|
||||
token: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const url = new URL(`${API_BASE_URL}/dm/conversation/${otherUserId}`);
|
||||
url.searchParams.append("limit", String(limit));
|
||||
url.searchParams.append("offset", String(offset));
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch encrypted conversation with user ${otherUserId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an encrypted message.
|
||||
*/
|
||||
export async function deleteEncryptedDM(messageId: number, token: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete encrypted DM ${messageId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached keys (useful on logout).
|
||||
*/
|
||||
export function clearCachedKeys(): void {
|
||||
cachedTransportKey = null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { DmEnvelope, User } from "@/core/types";
|
||||
import { fetchUserPublicKey } from "./crypto";
|
||||
import { fetchUsers, searchUsers } from "./users";
|
||||
|
||||
export async function decryptDm(envelope: DmEnvelope): Promise<string> {
|
||||
const { decrypt } = await import("./chats/dm");
|
||||
return decrypt(envelope);
|
||||
}
|
||||
|
||||
export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise<DmEnvelope[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
// Re-export user functions for convenience
|
||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||
|
||||
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||
const { send } = await import("./chats/dm");
|
||||
return send(recipientId, recipientPublicKeyB64, plaintext, authToken, replyToId);
|
||||
}
|
||||
|
||||
|
||||
export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise<void> {
|
||||
await request({
|
||||
type: "dmDelete",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: { id, recipientId }
|
||||
});
|
||||
}
|
||||
|
||||
export interface DMConversationResponse {
|
||||
user: User;
|
||||
lastMessage: DmEnvelope;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export async function fetchDMConversations(token: string): Promise<DMConversationResponse[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/dm/conversations`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.conversations || [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./user/auth";
|
||||
|
||||
export const normal = {
|
||||
/**
|
||||
* Gets the URL for a normal (unencrypted) file
|
||||
*/
|
||||
url(filename: string): string {
|
||||
return `${API_BASE_URL}/uploads/files/normal/${filename}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches a normal file (unencrypted)
|
||||
*/
|
||||
async fetch(filename: string, token: string): Promise<Blob> {
|
||||
const res = await fetch(this.url(filename), {
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch file");
|
||||
return await res.blob();
|
||||
}
|
||||
};
|
||||
|
||||
export const encrypted = {
|
||||
/**
|
||||
* Gets the URL for an encrypted file
|
||||
*/
|
||||
url(filename: string): string {
|
||||
return `${API_BASE_URL}/uploads/files/encrypted/${filename}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches an encrypted file
|
||||
*/
|
||||
async fetch(filename: string, token: string): Promise<Blob> {
|
||||
const res = await fetch(this.url(filename), {
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch encrypted file");
|
||||
return await res.blob();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as chatsGeneral from "./chats/general";
|
||||
import * as chatsDm from "./chats/dm";
|
||||
import * as userProfile from "./user/profile";
|
||||
import * as userAuth from "./user/auth";
|
||||
import * as userDevices from "./user/devices";
|
||||
import * as userSearch from "./user/search";
|
||||
import * as cryptoPrekeys from "./crypto/prekeys";
|
||||
import * as cryptoIdentity from "./crypto/identity";
|
||||
import * as cryptoBackup from "./crypto/backup";
|
||||
import * as moderationBlocklist from "./moderation/blocklist";
|
||||
import * as moderationUsers from "./moderation/users";
|
||||
import * as filesModule from "./files";
|
||||
import * as pushModule from "./push";
|
||||
|
||||
const api = {
|
||||
chats: {
|
||||
general: chatsGeneral,
|
||||
dm: chatsDm
|
||||
},
|
||||
user: {
|
||||
profile: userProfile,
|
||||
auth: userAuth,
|
||||
devices: userDevices,
|
||||
search: userSearch
|
||||
},
|
||||
crypto: {
|
||||
prekeys: cryptoPrekeys,
|
||||
identity: cryptoIdentity,
|
||||
backup: cryptoBackup
|
||||
},
|
||||
moderation: {
|
||||
blocklist: moderationBlocklist,
|
||||
users: moderationUsers
|
||||
},
|
||||
files: filesModule,
|
||||
push: pushModule
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
export const chats = api.chats;
|
||||
export const user = api.user;
|
||||
export const crypto = api.crypto;
|
||||
export const moderation = api.moderation;
|
||||
export const files = api.files;
|
||||
export const push = api.push;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { Message, Messages, SendMessageRequest } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
|
||||
class HttpError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
|
||||
constructor(message: string, status: number, detail: string) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches public chat messages
|
||||
*/
|
||||
export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<Message[]> {
|
||||
let url = `${API_BASE_URL}/get_messages?limit=${limit}`;
|
||||
if (beforeId) {
|
||||
url += `&before_id=${beforeId}`;
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data: Messages = await response.json();
|
||||
return data.messages || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message via WebSocket
|
||||
*/
|
||||
export async function sendMessage(content: string, replyToId: number | null, authToken: string): Promise<void> {
|
||||
await request({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
},
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: authToken
|
||||
},
|
||||
type: "sendMessage"
|
||||
} satisfies SendMessageRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a public chat message with files via HTTP
|
||||
*/
|
||||
export async function sendMessageWithFiles(
|
||||
content: string,
|
||||
replyToId: number | null,
|
||||
files: File[],
|
||||
authToken: string
|
||||
): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append("payload", JSON.stringify({
|
||||
content: content.trim(),
|
||||
reply_to_id: replyToId ?? null
|
||||
} satisfies SendMessageRequest["data"]));
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
const res = await fetch(`${API_BASE_URL}/send_message`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(authToken, false),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errorDetail = "Failed to send message with files";
|
||||
try {
|
||||
const errorJson = await res.json();
|
||||
errorDetail = errorJson.detail || errorDetail;
|
||||
} catch {
|
||||
const errorText = await res.text();
|
||||
errorDetail = errorText || errorDetail;
|
||||
}
|
||||
throw new HttpError(errorDetail, res.status, errorDetail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a public chat message
|
||||
*/
|
||||
export async function editMessage(messageId: number, newContent: string, authToken: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/edit_message/${messageId}`, {
|
||||
method: "PUT",
|
||||
headers: getAuthHeaders(authToken, true),
|
||||
body: JSON.stringify({ content: newContent })
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errorDetail = "Failed to edit message";
|
||||
try {
|
||||
const errorJson = await res.json();
|
||||
errorDetail = errorJson.detail || errorDetail;
|
||||
} catch {
|
||||
const errorText = await res.text();
|
||||
errorDetail = errorText || errorDetail;
|
||||
}
|
||||
throw new HttpError(errorDetail, res.status, errorDetail);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a public chat message
|
||||
*/
|
||||
export async function deleteMessage(messageId: number, authToken: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/delete_message/${messageId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(authToken, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete message");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
|
||||
export interface BlocklistResponse {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateRequest {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateResponse {
|
||||
added?: string[];
|
||||
removed?: string[];
|
||||
words: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current blocklist (admin only)
|
||||
*/
|
||||
export async function getBlocklist(token: string): Promise<BlocklistResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds words to the blocklist (admin only)
|
||||
*/
|
||||
export async function addToBlocklist(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add to blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes words from the blocklist (admin only)
|
||||
*/
|
||||
export async function removeFromBlocklist(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove from blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
|
||||
export interface BlocklistResponse {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateRequest {
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface BlocklistUpdateResponse {
|
||||
added?: string[];
|
||||
removed?: string[];
|
||||
words: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current blocklist (admin only)
|
||||
*/
|
||||
export async function get(token: string): Promise<BlocklistResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds words to the blocklist (admin only)
|
||||
*/
|
||||
export async function add(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add to blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes words from the blocklist (admin only)
|
||||
*/
|
||||
export async function remove(words: string[], token: string): Promise<BlocklistUpdateResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ words })
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to remove from blocklist");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "../user/auth";
|
||||
|
||||
/**
|
||||
* Toggles verification status for a user (owner only)
|
||||
*/
|
||||
export async function verify(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends a user account (admin only)
|
||||
*/
|
||||
export async function suspend(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error suspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuspends a user account (admin only)
|
||||
*/
|
||||
export async function unsuspend(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error unsuspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (admin only)
|
||||
*/
|
||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function loadProfile(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.bio
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile picture to the server
|
||||
*/
|
||||
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile information
|
||||
*/
|
||||
export async function updateProfile(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
// Map frontend fields to backend fields
|
||||
const backendData = {
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(token),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(backendData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user bio
|
||||
*/
|
||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by username
|
||||
*/
|
||||
export async function fetchUserProfile(token: string, username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by user ID
|
||||
*/
|
||||
export async function fetchUserProfileById(token: string, userId: number): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles verification status for a user (owner only)
|
||||
*/
|
||||
export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error verifying user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Suspends a user account (admin only)
|
||||
*/
|
||||
export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token),
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error suspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsuspends a user account (admin only)
|
||||
*/
|
||||
export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error unsuspending user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (admin only)
|
||||
*/
|
||||
export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(token)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./user/auth";
|
||||
|
||||
export interface PushSubscriptionRequest {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PushSubscriptionResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const subscription = {
|
||||
/**
|
||||
* Subscribes the current user to push notifications
|
||||
*/
|
||||
async subscribe(
|
||||
subscription: PushSubscriptionRequest,
|
||||
token: string
|
||||
): Promise<PushSubscriptionResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" }));
|
||||
throw new Error(error.detail || "Failed to subscribe to push notifications");
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Unsubscribes the current user from push notifications
|
||||
*/
|
||||
async unsubscribe(token: string): Promise<PushSubscriptionResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" }));
|
||||
throw new Error(error.detail || "Failed to unsubscribe from push notifications");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
|
||||
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
|
||||
|
||||
/**
|
||||
* Generates authentication headers for API requests
|
||||
* @param {string | null} token - Authentication token
|
||||
* @param {boolean} json - Whether to include JSON content type header
|
||||
* @returns {Headers} Headers object with authentication and content type
|
||||
*/
|
||||
export function getAuthHeaders(token: string | null, json: boolean = true): Headers {
|
||||
const headers: Headers = {};
|
||||
|
||||
if (json) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export interface CheckAuthResponse {
|
||||
authenticated: boolean;
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UserKeyPairMemory {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
let currentPublicKey: Uint8Array | null = null;
|
||||
let currentPrivateKey: Uint8Array | null = null;
|
||||
|
||||
export function getCurrentKeys(): UserKeyPairMemory | null {
|
||||
if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey };
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveKeys(
|
||||
publicKey: Uint8Array<ArrayBufferLike>,
|
||||
privateKey: Uint8Array<ArrayBufferLike>
|
||||
) {
|
||||
const encodedPublicKey = b64(publicKey);
|
||||
const encodedPrivateKey = b64(privateKey);
|
||||
|
||||
localStorage.setItem("publicKey", encodedPublicKey);
|
||||
localStorage.setItem("privateKey", encodedPrivateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current user is authenticated
|
||||
*/
|
||||
export async function checkAuth(token: string): Promise<CheckAuthResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/check_auth`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to check auth");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in a user with username and password
|
||||
*/
|
||||
export async function login(request: LoginRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Login failed" }));
|
||||
throw new Error(error.detail || "Login failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new user
|
||||
*/
|
||||
export async function register(request: RegisterRequest): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/register`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(null, true),
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Registration failed" }));
|
||||
throw new Error(error.detail || "Registration failed");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the current user
|
||||
*/
|
||||
export async function logout(token: string): Promise<LogoutResponse> {
|
||||
const res = await fetch(`${API_BASE_URL}/logout`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to logout");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a client-side authentication secret so the raw password never leaves the client.
|
||||
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
|
||||
*/
|
||||
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
|
||||
// Use per-user salt derived from username; in future we can fetch a server-provided salt
|
||||
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
|
||||
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
|
||||
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
|
||||
return b64(derived);
|
||||
}
|
||||
|
||||
export async function ensureKeysOnLogin(password: string, token: string): Promise<UserKeyPairMemory> {
|
||||
// Try to restore from backup
|
||||
const blobJson = await fetchBackupBlob(token);
|
||||
if (blobJson) {
|
||||
const blob = decodeBlob(blobJson);
|
||||
const bundle = await decryptBackupWithPassword(password, blob);
|
||||
currentPrivateKey = bundle.privateKey;
|
||||
// Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous
|
||||
// In our simple scheme, we rely on server having the public key or we reupload generated one on first setup
|
||||
const serverPub = await fetchPublicKey(token);
|
||||
if (serverPub) {
|
||||
currentPublicKey = serverPub;
|
||||
} else {
|
||||
// We don't have the corresponding public key from server; regenerate pair to resync
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(newBlob), token);
|
||||
}
|
||||
|
||||
saveKeys(currentPublicKey!, currentPrivateKey!);
|
||||
|
||||
return {
|
||||
publicKey: currentPublicKey!,
|
||||
privateKey: currentPrivateKey!
|
||||
};
|
||||
}
|
||||
|
||||
// First-time setup: generate keys and upload
|
||||
const pair = generateX25519KeyPair();
|
||||
currentPublicKey = pair.publicKey;
|
||||
currentPrivateKey = pair.privateKey;
|
||||
await uploadPublicKey(pair.publicKey, token);
|
||||
const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey });
|
||||
await uploadBackupBlob(encodeBlob(encBlob), token);
|
||||
|
||||
saveKeys(pair.publicKey, pair.privateKey);
|
||||
|
||||
return pair;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the client already has a keypair (e.g. from localStorage) but the server has no public key row,
|
||||
* upload the public key. Covers failed uploads during login, DB resets, and legacy accounts.
|
||||
*/
|
||||
export async function syncPublicKeyToServerIfMissing(token: string): Promise<void> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys?.publicKey?.length || !keys?.privateKey?.length) {
|
||||
return;
|
||||
}
|
||||
const serverPk = await fetchPublicKey(token);
|
||||
if (serverPk) {
|
||||
return;
|
||||
}
|
||||
await uploadPublicKey(keys.publicKey, token);
|
||||
}
|
||||
|
||||
export function restoreKeys() {
|
||||
currentPublicKey = ub64(localStorage.getItem("publicKey")!);
|
||||
currentPrivateKey = ub64(localStorage.getItem("privateKey")!);
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
return localStorage.getItem("authToken");
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's password
|
||||
*/
|
||||
export async function changePassword(
|
||||
token: string,
|
||||
username: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
logoutAllExceptCurrent: boolean
|
||||
): Promise<void> {
|
||||
const currentDerived = await deriveAuthSecret(username, currentPassword);
|
||||
const newDerived = await deriveAuthSecret(username, newPassword);
|
||||
const res = await fetch(`${API_BASE_URL}/change-password`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({
|
||||
currentPasswordDerived: currentDerived,
|
||||
newPasswordDerived: newDerived,
|
||||
logoutAllExceptCurrent
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to change password");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the current user's account
|
||||
*/
|
||||
export async function deleteAccount(token: string): Promise<{ status: string; message: string }> {
|
||||
const res = await fetch(`${API_BASE_URL}/account/delete`, {
|
||||
method: "POST",
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
|
||||
throw new Error(error.detail || "Failed to delete account");
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
|
||||
export interface DeviceInfo {
|
||||
session_id: string;
|
||||
device_name?: string;
|
||||
device_type?: string;
|
||||
os_name?: string;
|
||||
os_version?: string;
|
||||
browser_name?: string;
|
||||
browser_version?: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
created_at?: string;
|
||||
last_seen?: string;
|
||||
revoked?: boolean;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export async function list(token: string): Promise<DeviceInfo[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to fetch devices");
|
||||
const data = await res.json();
|
||||
return data.devices as DeviceInfo[];
|
||||
}
|
||||
|
||||
export async function revoke(token: string, sessionId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to revoke device");
|
||||
}
|
||||
|
||||
export async function revokeAll(token: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) throw new Error("Failed to logout all devices");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { UserProfile } from "@/core/types";
|
||||
|
||||
export interface ProfileData {
|
||||
profile_picture?: string;
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
profile_picture_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user profile data from the server
|
||||
*/
|
||||
export async function get(token: string): Promise<ProfileData | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Map backend fields to frontend fields
|
||||
return {
|
||||
profile_picture: data.profile_picture,
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.bio
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile picture to the server
|
||||
*/
|
||||
export async function uploadPicture(token: string, file: Blob): Promise<UploadResponse | null> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', file, 'profile_picture.jpg');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: getAuthHeaders(token, false)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user profile information
|
||||
*/
|
||||
export async function update(token: string, data: Partial<ProfileData>): Promise<boolean> {
|
||||
try {
|
||||
// Map frontend fields to backend fields
|
||||
const backendData = {
|
||||
username: data.username,
|
||||
display_name: data.display_name,
|
||||
description: data.description
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/user/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(token, true),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(backendData)
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user bio
|
||||
*/
|
||||
export async function updateBio(token: string, bio: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/bio`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(token, true),
|
||||
body: JSON.stringify({ bio })
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Error updating bio:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by username
|
||||
*/
|
||||
export async function fetchByUsername(token: string, username: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/${username}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches user profile data by user ID
|
||||
*/
|
||||
export async function fetchById(token: string, userId: number): Promise<UserProfile | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
import type { User } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Fetches a list of all users (excluding current user)
|
||||
*/
|
||||
export async function fetchUsers(token: string): Promise<User[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for users by username query
|
||||
*/
|
||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a user by ID
|
||||
*/
|
||||
export async function get(userId: number, token: string): Promise<User | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/users/${userId}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import type { User } from "@/core/types";
|
||||
|
||||
/**
|
||||
* Fetches a list of all users (excluding current user)
|
||||
*/
|
||||
export async function fetchUsers(token: string): Promise<User[]> {
|
||||
const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for users by username query
|
||||
*/
|
||||
export async function searchUsers(query: string, token: string): Promise<User[]> {
|
||||
if (query.length < 2) return [];
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||
headers: getAuthHeaders(token, true)
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.users || [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Java [String.hashCode] for cross-platform parity with Android avatar gradients. */
|
||||
function javaStringHashCode(value: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (Math.imul(31, hash) + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function rgbFromHash(hash: number, offset: number): string {
|
||||
const r = Math.abs(hash % 256);
|
||||
const g = Math.abs(Math.floor(hash / 256) % 256);
|
||||
const b = Math.abs(Math.floor(hash / 65536) % 256);
|
||||
const clamp = (channel: number) => Math.min(255, Math.max(0, channel));
|
||||
return `rgb(${clamp(r + offset)}, ${clamp(g + offset)}, ${clamp(b + offset)})`;
|
||||
}
|
||||
|
||||
/** CSS linear-gradient matching [generateGradientFromName] on Android for a user id seed. */
|
||||
export function avatarGradientFromUserId(userId: number): string {
|
||||
const hash = javaStringHashCode(String(userId));
|
||||
return `linear-gradient(135deg, ${rgbFromHash(hash, 100)}, ${rgbFromHash(hash, 50)})`;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* E2EE Worker for WebRTC Insertable Streams
|
||||
* Encrypts/decrypts encoded audio and video frames using AES-GCM
|
||||
* Uses RTP timestamps for IVs to handle out-of-order and dropped frames
|
||||
*/
|
||||
|
||||
export interface FrameMetadata {
|
||||
contributingSources?: number[];
|
||||
mimeType?: string;
|
||||
payloadType?: number;
|
||||
rtpTimestamp: number;
|
||||
synchronizationSource: number;
|
||||
dependencies?: number[];
|
||||
frameId?: number;
|
||||
spatialIndex?: number;
|
||||
temporalIndex?: number;
|
||||
}
|
||||
|
||||
export interface EncodedFrame {
|
||||
data: Uint8Array | ArrayBuffer;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
getMetadata?: () => FrameMetadata;
|
||||
}
|
||||
|
||||
export interface WorkerOptions {
|
||||
key: CryptoKey;
|
||||
mode: 'encrypt' | 'decrypt';
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sequence number from encoded frame
|
||||
* For RTCEncodedVideoFrame/AudioFrame, we use the frame's metadata if available,
|
||||
* otherwise fall back to extracting from RTP header
|
||||
*/
|
||||
function makeIV(encodedFrame: EncodedFrame): ArrayBuffer {
|
||||
// Create IV using ONLY RTP metadata - this ensures sender and receiver use identical IVs
|
||||
// Frame data can differ between sender/receiver due to encoding differences
|
||||
const ivBuffer = new ArrayBuffer(12);
|
||||
const view = new DataView(ivBuffer);
|
||||
|
||||
if (encodedFrame.getMetadata) {
|
||||
try {
|
||||
const metadata = encodedFrame.getMetadata();
|
||||
if (metadata && typeof metadata.rtpTimestamp === 'number') {
|
||||
// Use ONLY RTP timestamp + sync source - these are identical on both sides
|
||||
view.setUint32(0, metadata.rtpTimestamp, false); // First 4 bytes
|
||||
view.setUint32(4, metadata.synchronizationSource || 0, false); // Middle 4 bytes
|
||||
view.setUint32(8, 0, false); // Last 4 bytes (padding for 12-byte IV)
|
||||
|
||||
return ivBuffer;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to get metadata:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use timestamp only (no random to avoid desync)
|
||||
view.setUint32(0, Date.now() & 0xFFFFFFFF, false);
|
||||
view.setUint32(4, 0, false);
|
||||
view.setUint32(8, 0, false);
|
||||
return ivBuffer;
|
||||
}
|
||||
|
||||
addEventListener("rtctransform", (event) => {
|
||||
const { transformer } = event;
|
||||
const { readable, writable } = transformer;
|
||||
const { key, mode } = transformer.options as WorkerOptions;
|
||||
|
||||
const isEncrypting = mode === 'encrypt';
|
||||
|
||||
let frameCount = 0;
|
||||
|
||||
async function transform(encodedFrame: EncodedFrame, controller: TransformStreamDefaultController<EncodedFrame>) {
|
||||
try {
|
||||
const data = new Uint8Array(encodedFrame.data);
|
||||
|
||||
// Increment frame counter
|
||||
frameCount++;
|
||||
|
||||
// Create IV using RTP timestamp from metadata (synchronized between peers)
|
||||
const iv = makeIV(encodedFrame);
|
||||
|
||||
// Ensure IV is properly typed
|
||||
const ivArray = new Uint8Array(iv);
|
||||
const params: AesGcmParams = { name: 'AES-GCM', iv: ivArray };
|
||||
|
||||
// COMPROMISE: Encrypt most of the frame while preserving minimal codec compatibility
|
||||
// This prevents most visual leakage while maintaining decodability
|
||||
let headerSize = 0;
|
||||
let payloadData: Uint8Array;
|
||||
|
||||
if (data.length > 20) {
|
||||
// For video frames, preserve first 8 bytes for better codec compatibility
|
||||
// This includes frame type, keyframe info, and basic header structure
|
||||
headerSize = Math.min(8, Math.floor(data.length / 10));
|
||||
payloadData = data.slice(headerSize);
|
||||
} else {
|
||||
// For small frames (likely audio), encrypt everything
|
||||
payloadData = data;
|
||||
}
|
||||
|
||||
// Encrypt the payload data
|
||||
const payloadBuffer = new ArrayBuffer(payloadData.byteLength);
|
||||
new Uint8Array(payloadBuffer).set(payloadData);
|
||||
|
||||
let encryptedPayload: ArrayBuffer;
|
||||
if (isEncrypting) {
|
||||
encryptedPayload = await crypto.subtle.encrypt(params, key, payloadBuffer);
|
||||
} else {
|
||||
try {
|
||||
encryptedPayload = await crypto.subtle.decrypt(params, key, payloadBuffer);
|
||||
} catch (error) {
|
||||
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, error);
|
||||
return; // Drop the frame
|
||||
}
|
||||
}
|
||||
|
||||
// Reconstruct frame: minimal headers + encrypted payload
|
||||
const encryptedArray = new Uint8Array(encryptedPayload);
|
||||
const result = new Uint8Array(headerSize + encryptedArray.length);
|
||||
|
||||
if (headerSize > 0) {
|
||||
result.set(data.slice(0, headerSize), 0); // Copy minimal headers
|
||||
result.set(encryptedArray, headerSize); // Add encrypted payload
|
||||
} else {
|
||||
result.set(encryptedArray, 0);
|
||||
}
|
||||
|
||||
// CRITICAL: Video frames need ArrayBuffer, not Uint8Array
|
||||
encodedFrame.data = result.buffer;
|
||||
controller.enqueue(encodedFrame);
|
||||
} catch (e) {
|
||||
// FAIL SECURELY: Never send unencrypted frames
|
||||
const data = new Uint8Array(encodedFrame.data);
|
||||
console.error(`E2EE ${mode} FAILED - dropping frame #${frameCount}, size: ${data.length}`, e);
|
||||
return; // Drop the frame completely
|
||||
}
|
||||
}
|
||||
|
||||
readable
|
||||
.pipeThrough(new TransformStream({ transform }))
|
||||
.pipeTo(writable);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import api from "@/core/api";
|
||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
||||
|
||||
export interface CallSessionKey {
|
||||
key: Uint8Array;
|
||||
hash: string; // For emoji display
|
||||
}
|
||||
|
||||
export interface CallKeyExchange {
|
||||
type: "call_key_exchange";
|
||||
sessionKeyHash: string;
|
||||
encryptedSessionKey: EncryptedCallMessage;
|
||||
}
|
||||
|
||||
export interface EncryptedCallMessage {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrappedSessionKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new call session key for end-to-end encryption
|
||||
* @returns Promise that resolves to a session key with its hash for display
|
||||
*/
|
||||
export async function generateCallSessionKey(): Promise<CallSessionKey> {
|
||||
// Generate session key material
|
||||
const sessionKeyMaterial = randomBytes(32);
|
||||
|
||||
// Generate hash for emoji display (first 4 bytes of SHA-256 hash)
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", sessionKeyMaterial.buffer as ArrayBuffer);
|
||||
const hash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
||||
|
||||
return {
|
||||
key: sessionKeyMaterial,
|
||||
hash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a session key by generating a completely new key
|
||||
* This provides forward secrecy for long-running calls
|
||||
*/
|
||||
export async function rotateCallSessionKey(): Promise<CallSessionKey> {
|
||||
// Generate new session key material (completely independent of current key)
|
||||
const newSessionKeyMaterial = randomBytes(32);
|
||||
|
||||
// Generate new hash for emoji display
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", newSessionKeyMaterial.buffer as ArrayBuffer);
|
||||
const newHash = b64(new Uint8Array(hashBuffer.slice(0, 4)));
|
||||
|
||||
return {
|
||||
key: newSessionKeyMaterial,
|
||||
hash: newHash
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Derive session key from ECDH shared secret and session key hash
|
||||
* This creates a deterministic but cryptographically secure key
|
||||
*/
|
||||
export async function deriveCallSessionKeyFromSharedSecret(
|
||||
sharedSecret: Uint8Array,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
// Use HKDF to derive the session key from the shared secret
|
||||
// Include the session key hash and role to ensure uniqueness
|
||||
const info = new TextEncoder().encode(`call-session-${sessionKeyHash}-${isInitiator ? 'initiator' : 'receiver'}`);
|
||||
const salt = new Uint8Array(32); // Zero salt for deterministic derivation
|
||||
|
||||
// Import the shared secret as a raw key for HKDF
|
||||
const sharedKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
sharedSecret.buffer as ArrayBuffer,
|
||||
{ name: 'HKDF' },
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
// Derive the session key using HKDF
|
||||
const sessionKey = await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: salt,
|
||||
info: info
|
||||
},
|
||||
sharedKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, // Make the key extractable so we can export it
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export the raw key material
|
||||
const sessionKeyMaterial = await crypto.subtle.exportKey('raw', sessionKey);
|
||||
|
||||
return {
|
||||
key: new Uint8Array(sessionKeyMaterial),
|
||||
hash: sessionKeyHash
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a call signaling message with the session key
|
||||
*/
|
||||
export async function encryptCallMessage(message: Record<string, unknown>, sessionKey: Uint8Array): Promise<EncryptedCallMessage> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const encrypted = await aesGcmEncrypt(messageKey, new TextEncoder().encode(JSON.stringify(message)));
|
||||
|
||||
return {
|
||||
iv: b64(encrypted.iv),
|
||||
ciphertext: b64(encrypted.ciphertext),
|
||||
salt: "", // Not used for message encryption, only for key wrapping
|
||||
iv2: "",
|
||||
wrappedSessionKey: ""
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a call signaling message
|
||||
*/
|
||||
export async function decryptCallMessage(encryptedMessage: EncryptedCallMessage, sessionKey: Uint8Array): Promise<Record<string, unknown>> {
|
||||
const messageKey = await importAesGcmKey(sessionKey);
|
||||
const decrypted = await aesGcmDecrypt(messageKey, ub64(encryptedMessage.iv), ub64(encryptedMessage.ciphertext));
|
||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 4 emojis representing the call session key
|
||||
*/
|
||||
export function generateCallEmojis(sessionKeyHash: string): string[] {
|
||||
// Convert hash to numbers and map to emoji ranges
|
||||
const hashBytes = new Uint8Array(ub64(sessionKeyHash));
|
||||
const emojis: string[] = [];
|
||||
|
||||
// Different emoji categories for variety
|
||||
const emojiSets = [
|
||||
["🎵", "🎶", "🎤", "🎧", "🎼", "🎹", "🥁", "🎺", "🎸", "🎻"], // Music
|
||||
["🔥", "💫", "⭐", "✨", "🌟", "💥", "⚡", "🌈", "🎆", "🎇"], // Energy
|
||||
["🚀", "🛸", "🛰️", "🌌", "🔭", "⚙️", "🔧", "⚡", "💡", "🔬"], // Tech/Space
|
||||
["🎭", "🎪", "🎨", "🎬", "📷", "🎥", "📺", "🎮", "🕹️", "🎯"] // Entertainment
|
||||
];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const set = emojiSets[i % emojiSets.length];
|
||||
const index = hashBytes[i % hashBytes.length] % set.length;
|
||||
emojis.push(set[index]);
|
||||
}
|
||||
|
||||
return emojis;
|
||||
}
|
||||
|
||||
// HKDF info for CALL key wrapping (distinct from DM's info)
|
||||
const CALL_INFO = new Uint8Array([2]);
|
||||
|
||||
/**
|
||||
* Wraps a call session key for a specific recipient using ECDH key exchange
|
||||
* @param recipientPublicKeyB64 - The recipient's public key in base64 format
|
||||
* @param sessionKey - The session key to wrap
|
||||
* @returns Promise that resolves to the wrapped session key payload
|
||||
*/
|
||||
export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise<WrappedSessionKeyPayload> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const salt = randomBytes(16);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const wrap = await aesGcmEncrypt(wk, sessionKey);
|
||||
return {
|
||||
salt: b64(salt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrapped: b64(wrap.ciphertext)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a shared secret and derive session key for the receiver
|
||||
*/
|
||||
export async function createSharedSecretAndDeriveSessionKey(
|
||||
senderPublicKeyB64: string,
|
||||
sessionKeyHash: string,
|
||||
isInitiator: boolean
|
||||
): Promise<CallSessionKey> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Create shared secret using ECDH
|
||||
const sharedSecret = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
|
||||
// Derive the session key from the shared secret
|
||||
return await deriveCallSessionKeyFromSharedSecret(sharedSecret, sessionKeyHash, isInitiator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a call session key received from a sender using ECDH key exchange
|
||||
* @param senderPublicKeyB64 - The sender's public key in base64 format
|
||||
* @param payload - The wrapped session key payload
|
||||
* @returns Promise that resolves to the unwrapped session key
|
||||
*/
|
||||
export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise<Uint8Array> {
|
||||
const keys = api.user.auth.getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const salt = ub64(payload.salt);
|
||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
||||
const wkRaw = await deriveWrappingKey(shared, salt, CALL_INFO);
|
||||
const wk = await importAesGcmKey(wkRaw);
|
||||
const sessionKey = await aesGcmDecrypt(wk, ub64(payload.iv2), ub64(payload.wrapped));
|
||||
return new Uint8Array(sessionKey);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { CallSignalingMessage, CallAcceptData, CallRejectData, CallOfferData, CallAnswerData, CallIceCandidateData, CallEndData, CallVideoToggleData, CallScreenShareToggleData, CallInviteMessageData } from "@/core/types";
|
||||
import * as WebRTC from "./webrtc";
|
||||
|
||||
export interface CallState {
|
||||
receiveCall: (userId: number, username: string) => void;
|
||||
endCall: () => void;
|
||||
setCallSessionKeyHash: (sessionKeyHash: string) => void;
|
||||
setRemoteVideoEnabled: (enabled: boolean) => void;
|
||||
setRemoteScreenSharing: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming WebSocket messages related to call signaling
|
||||
*/
|
||||
export class CallSignalingHandler {
|
||||
private getState: () => CallState;
|
||||
|
||||
constructor(getState: () => CallState) {
|
||||
this.getState = getState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes incoming call signaling messages to appropriate handlers
|
||||
*/
|
||||
handleWebSocketMessage(message: CallSignalingMessage) {
|
||||
const { data } = message;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "call_invite":
|
||||
this.handleCallInvite(message, data as CallInviteMessageData);
|
||||
break;
|
||||
case "call_accept":
|
||||
this.handleCallAccept(data as CallAcceptData);
|
||||
break;
|
||||
case "call_reject":
|
||||
this.handleCallReject(data as CallRejectData);
|
||||
break;
|
||||
case "call_offer":
|
||||
this.handleCallOffer(message, data as CallOfferData);
|
||||
break;
|
||||
case "call_answer":
|
||||
this.handleCallAnswer(message, data as CallAnswerData);
|
||||
break;
|
||||
case "call_ice_candidate":
|
||||
this.handleIceCandidate(message, data as CallIceCandidateData);
|
||||
break;
|
||||
case "call_end":
|
||||
this.handleCallEnd(data as CallEndData);
|
||||
break;
|
||||
case "call_session_key":
|
||||
this.handleCallSessionKey(message);
|
||||
break;
|
||||
case "call_video_toggle":
|
||||
this.handleVideoToggle(message, data as CallVideoToggleData);
|
||||
break;
|
||||
case "call_screen_share_toggle":
|
||||
this.handleScreenShareToggle(message, data as CallScreenShareToggleData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming call invitation
|
||||
*/
|
||||
private async handleCallInvite(message: CallSignalingMessage, data: CallInviteMessageData) {
|
||||
const { fromUsername } = data;
|
||||
const fromUserId = message.fromUserId;
|
||||
const state = this.getState();
|
||||
|
||||
// First, create the peer connection in WebRTC service
|
||||
await WebRTC.handleIncomingCall(fromUserId, fromUsername);
|
||||
|
||||
// Then show incoming call UI
|
||||
state.receiveCall(fromUserId, fromUsername);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles call acceptance from remote peer
|
||||
*/
|
||||
private async handleCallAccept(data: CallAcceptData) {
|
||||
const { fromUserId } = data;
|
||||
// Initiator should create and send offer now
|
||||
try {
|
||||
await WebRTC.onRemoteAccepted(fromUserId);
|
||||
} catch (error) {
|
||||
console.error("Failed to proceed after accept:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles call rejection from remote peer
|
||||
*/
|
||||
private handleCallReject(data: CallRejectData) {
|
||||
const state = this.getState();
|
||||
const { fromUserId } = data;
|
||||
|
||||
// Clean up WebRTC connection first
|
||||
if (fromUserId) {
|
||||
WebRTC.cleanupCall(fromUserId);
|
||||
}
|
||||
|
||||
// End the call
|
||||
state.endCall();
|
||||
}
|
||||
|
||||
private async handleCallOffer(message: CallSignalingMessage, data: CallOfferData) {
|
||||
await WebRTC.handleCallOffer(message.fromUserId, data);
|
||||
}
|
||||
|
||||
private async handleCallAnswer(message: CallSignalingMessage, data: CallAnswerData) {
|
||||
await WebRTC.handleCallAnswer(message.fromUserId, data);
|
||||
}
|
||||
|
||||
private async handleIceCandidate(message: CallSignalingMessage, data: CallIceCandidateData) {
|
||||
await WebRTC.handleIceCandidate(message.fromUserId, data);
|
||||
}
|
||||
|
||||
private handleCallEnd(data: CallEndData) {
|
||||
const state = this.getState();
|
||||
const { fromUserId } = data;
|
||||
|
||||
// Clean up WebRTC connection first
|
||||
if (fromUserId) {
|
||||
WebRTC.cleanupCall(fromUserId);
|
||||
}
|
||||
|
||||
// End the call
|
||||
state.endCall();
|
||||
}
|
||||
|
||||
private handleCallSessionKey(message: CallSignalingMessage) {
|
||||
const state = this.getState();
|
||||
const { sessionKeyHash, data } = message;
|
||||
if (sessionKeyHash) {
|
||||
state.setCallSessionKeyHash(sessionKeyHash);
|
||||
}
|
||||
if (data && 'wrappedSessionKey' in data && data.wrappedSessionKey && message.fromUserId) {
|
||||
WebRTC.receiveWrappedSessionKey(message.fromUserId, data.wrappedSessionKey, sessionKeyHash);
|
||||
}
|
||||
}
|
||||
|
||||
private handleVideoToggle(message: CallSignalingMessage, data: CallVideoToggleData) {
|
||||
const state = this.getState();
|
||||
|
||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
||||
// Update Zustand state (for UI)
|
||||
state.setRemoteVideoEnabled(data.enabled);
|
||||
// Update WebRTC internal state (for track routing)
|
||||
WebRTC.setRemoteVideoEnabled(message.fromUserId, data.enabled);
|
||||
} else {
|
||||
console.warn("Invalid toggle data:", data);
|
||||
}
|
||||
}
|
||||
|
||||
private handleScreenShareToggle(message: CallSignalingMessage, data: CallScreenShareToggleData) {
|
||||
const state = this.getState();
|
||||
|
||||
if (data && typeof data.enabled === "boolean" && message.fromUserId) {
|
||||
// Update Zustand state (for UI)
|
||||
state.setRemoteScreenSharing(data.enabled);
|
||||
// Update WebRTC internal state (for track routing)
|
||||
WebRTC.setRemoteScreenSharing(message.fromUserId, data.enabled);
|
||||
} else {
|
||||
console.warn("Invalid toggle data:", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { StyledDialog } from "./StyledDialog";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
import styles from "./css/alert-dialog.module.scss";
|
||||
|
||||
interface AlertDialogState {
|
||||
open: boolean;
|
||||
message: string;
|
||||
resolve: (() => void) | null;
|
||||
}
|
||||
|
||||
let alertState: AlertDialogState = {
|
||||
open: false,
|
||||
message: "",
|
||||
resolve: null
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners() {
|
||||
listeners.forEach(listener => listener());
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for window.alert() using StyledDialog
|
||||
* @param message - The message to display
|
||||
* @returns Promise that resolves when the dialog is closed
|
||||
*/
|
||||
export function alert(message: string): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
alertState = {
|
||||
open: true,
|
||||
message,
|
||||
resolve: () => {
|
||||
alertState.open = false;
|
||||
alertState.message = "";
|
||||
alertState.resolve = null;
|
||||
notifyListeners();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal component that renders the alert dialog
|
||||
*/
|
||||
export function AlertDialogProvider() {
|
||||
const [, setUpdateKey] = useState(0);
|
||||
|
||||
const update = useCallback(() => {
|
||||
setUpdateKey(prev => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
listeners.add(update);
|
||||
return () => {
|
||||
listeners.delete(update);
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (alertState.resolve) {
|
||||
alertState.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
open={alertState.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
onBackdropClick={handleClose}
|
||||
className={styles.alertDialog}
|
||||
contentClassName={styles.alertDialogContent}
|
||||
>
|
||||
<div className={styles.alertDialogMessage}>
|
||||
{alertState.message}
|
||||
</div>
|
||||
<div className={styles.alertDialogActions}>
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
onClick={handleClose}
|
||||
>
|
||||
OK
|
||||
</MaterialButton>
|
||||
</div>
|
||||
</StyledDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import useCombinedRefs from '@/core/hooks/useCombinedRefs';
|
||||
import { id } from '@/utils/utils';
|
||||
|
||||
interface AutoResizeInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
autoresizing?: true;
|
||||
placeholderMinWidth?: boolean;
|
||||
onAutosize?: (width: number) => void;
|
||||
}
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
autoresizing?: false;
|
||||
placeholderMinWidth?: false;
|
||||
onAutosize?: undefined;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
autoresizing = false,
|
||||
placeholderMinWidth = false,
|
||||
onAutosize,
|
||||
style: inputStyle,
|
||||
...inputProps
|
||||
}: AutoResizeInputProps | InputProps) {
|
||||
const [inputWidth, setInputWidth] = useState(0);
|
||||
|
||||
const sizerRef = useRef<HTMLDivElement>(null);
|
||||
const placeholderSizerRef = useRef<HTMLDivElement>(null);
|
||||
const [inputRef, inputElement] = useCombinedRefs<HTMLInputElement>();
|
||||
|
||||
const sizerStyle: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
visibility: 'hidden',
|
||||
height: 0,
|
||||
overflow: 'scroll',
|
||||
whiteSpace: 'pre',
|
||||
};
|
||||
|
||||
const copyStyles = useCallback((styles: CSSStyleDeclaration, node: HTMLElement) => {
|
||||
node.style.fontSize = styles.fontSize;
|
||||
node.style.fontFamily = styles.fontFamily;
|
||||
node.style.fontWeight = styles.fontWeight;
|
||||
node.style.fontStyle = styles.fontStyle;
|
||||
node.style.letterSpacing = styles.letterSpacing;
|
||||
node.style.textTransform = styles.textTransform;
|
||||
}, []);
|
||||
|
||||
const updateInputWidth = useCallback(() => {
|
||||
if (!sizerRef.current || typeof sizerRef.current.scrollWidth === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let newInputWidth: number;
|
||||
|
||||
if (inputProps.placeholder && (!inputProps.value || (inputProps.value && placeholderMinWidth))) {
|
||||
const sizerWidth = sizerRef.current.scrollWidth;
|
||||
const placeholderWidth = placeholderSizerRef.current?.scrollWidth || 0;
|
||||
newInputWidth = Math.max(sizerWidth, placeholderWidth) + 2;
|
||||
} else {
|
||||
newInputWidth = sizerRef.current.scrollWidth + 2;
|
||||
}
|
||||
|
||||
|
||||
if (newInputWidth !== inputWidth) {
|
||||
setInputWidth(newInputWidth);
|
||||
onAutosize?.(newInputWidth);
|
||||
}
|
||||
}, [inputProps.placeholder, inputProps.value, inputProps.type, placeholderMinWidth, inputWidth, onAutosize]);
|
||||
|
||||
const copyInputStyles = useCallback(() => {
|
||||
if (!inputElement.current || !window.getComputedStyle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputStyles = window.getComputedStyle(inputElement.current);
|
||||
if (!inputStyles) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyStyles(inputStyles, sizerRef.current!);
|
||||
if (placeholderSizerRef.current) {
|
||||
copyStyles(inputStyles, placeholderSizerRef.current);
|
||||
}
|
||||
}, [inputElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoresizing) {
|
||||
copyInputStyles();
|
||||
updateInputWidth();
|
||||
}
|
||||
}, [autoresizing, copyInputStyles, updateInputWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoresizing) {
|
||||
updateInputWidth();
|
||||
}
|
||||
}, [inputProps.value, inputProps.placeholder, autoresizing, updateInputWidth]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
width: autoresizing ? `${inputWidth}px` : undefined,
|
||||
...inputStyle,
|
||||
}}
|
||||
/>
|
||||
{autoresizing && createPortal(
|
||||
<>
|
||||
<div ref={sizerRef} style={sizerStyle}>
|
||||
{inputProps.defaultValue || inputProps.value || ''}
|
||||
</div>
|
||||
{inputProps.placeholder && (
|
||||
<div ref={placeholderSizerRef} style={sizerStyle}>
|
||||
{inputProps.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
id("root")
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface QuoteProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
background?: "surfaceContainer" | "primaryContainer"
|
||||
}
|
||||
|
||||
export default function Quote({ className, children, background = "primaryContainer" }: QuoteProps) {
|
||||
return (
|
||||
<div className={`quote bg-${background} ${className}`}>
|
||||
<div className="quote-inner">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useCallback, useLayoutEffect } from "react";
|
||||
|
||||
interface RichTextAreaProps {
|
||||
text: string;
|
||||
onTextChange: (value: string) => void;
|
||||
onEnter?: "newLine" | null | ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void);
|
||||
onCtrlEnter?: ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void) | null;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
rows?: number;
|
||||
autoComplete?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function RichTextArea({
|
||||
text,
|
||||
onTextChange,
|
||||
onEnter = "newLine",
|
||||
onCtrlEnter = null,
|
||||
placeholder,
|
||||
className,
|
||||
rows = 1,
|
||||
autoComplete = "off",
|
||||
readOnly = false
|
||||
}: RichTextAreaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const heightRef = useRef<number | null>(null);
|
||||
|
||||
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
|
||||
const raw = computedStyle[prop] as string | number | undefined;
|
||||
if (raw == null) return 0;
|
||||
const str = String(raw);
|
||||
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
|
||||
}
|
||||
|
||||
const calculateTextareaStyles = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const hidden = hiddenTextareaRef.current;
|
||||
if (!textarea || !hidden) return undefined;
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
if (computedStyle.width === "0px") {
|
||||
return { outerHeightStyle: 0, overflowing: false };
|
||||
}
|
||||
|
||||
// Ensure hidden textarea copies width but not percentage-based anomalies from parents
|
||||
// Normalize hidden textarea to avoid inherited constraints and copy critical metrics
|
||||
hidden.style.position = "fixed";
|
||||
hidden.style.top = "-9999px";
|
||||
hidden.style.left = "-9999px";
|
||||
hidden.style.visibility = "hidden";
|
||||
hidden.style.height = "auto";
|
||||
hidden.style.minHeight = "0";
|
||||
hidden.style.maxHeight = "none";
|
||||
hidden.style.overflow = "hidden";
|
||||
hidden.style.boxSizing = computedStyle.boxSizing;
|
||||
// Avoid counting vertical padding twice: keep 0 for measurement
|
||||
hidden.style.paddingTop = "0";
|
||||
hidden.style.paddingBottom = "0";
|
||||
hidden.style.paddingLeft = computedStyle.paddingLeft;
|
||||
hidden.style.paddingRight = computedStyle.paddingRight;
|
||||
// Do not include borders in the inner scrollHeight measurement
|
||||
hidden.style.borderTopWidth = "0";
|
||||
hidden.style.borderBottomWidth = "0";
|
||||
hidden.style.borderLeftWidth = computedStyle.borderLeftWidth;
|
||||
hidden.style.borderRightWidth = computedStyle.borderRightWidth;
|
||||
hidden.style.fontFamily = computedStyle.fontFamily;
|
||||
hidden.style.fontSize = computedStyle.fontSize;
|
||||
hidden.style.fontWeight = computedStyle.fontWeight;
|
||||
hidden.style.lineHeight = computedStyle.lineHeight;
|
||||
hidden.style.letterSpacing = computedStyle.letterSpacing;
|
||||
hidden.style.whiteSpace = computedStyle.whiteSpace;
|
||||
hidden.style.wordSpacing = computedStyle.wordSpacing;
|
||||
hidden.style.textIndent = computedStyle.textIndent;
|
||||
hidden.style.textTransform = computedStyle.textTransform;
|
||||
hidden.style.textDecoration = computedStyle.textDecoration;
|
||||
hidden.style.width = computedStyle.width;
|
||||
hidden.style.maxWidth = computedStyle.width;
|
||||
hidden.value = textarea.value || placeholder || "x";
|
||||
if (hidden.value.slice(-1) === "\n") {
|
||||
hidden.value += " ";
|
||||
}
|
||||
|
||||
const boxSizing = computedStyle.boxSizing;
|
||||
const padding = getStyleValue(computedStyle, "paddingBottom") + getStyleValue(computedStyle, "paddingTop");
|
||||
const border = getStyleValue(computedStyle, "borderBottomWidth") + getStyleValue(computedStyle, "borderTopWidth");
|
||||
|
||||
const innerHeight = hidden.scrollHeight;
|
||||
|
||||
hidden.value = "x";
|
||||
const singleRowHeight = hidden.scrollHeight;
|
||||
|
||||
let outerHeight = innerHeight;
|
||||
const minRows = Number(rows || 1);
|
||||
if (minRows) {
|
||||
outerHeight = Math.max(minRows * singleRowHeight, outerHeight);
|
||||
}
|
||||
outerHeight = Math.max(outerHeight, singleRowHeight);
|
||||
|
||||
// Use ceil to avoid sub-pixel gaps and subtract a tiny epsilon to reduce visual gap
|
||||
let outerHeightStyle = outerHeight + (boxSizing === "border-box" ? padding + border : 0);
|
||||
outerHeightStyle = Math.round(outerHeightStyle); // snap to pixel to avoid half-line gaps
|
||||
const overflowing = Math.abs(outerHeight - innerHeight) <= 1;
|
||||
return { outerHeightStyle, overflowing };
|
||||
}, [rows, placeholder]);
|
||||
|
||||
const syncHeight = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const styles = calculateTextareaStyles();
|
||||
if (!textarea || !styles) return;
|
||||
const { outerHeightStyle, overflowing } = styles;
|
||||
if (heightRef.current !== outerHeightStyle) {
|
||||
heightRef.current = outerHeightStyle;
|
||||
textarea.style.height = `${outerHeightStyle}px`;
|
||||
}
|
||||
textarea.style.overflowY = overflowing ? "hidden" : "";
|
||||
}, [calculateTextareaStyles]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncHeight();
|
||||
}, [syncHeight, text]);
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const onResize = () => syncHeight();
|
||||
window.addEventListener("resize", onResize);
|
||||
let ro: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
ro = new ResizeObserver(() => {
|
||||
ro!.unobserve(textarea);
|
||||
syncHeight();
|
||||
requestAnimationFrame(() => ro && textarea && ro.observe(textarea));
|
||||
});
|
||||
ro.observe(textarea);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
if (ro) ro.disconnect();
|
||||
};
|
||||
}, [syncHeight]);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
// Keep height responsive during rapid uncontrolled input bursts
|
||||
syncHeight();
|
||||
onTextChange(e.target.value);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
const isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey);
|
||||
const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
|
||||
|
||||
if (isCtrlEnter) {
|
||||
e.preventDefault();
|
||||
if (typeof onCtrlEnter === "function") {
|
||||
onCtrlEnter(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainEnter) {
|
||||
if (onEnter === "newLine") {
|
||||
// allow default
|
||||
return;
|
||||
}
|
||||
if (onEnter === null) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (typeof onEnter === "function") {
|
||||
e.preventDefault();
|
||||
onEnter(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
className={`rich-text-area ${className}`}
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
autoComplete={readOnly ? "off" : autoComplete}
|
||||
onChange={readOnly ? undefined : handleChange}
|
||||
onKeyDown={readOnly ? undefined : handleKeyDown}
|
||||
readOnly={readOnly} />
|
||||
<textarea
|
||||
aria-hidden
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
ref={hiddenTextareaRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "-9999px",
|
||||
left: "-9999px",
|
||||
visibility: "hidden",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
height: "auto",
|
||||
minHeight: 0,
|
||||
maxHeight: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import styles from "./css/searchBar.module.scss";
|
||||
import { MaterialIcon, type MDUIBottomAppBar } from "@/utils/material";
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder: string;
|
||||
children?: React.ReactNode;
|
||||
searchQuery: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
isExpanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
leftIcon?: string | React.ReactNode;
|
||||
rightIcon?: string | React.ReactNode;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
headerRef?: React.RefObject<HTMLElement | null>;
|
||||
bottomAppBarRef?: React.RefObject<MDUIBottomAppBar | null>;
|
||||
}
|
||||
|
||||
export default function SearchBar({
|
||||
placeholder,
|
||||
children,
|
||||
searchQuery,
|
||||
onQueryChange,
|
||||
isExpanded,
|
||||
onToggleExpanded,
|
||||
leftIcon = "search--outlined",
|
||||
rightIcon = null,
|
||||
containerRef,
|
||||
headerRef,
|
||||
bottomAppBarRef
|
||||
}: SearchBarProps) {
|
||||
const [dynamicHeight, setDynamicHeight] = useState<string>("48px");
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const parentContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
// Focus input when expanded and manage height
|
||||
useEffect(() => {
|
||||
if (isExpanded && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
// Set expanded height, subtracting both header and bottom app bar heights
|
||||
if (containerRef.current) {
|
||||
const panelHeight = containerRef.current.offsetHeight;
|
||||
let headerHeight = 0;
|
||||
let bottomBarHeight = 0;
|
||||
|
||||
// Get header height
|
||||
if (headerRef?.current) {
|
||||
headerHeight = headerRef.current.offsetHeight;
|
||||
}
|
||||
|
||||
// Get bottom app bar height
|
||||
if (bottomAppBarRef?.current) {
|
||||
bottomBarHeight = bottomAppBarRef.current.offsetHeight;
|
||||
}
|
||||
|
||||
// Calculate height by subtracting both header and bottom bar heights
|
||||
const availableHeight = panelHeight - headerHeight - bottomBarHeight;
|
||||
setDynamicHeight(`${availableHeight}px`);
|
||||
}
|
||||
} else {
|
||||
// Set collapsed height
|
||||
setDynamicHeight("48px");
|
||||
}
|
||||
|
||||
// Show/hide results and disable overflow during transition
|
||||
if (isExpanded) {
|
||||
setShowResults(true);
|
||||
}
|
||||
|
||||
setIsTransitioning(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setIsTransitioning(false);
|
||||
if (!isExpanded) {
|
||||
setShowResults(false);
|
||||
}
|
||||
}, 400); // Match transition duration (0.4s)
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isExpanded, containerRef, headerRef, bottomAppBarRef]);
|
||||
|
||||
function handleToggle() {
|
||||
onToggleExpanded();
|
||||
};
|
||||
|
||||
function handleQueryChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const query = e.target.value;
|
||||
onQueryChange(query);
|
||||
};
|
||||
|
||||
// Helper function to render icon
|
||||
function renderIcon(icon: string | React.ReactNode | undefined, defaultIcon?: string) {
|
||||
if (icon === null) return null;
|
||||
if (!icon) {
|
||||
return defaultIcon ? <MaterialIcon name={defaultIcon} /> : null;
|
||||
} else if (typeof icon === 'string') {
|
||||
return <MaterialIcon name={icon} />;
|
||||
} else {
|
||||
return icon;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentContainerRef}
|
||||
className={styles.searchParent}
|
||||
>
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={`${styles.searchBarContainer} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||
style={{ height: dynamicHeight }}
|
||||
onClick={!isExpanded ? handleToggle : undefined}
|
||||
>
|
||||
{/* Single Search Bar Element */}
|
||||
<div className={styles.searchBar}>
|
||||
{/* Left Icon */}
|
||||
<div className={styles.searchIcon}>
|
||||
{renderIcon(leftIcon, "search--outlined")}
|
||||
</div>
|
||||
|
||||
{/* Input/Placeholder */}
|
||||
<div className={styles.searchInputContainer}>
|
||||
{isExpanded ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
className={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChange={handleQueryChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.searchPlaceholder}>{placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Icon */}
|
||||
<div className={styles.searchClear}>
|
||||
{renderIcon(rightIcon)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Section - Visible during expansion and collapse transition */}
|
||||
{showResults && (
|
||||
<div
|
||||
className={styles.searchResults}
|
||||
style={{ overflowY: isTransitioning ? "hidden" : "auto" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material";
|
||||
import useWindowSize from "@/core/hooks/useWindowSize";
|
||||
import styles from "./css/split-button.module.scss";
|
||||
|
||||
export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated";
|
||||
|
||||
interface SplitButtonProps {
|
||||
text: ReactNode;
|
||||
icon?: ReactNode | string;
|
||||
menu: ReactNode;
|
||||
menuOpen: boolean;
|
||||
onMenuOpen: (open: boolean) => void;
|
||||
onPrimaryClick?: () => void;
|
||||
variant?: SplitButtonVariant;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
menuAriaLabel?: string;
|
||||
}
|
||||
|
||||
export function SplitButton({
|
||||
text,
|
||||
icon,
|
||||
menu,
|
||||
menuOpen: open,
|
||||
onMenuOpen,
|
||||
onPrimaryClick,
|
||||
variant = "filled",
|
||||
disabled = false,
|
||||
className = "",
|
||||
menuAriaLabel,
|
||||
}: SplitButtonProps) {
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuSegmentRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const primaryRipple = useRippleHandlers(disabled);
|
||||
const menuRipple = useRippleHandlers(disabled);
|
||||
|
||||
const MENU_GAP = 16;
|
||||
const EDGE_PAD = 16;
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
const anchor = menuSegmentRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const menuEl = menuRef.current;
|
||||
const menuWidth = menuEl?.offsetWidth ?? 220;
|
||||
const menuHeight = menuEl?.offsetHeight ?? 320;
|
||||
|
||||
const anchorCenterX = rect.left + rect.width / 2;
|
||||
let left: number;
|
||||
let top: number | undefined;
|
||||
let bottom: number | undefined;
|
||||
let maxHeight: number;
|
||||
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD;
|
||||
const availableAbove = rect.top - MENU_GAP - EDGE_PAD;
|
||||
const fitsBelow = menuHeight <= availableBelow;
|
||||
const fitsAbove = menuHeight <= availableAbove;
|
||||
const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow);
|
||||
|
||||
if (placeAbove) {
|
||||
bottom = vh - (rect.top - MENU_GAP);
|
||||
maxHeight = Math.max(100, availableAbove);
|
||||
} else {
|
||||
top = rect.bottom + MENU_GAP;
|
||||
maxHeight = Math.max(100, availableBelow);
|
||||
}
|
||||
|
||||
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
|
||||
left = EDGE_PAD;
|
||||
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
|
||||
left = vw - menuWidth - EDGE_PAD;
|
||||
} else {
|
||||
left = anchorCenterX - menuWidth / 2;
|
||||
}
|
||||
|
||||
setMenuPosition({ top, bottom, left, maxHeight });
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
onMenuOpen(false);
|
||||
setIsExiting(true);
|
||||
}, [onMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && !isExiting) {
|
||||
setMenuPosition(null);
|
||||
return;
|
||||
}
|
||||
if (!open) return;
|
||||
|
||||
updateMenuPosition();
|
||||
window.addEventListener("scroll", updateMenuPosition, true);
|
||||
|
||||
function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
if (rootRef.current?.contains(target)) return;
|
||||
if (menuRef.current?.contains(target)) return;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeMenu();
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||
document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||
document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||
document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setIsExiting(true);
|
||||
}, [open]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open && menuRef.current) {
|
||||
updateMenuPosition();
|
||||
}
|
||||
}, [open, updateMenuPosition]);
|
||||
|
||||
const handlePrimaryClick = () => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onPrimaryClick?.();
|
||||
};
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
if (disabled) return;
|
||||
if (open) closeMenu();
|
||||
else onMenuOpen(true);
|
||||
};
|
||||
|
||||
const variantClass =
|
||||
variant === "tonal"
|
||||
? styles.variantTonal
|
||||
: variant === "outlined"
|
||||
? styles.variantOutlined
|
||||
: variant === "elevated"
|
||||
? styles.variantElevated
|
||||
: styles.variantFilled;
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof icon === "string") {
|
||||
return <MaterialIcon name={icon} className={styles.leadingIconIcon} />;
|
||||
}
|
||||
|
||||
return <span className={styles.leadingIconIcon}>{icon}</span>;
|
||||
};
|
||||
|
||||
const rootClasses = [
|
||||
styles.splitButton,
|
||||
variantClass,
|
||||
disabled ? styles.disabled : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={rootClasses}
|
||||
data-open={open ? "true" : "false"}
|
||||
aria-disabled={disabled ? "true" : "false"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primarySegment}
|
||||
onClick={handlePrimaryClick}
|
||||
onPointerDown={primaryRipple.onPointerDown}
|
||||
onPointerEnter={primaryRipple.onPointerEnter}
|
||||
onPointerLeave={primaryRipple.onPointerLeave}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MaterialRipple ref={primaryRipple.rippleRef} />
|
||||
<span className={styles.primaryContent}>
|
||||
{icon && <span className={styles.leadingIcon}>{renderIcon()}</span>}
|
||||
<span className={styles.label}>{text}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={menuSegmentRef}
|
||||
type="button"
|
||||
className={styles.menuSegment}
|
||||
onClick={handleMenuToggle}
|
||||
onPointerDown={menuRipple.onPointerDown}
|
||||
onPointerEnter={menuRipple.onPointerEnter}
|
||||
onPointerLeave={menuRipple.onPointerLeave}
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={menuAriaLabel}
|
||||
>
|
||||
<MaterialRipple ref={menuRipple.rippleRef} />
|
||||
<span className={styles.menuIcon}>
|
||||
<MaterialIcon name="expand_more" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{(open || isExiting) &&
|
||||
menuPosition &&
|
||||
createPortal(
|
||||
<AnimatePresence onExitComplete={() => setIsExiting(false)}>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="menu"
|
||||
ref={menuRef}
|
||||
className={styles.menu}
|
||||
style={{
|
||||
position: "fixed",
|
||||
...(menuPosition.bottom != null
|
||||
? { bottom: menuPosition.bottom }
|
||||
: { top: menuPosition.top }),
|
||||
left: menuPosition.left,
|
||||
maxHeight: menuPosition.maxHeight,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
>
|
||||
{menu}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MaterialIcon } from "@/utils/material";
|
||||
|
||||
export type VerificationStatus = "verified" | "warning" | "blocked" | "none";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
verificationStatus?: VerificationStatus | null;
|
||||
/** @deprecated Use verificationStatus instead */
|
||||
verified?: boolean;
|
||||
size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
function resolveVerificationStatus(
|
||||
verificationStatus?: VerificationStatus | null,
|
||||
verified?: boolean,
|
||||
): VerificationStatus {
|
||||
if (verificationStatus) {
|
||||
return verificationStatus;
|
||||
}
|
||||
if (verified) {
|
||||
return "verified";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
export function StatusBadge({ verificationStatus, verified, size = "small" }: StatusBadgeProps) {
|
||||
const status = resolveVerificationStatus(verificationStatus, verified);
|
||||
const className = `status-badge ${size}`;
|
||||
|
||||
if (status === "verified") {
|
||||
return (
|
||||
<span className={`${className} verified`} title="Подтверждённый аккаунт">
|
||||
<MaterialIcon name="verified--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "warning") {
|
||||
return (
|
||||
<span className={`${className} warning`} title="Похож на подтверждённый аккаунт">
|
||||
<MaterialIcon name="warning--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "blocked") {
|
||||
return (
|
||||
<span className={`${className} blocked`} title="Аккаунт заблокирован">
|
||||
<MaterialIcon name="block--filled" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence, type Transition } from "motion/react";
|
||||
import styles from "./css/styled-dialog.module.scss";
|
||||
|
||||
interface StyledDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: ReactNode;
|
||||
onBackdropClick?: () => void;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
afterChildren?: ReactNode;
|
||||
}
|
||||
|
||||
export function StyledDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
onBackdropClick,
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
afterChildren
|
||||
}: StyledDialogProps) {
|
||||
const transition: Transition = { duration: 0.3, type: "tween", ease: "easeInOut" };
|
||||
|
||||
// Handle ESC key
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
function handleEsc(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleEsc);
|
||||
return () => document.removeEventListener("keydown", handleEsc);
|
||||
}
|
||||
}, [open, onOpenChange]);
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className={styles.styledDialogBackdrop}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
if (onBackdropClick) {
|
||||
onBackdropClick();
|
||||
} else {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={transition}>
|
||||
<motion.div
|
||||
className={`${styles.styledDialog} ${className}`}
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={transition}>
|
||||
<div className={`${styles.styledDialogContent} ${contentClassName}`}>
|
||||
{children}
|
||||
</div>
|
||||
{afterChildren}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.getElementById("root")!
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import api from "@/core/api";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
|
||||
interface VerifyButtonProps {
|
||||
userId: number;
|
||||
verified: boolean;
|
||||
onVerificationChange?: (verified: boolean) => void;
|
||||
}
|
||||
|
||||
export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const { user } = useUserStore();
|
||||
|
||||
// Only show for owner
|
||||
if (user.currentUser?.id !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleVerifyToggle() {
|
||||
if (!user.authToken || isVerifying) return;
|
||||
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const result = await api.moderation.users.verify(userId, user.authToken);
|
||||
if (result) {
|
||||
onVerificationChange?.(result.verified);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling verification:', error);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
loading={isVerifying}
|
||||
onClick={handleVerifyToggle}
|
||||
title={verified ? "Снять подтверждение" : "Подтвердить аккаунт"}
|
||||
>
|
||||
{verified ? "Отменить подтверждение" : "Подтвердить"}
|
||||
</MaterialButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
|
||||
.alertDialog {
|
||||
.alertDialogContent {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.alertDialogMessage {
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.alertDialogActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
@use "../../../css/material" as *;
|
||||
|
||||
$font-size: 16px;
|
||||
|
||||
// Search container
|
||||
.searchParent {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// SearchBar component styles
|
||||
.searchBarContainer {
|
||||
position: absolute;
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// All properties animate together simultaneously
|
||||
transition:
|
||||
height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
top 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
left 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
right 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-radius 0.4s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
// Initial background color for smooth transition
|
||||
background-color: $color-dark-surface-container-high;
|
||||
|
||||
&.collapsed {
|
||||
top: 8px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
border-radius: 24px;
|
||||
// Height will be set dynamically by React (48px)
|
||||
// background-color inherited from parent
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
// bottom will be set dynamically by React to account for bottom app bar
|
||||
border-radius: 0;
|
||||
background-color: $color-dark-surface-container;
|
||||
// Height will be set dynamically by React
|
||||
}
|
||||
|
||||
// Single search bar element
|
||||
.searchBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
.searchIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
mdui-icon {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.searchInputContainer {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.searchPlaceholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: $font-size;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: $font-size;
|
||||
padding: 8px 0;
|
||||
pointer-events: auto;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.searchClear {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
mdui-icon {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results section
|
||||
.searchResults {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
@use "../../../css/material" as *;
|
||||
|
||||
$height: 40px;
|
||||
$trailing-width: 48px; // 12 + 22 + 14 per spec
|
||||
$between-space: 2px;
|
||||
$outer-radius: calc(#{$height} / 2); // 20px
|
||||
$inner-radius: 4px;
|
||||
$inner-radius-hovered: 12px;
|
||||
|
||||
.splitButton {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
border-radius: $outer-radius;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.1px;
|
||||
background-color: transparent;
|
||||
color: $color-dark-on-primary;
|
||||
isolation: isolate;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.38;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.primarySegment,
|
||||
.menuSegment {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
min-height: $height;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $color-dark-primary;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
mdui-ripple {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.primarySegment {
|
||||
border-top-left-radius: $outer-radius;
|
||||
border-bottom-left-radius: $outer-radius;
|
||||
border-top-right-radius: $inner-radius;
|
||||
border-bottom-right-radius: $inner-radius;
|
||||
padding-inline: 16px 12px;
|
||||
transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
border-top-right-radius: $inner-radius-hovered;
|
||||
border-bottom-right-radius: $inner-radius-hovered;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-top-right-radius: $inner-radius-hovered;
|
||||
border-bottom-right-radius: $inner-radius-hovered;
|
||||
}
|
||||
|
||||
.primaryContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
.leadingIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.leadingIconIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menuSegment {
|
||||
width: $trailing-width;
|
||||
border-top-right-radius: $outer-radius;
|
||||
border-bottom-right-radius: $outer-radius;
|
||||
border-top-left-radius: $inner-radius;
|
||||
border-bottom-left-radius: $inner-radius;
|
||||
margin-left: $between-space;
|
||||
padding-inline: 12px 14px;
|
||||
transition:
|
||||
border-top-left-radius 0.18s ease-out,
|
||||
border-bottom-left-radius 0.18s ease-out,
|
||||
padding-inline 0.18s ease-out;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
border-top-left-radius: $inner-radius-hovered;
|
||||
border-bottom-left-radius: $inner-radius-hovered;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
border-top-left-radius: $inner-radius-hovered;
|
||||
border-bottom-left-radius: $inner-radius-hovered;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 22px;
|
||||
transition: transform 0.18s ease-out;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
|
||||
mdui-icon {
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-open="true"] .menuSegment {
|
||||
$size: calc($trailing-width / 2);
|
||||
border-radius: $size;
|
||||
padding-inline: 13px 13px;
|
||||
|
||||
.menuIcon {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
&.variantFilled {
|
||||
.primarySegment, .menuSegment {
|
||||
background-color: $color-dark-primary;
|
||||
color: $color-dark-on-primary;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.variantTonal {
|
||||
.primarySegment, .menuSegment {
|
||||
background-color: $color-dark-primary-container;
|
||||
color: $color-dark-on-primary-container;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.variantOutlined {
|
||||
.primarySegment, .menuSegment {
|
||||
border: 1px solid rgba($color-dark-outline, 0.8);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
&.variantElevated {
|
||||
.primarySegment, .menuSegment {
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.3),
|
||||
0 1px 2px rgba(0, 0, 0, 0.15);
|
||||
background-color: $color-dark-surface-container-low;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$menu-padding: 8px;
|
||||
|
||||
.menu {
|
||||
padding: $menu-padding;
|
||||
min-width: 220px;
|
||||
border-radius: 16px;
|
||||
background-color: rgba($color-dark-surface-container-high, 0.4);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
z-index: 100000000;
|
||||
|
||||
// Custom slim semi-transparent scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba($color-dark-on-surface, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba($color-dark-on-surface, 0.4);
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
|
||||
|
||||
mdui-list {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
@use "../../../css/colors" as *;
|
||||
@use "../../../css/material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
// Dialog padding variable
|
||||
$dialog-padding: 30px;
|
||||
|
||||
// Base Styled Dialog Styles
|
||||
.styledDialogBackdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $dialog-padding;
|
||||
box-sizing: border-box;
|
||||
|
||||
.styledDialog {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: calc(100vh - #{$dialog-padding} * 2);
|
||||
background: rgba($color-dark-surface-container, 0.75);
|
||||
border: 1px solid rgba($color-dark-outline-variant, 0.3);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 38px 3px rgba(0, 0, 0, 0.14),
|
||||
0 9px 46px 8px rgba(0, 0, 0, 0.12),
|
||||
0 11px 15px -7px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
.styledDialogContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @fileoverview Application configuration constants
|
||||
* @description Contains all configuration values used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
const DEFAULT_API_BASE_URL = import.meta.env.DEV
|
||||
? "http://localhost:8300"
|
||||
: "https://api.fromchat.ru";
|
||||
|
||||
function resolveApiBaseUrl(): string {
|
||||
return import.meta.env.VITE_API_BASE_URL || DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function stripUrlProtocol(value: string): string {
|
||||
return value.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function resolveWsHost(apiBaseUrl: string): string {
|
||||
const explicit = import.meta.env.VITE_API_WS_BASE_URL;
|
||||
if (explicit) {
|
||||
return stripUrlProtocol(explicit);
|
||||
}
|
||||
|
||||
if (apiBaseUrl.startsWith("/")) {
|
||||
const path = apiBaseUrl.replace(/\/$/, "");
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.host}${path}`;
|
||||
}
|
||||
return `localhost:8301${path}`;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(apiBaseUrl).host;
|
||||
} catch {
|
||||
return stripUrlProtocol(apiBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWsProtocol(apiBaseUrl: string): "ws:" | "wss:" {
|
||||
if (apiBaseUrl.startsWith("https:")) {
|
||||
return "wss:";
|
||||
}
|
||||
if (typeof window !== "undefined" && window.location.protocol === "https:") {
|
||||
return "wss:";
|
||||
}
|
||||
return "ws:";
|
||||
}
|
||||
|
||||
export const BASE_DOMAIN = "fromchat.ru";
|
||||
export const API_BASE_URL = resolveApiBaseUrl();
|
||||
export const API_WS_BASE_URL = resolveWsHost(API_BASE_URL);
|
||||
|
||||
export function getChatWebSocketUrl(): string {
|
||||
return `${resolveWsProtocol(API_BASE_URL)}//${API_WS_BASE_URL}/chat/ws`;
|
||||
}
|
||||
|
||||
export const PRODUCT_NAME = "FromChat";
|
||||
export const MINIMUM_WIDTH = 800;
|
||||
@@ -0,0 +1,41 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
#electron-title-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.electron {
|
||||
#electron-title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
background-color: $color-dark-surface-container;
|
||||
width: 100%;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
transition: background-color 0.5s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.color-surface {
|
||||
background-color: $color-dark-surface;
|
||||
}
|
||||
}
|
||||
|
||||
#window-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#main-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&.platform-darwin .macos-padding {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @fileoverview Electron-specific code
|
||||
* @description This module initializes Electron-specific functionality.
|
||||
* @author denis0001-dev
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import "./electron.scss";
|
||||
|
||||
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
|
||||
|
||||
if (isElectron) {
|
||||
console.log("Running in Electron");
|
||||
document.documentElement.classList.add("electron", `platform-${window.electronInterface.platform}`);
|
||||
} else {
|
||||
console.log("Running in normal browser");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
|
||||
|
||||
// Определяем тип для ref, который может быть либо функцией, либо объектом
|
||||
type PossibleRef<T> = Ref<T> | undefined;
|
||||
|
||||
export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallback<T>, React.RefObject<T | null>] {
|
||||
const targetRef = useRef<T | null>(null);
|
||||
|
||||
const setRefs = useCallback((node: T | null) => {
|
||||
// Обновляем внутренний ref
|
||||
targetRef.current = node;
|
||||
|
||||
// Обновляем все переданные refs
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
// Если ref - это функция, вызываем её
|
||||
ref(node);
|
||||
} else {
|
||||
// Если ref - это объект, обновляем его свойство .current
|
||||
// Используем проверку, чтобы убедиться, что это действительно MutableRefObject
|
||||
// (хотя в реальном коде это почти всегда так)
|
||||
ref.current = node;
|
||||
}
|
||||
});
|
||||
},
|
||||
// Убедитесь, что массив зависимостей всегда актуален
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[...refs]
|
||||
);
|
||||
|
||||
return [setRefs, targetRef];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { MINIMUM_WIDTH } from "@/core/config";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
export default function useDownloadAppScreen() {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < MINIMUM_WIDTH;
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
navigate: isMobile ? <Navigate to="/download-app" replace /> : null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function useWindowSize(): WindowSize {
|
||||
const [width, setWidth] = useState(innerWidth);
|
||||
const [height, setHeight] = useState(innerHeight);
|
||||
|
||||
useEffect(() => {
|
||||
function listener() {
|
||||
setWidth(innerWidth);
|
||||
setHeight(innerHeight);
|
||||
}
|
||||
|
||||
addEventListener("resize", listener);
|
||||
|
||||
return () => {
|
||||
removeEventListener("resize", listener);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
width: width,
|
||||
height: height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @fileoverview Application initialization logic
|
||||
* @description Handles initial application setup and state
|
||||
* @author FromChat Team
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { PRODUCT_NAME } from "./config";
|
||||
import { enableMapSet } from "immer";
|
||||
import type { Platform } from "../../electron/electron.d";
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (userAgent.includes("win")) return "win32";
|
||||
if (userAgent.includes("mac")) return "darwin";
|
||||
return "linux";
|
||||
}
|
||||
|
||||
document.title = PRODUCT_NAME;
|
||||
enableMapSet();
|
||||
|
||||
// Add platform class to body
|
||||
const platform = detectPlatform();
|
||||
document.body.classList.add(`platform-${platform}`);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import legalStyles from "@/core/legal/legal.module.scss";
|
||||
|
||||
export function LegalInlineLinks() {
|
||||
return (
|
||||
<p className={legalStyles.legalInlineLinks}>
|
||||
Регистрируясь, вы соглашаетесь с{" "}
|
||||
<Link to="/terms">пользовательским соглашением</Link>
|
||||
<span className={legalStyles.legalInlineLinksSep}>·</span>
|
||||
<Link to="/privacy">политикой конфиденциальности</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { parse } from "marked";
|
||||
import { escape as escapeHtml } from "he";
|
||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||
import { fitPathToUnitSquare, getMaterialShapePath } from "./materialShapes";
|
||||
import { legalMaterialIconName, parseLegalMarkdown, type LegalSection } from "./fcDirective";
|
||||
import { rewriteLegalDocumentHref, rewriteLegalLinksInHtml } from "./legalLinks";
|
||||
import {
|
||||
loadLegalDocument,
|
||||
type LegalDocumentKind,
|
||||
} from "./legalDocumentLoader";
|
||||
import { LegalPageShell } from "./LegalPageShell";
|
||||
import styles from "./legal.module.scss";
|
||||
|
||||
const CACHED_BANNER_TEXT =
|
||||
"Показана сохранённая копия документа. Содержимое может быть устаревшим.";
|
||||
|
||||
function wrapMarkdownTables(html: string): string {
|
||||
return html.replace(
|
||||
/<table\b[^>]*>[\s\S]*?<\/table>/gi,
|
||||
(table) => `<div class="legalTableScroll">${table}</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
function renderMarkdownBody(markdown: string): string {
|
||||
const html = parse(markdown, { breaks: true, gfm: true }) as string;
|
||||
return wrapMarkdownTables(rewriteLegalLinksInHtml(html));
|
||||
}
|
||||
|
||||
function ExpressiveSectionHeader({
|
||||
section,
|
||||
}: {
|
||||
section: LegalSection;
|
||||
}) {
|
||||
const shapePath = useMemo(
|
||||
() => getMaterialShapePath(section.directive.shape),
|
||||
[section.directive.shape],
|
||||
);
|
||||
const shapeFit = useMemo(
|
||||
() => fitPathToUnitSquare(shapePath),
|
||||
[shapePath],
|
||||
);
|
||||
const iconName = legalMaterialIconName(section.directive.icon);
|
||||
|
||||
return (
|
||||
<div className={styles.sectionHeader}>
|
||||
<div className={styles.sectionIconFrame}>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
className={styles.sectionIconShape}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g transform={shapeFit.transform}>
|
||||
<path d={shapePath} className={styles.sectionShapeFill} />
|
||||
</g>
|
||||
</svg>
|
||||
<MaterialIcon name={iconName} className={styles.sectionIconGlyph} />
|
||||
</div>
|
||||
<h2 className={styles.sectionTitle}>{section.title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LegalMarkdownPageProps {
|
||||
kind: LegalDocumentKind;
|
||||
}
|
||||
|
||||
export function LegalMarkdownPage({ kind }: LegalMarkdownPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [isCached, setIsCached] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const handleContentClick = useCallback((event: MouseEvent<HTMLDivElement>) => {
|
||||
const anchor = (event.target as HTMLElement).closest("a");
|
||||
if (!anchor) return;
|
||||
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href) return;
|
||||
|
||||
const clientRoute = rewriteLegalDocumentHref(href) ?? (
|
||||
href === "/terms" || href === "/privacy" ? href : null
|
||||
);
|
||||
if (!clientRoute) return;
|
||||
|
||||
event.preventDefault();
|
||||
navigate(clientRoute);
|
||||
}, [navigate]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setLoadAttempt((attempt) => attempt + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const abortController = new AbortController();
|
||||
|
||||
setMarkdown(null);
|
||||
setError(null);
|
||||
setIsCached(false);
|
||||
setLoading(true);
|
||||
|
||||
loadLegalDocument(kind, abortController.signal)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.status === "error") {
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setMarkdown(result.markdown);
|
||||
setIsCached(result.fromCache);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (cancelled || (e instanceof DOMException && e.name === "AbortError")) {
|
||||
return;
|
||||
}
|
||||
setError("Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abortController.abort();
|
||||
};
|
||||
}, [kind, loadAttempt]);
|
||||
|
||||
const content = (() => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.legalPage}>
|
||||
<p className={styles.loading}>Загрузка…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.legalPage}>
|
||||
<div className={styles.errorState}>
|
||||
<p className={styles.error}>{escapeHtml(error)}</p>
|
||||
<MaterialButton onClick={retry}>Повторить</MaterialButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!markdown) {
|
||||
return (
|
||||
<div className={styles.legalPage}>
|
||||
<p className={styles.loading}>Загрузка…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { preamble, sections } = parseLegalMarkdown(markdown);
|
||||
|
||||
return (
|
||||
<div className={styles.legalPage} onClick={handleContentClick}>
|
||||
{isCached ? (
|
||||
<div className={styles.cachedBanner} role="status">
|
||||
{CACHED_BANNER_TEXT}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preamble ? (
|
||||
<div
|
||||
className={styles.preamble}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdownBody(preamble) }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{sections.map((section, index) => (
|
||||
<section key={`${section.title}-${index}`} className={styles.section}>
|
||||
<ExpressiveSectionHeader section={section} />
|
||||
<div
|
||||
className={styles.sectionBody}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdownBody(section.bodyMarkdown) }}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})();
|
||||
|
||||
return <LegalPageShell>{content}</LegalPageShell>;
|
||||
}
|
||||
|
||||
export type { LegalDocumentKind };
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { HomeHeader } from "@/pages/home/HomeHeader";
|
||||
import { HomeFooter } from "@/pages/home/HomeFooter";
|
||||
import homeStyles from "@/pages/home/home.module.scss";
|
||||
import styles from "./legal.module.scss";
|
||||
|
||||
interface LegalPageShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function LegalPageShell({ children }: LegalPageShellProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const scrollToDownload = () => {
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={homeStyles.homepage}>
|
||||
<HomeHeader onScrollToDownload={scrollToDownload} />
|
||||
<main className={styles.legalMain}>{children}</main>
|
||||
<HomeFooter onScrollToDownload={scrollToDownload} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Parses `<!-- fc:shape=Cookie4Sided icon=shield -->` directives before section headers.
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
|
||||
export interface FcSectionDirective {
|
||||
shape: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const FC_DIRECTIVE_RE = /<!--\s*fc:([^>]+?)\s*-->/i;
|
||||
|
||||
function parseDirectiveBody(body: string): FcSectionDirective | null {
|
||||
const shapeMatch = body.match(/shape=([A-Za-z0-9_]+)/);
|
||||
const iconMatch = body.match(/icon=([A-Za-z0-9_-]+)/);
|
||||
if (!shapeMatch || !iconMatch) return null;
|
||||
return { shape: shapeMatch[1], icon: iconMatch[1] };
|
||||
}
|
||||
|
||||
export function parseFcDirective(line: string): FcSectionDirective | null {
|
||||
const match = line.match(FC_DIRECTIVE_RE);
|
||||
if (!match) return null;
|
||||
return parseDirectiveBody(match[1]);
|
||||
}
|
||||
|
||||
export interface LegalSection {
|
||||
directive: FcSectionDirective;
|
||||
title: string;
|
||||
bodyMarkdown: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split markdown into sections keyed by fc directives + `##` headings.
|
||||
*/
|
||||
export function parseLegalMarkdown(markdown: string): { preamble: string; sections: LegalSection[] } {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const preambleLines: string[] = [];
|
||||
const sections: LegalSection[] = [];
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const directive = parseFcDirective(lines[i]);
|
||||
if (directive && i + 1 < lines.length && lines[i + 1].startsWith("## ")) {
|
||||
const title = lines[i + 1].slice(3).trim();
|
||||
i += 2;
|
||||
const bodyLines: string[] = [];
|
||||
while (i < lines.length) {
|
||||
if (parseFcDirective(lines[i]) && i + 1 < lines.length && lines[i + 1].startsWith("## ")) {
|
||||
break;
|
||||
}
|
||||
bodyLines.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
sections.push({
|
||||
directive,
|
||||
title,
|
||||
bodyMarkdown: bodyLines.join("\n").trim(),
|
||||
});
|
||||
} else if (sections.length === 0) {
|
||||
preambleLines.push(lines[i]);
|
||||
i += 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
preamble: preambleLines.join("\n").trim(),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
export function staticIconUrl(icon: string): string {
|
||||
return `${API_BASE_URL}/static/icons/${encodeURIComponent(icon)}.webp`;
|
||||
}
|
||||
|
||||
/** Maps legal-doc icon keys to Material Symbols names (Google Fonts). */
|
||||
const LEGAL_MATERIAL_ICON: Record<string, string> = {
|
||||
privacy: "privacy_tip",
|
||||
terms: "contract",
|
||||
};
|
||||
|
||||
export function legalMaterialIconName(icon: string): string {
|
||||
return LEGAL_MATERIAL_ICON[icon] ?? icon;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
.legalMain {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.legalPage {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 64px;
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
font-size: 1rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $color-dark-error;
|
||||
}
|
||||
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cachedBanner {
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: $color-dark-secondary-container;
|
||||
color: $color-dark-on-secondary-container;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preamble {
|
||||
margin-bottom: 36px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
color: $color-dark-on-surface-variant;
|
||||
text-align: center;
|
||||
|
||||
:global(blockquote) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
|
||||
:global(.legalTableScroll) {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 0 0.75em;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:global(table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:global(th),
|
||||
:global(td) {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid $color-dark-outline-variant;
|
||||
}
|
||||
|
||||
:global(th) {
|
||||
font-weight: 600;
|
||||
background: $color-dark-surface-container-low;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
$expressive-hero-shape-size: 110px;
|
||||
$expressive-hero-icon-size: 50px;
|
||||
|
||||
.sectionIconFrame {
|
||||
width: $expressive-hero-shape-size;
|
||||
height: $expressive-hero-shape-size;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sectionIconShape {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: $expressive-hero-shape-size;
|
||||
height: $expressive-hero-shape-size;
|
||||
transform: translate(-50%, -50%);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sectionShapeFill {
|
||||
fill: $color-dark-primary-container;
|
||||
}
|
||||
|
||||
.sectionIconGlyph {
|
||||
font-size: $expressive-hero-icon-size !important;
|
||||
width: $expressive-hero-icon-size !important;
|
||||
height: $expressive-hero-icon-size !important;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: $color-dark-on-primary-container;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
|
||||
:global(h3) {
|
||||
font-size: calc((0.95rem + 1.25rem) / 2);
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 1.25em 0 0.5em;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(h2) {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
margin: 1.5em 0 0.5em;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
margin: 0 0 0.75em;
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
|
||||
:global(li) {
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
|
||||
:global(.legalTableScroll) {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0 0 0.75em;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:global(table) {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:global(th),
|
||||
:global(td) {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid $color-dark-outline-variant;
|
||||
}
|
||||
|
||||
:global(th) {
|
||||
font-weight: 600;
|
||||
background: $color-dark-surface-container-low;
|
||||
}
|
||||
}
|
||||
|
||||
.legalInlineLinks {
|
||||
font-size: 0.875rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
margin-top: 12px;
|
||||
|
||||
a {
|
||||
color: $color-dark-primary;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legalInlineLinksSep {
|
||||
margin: 0 6px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { delay } from "@/utils/utils";
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
|
||||
export type LegalDocumentKind = "privacy" | "terms";
|
||||
|
||||
export const LEGAL_DOCUMENT_PATH: Record<LegalDocumentKind, string> = {
|
||||
privacy: `${API_BASE_URL}/static/PRIVACY.md`,
|
||||
terms: `${API_BASE_URL}/static/TERMS.md`,
|
||||
};
|
||||
|
||||
const RETRY_WINDOW_MS = 5000;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
const CACHE_KEY: Record<LegalDocumentKind, string> = {
|
||||
privacy: "fromchat:legal:privacy",
|
||||
terms: "fromchat:legal:terms",
|
||||
};
|
||||
|
||||
export type LegalDocumentLoadResult =
|
||||
| { status: "success"; markdown: string; fromCache: false }
|
||||
| { status: "cached"; markdown: string; fromCache: true }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
function readCache(kind: LegalDocumentKind): string | null {
|
||||
try {
|
||||
return localStorage.getItem(CACHE_KEY[kind]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(kind: LegalDocumentKind, markdown: string): void {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY[kind], markdown);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOnce(path: string): Promise<string> {
|
||||
const response = await fetch(path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
export async function loadLegalDocument(
|
||||
kind: LegalDocumentKind,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LegalDocumentLoadResult> {
|
||||
const path = LEGAL_DOCUMENT_PATH[kind];
|
||||
const start = Date.now();
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
|
||||
try {
|
||||
const markdown = await fetchOnce(path);
|
||||
writeCache(kind, markdown);
|
||||
return { status: "success", markdown, fromCache: false };
|
||||
} catch {
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed >= RETRY_WINDOW_MS) {
|
||||
break;
|
||||
}
|
||||
await delay(RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const cached = readCache(kind);
|
||||
if (cached != null && cached.length > 0) {
|
||||
return { status: "cached", markdown: cached, fromCache: true };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
message: "Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const LEGAL_STATIC_LINK_RE = /(?:^|\/)?(?:api\/)?static\/(TERMS|PRIVACY)\.md$/i;
|
||||
|
||||
/**
|
||||
* Maps static legal markdown API paths to client routes.
|
||||
* Returns null when the href is not a legal document link.
|
||||
*/
|
||||
export function rewriteLegalDocumentHref(href: string): string | null {
|
||||
const path = href.replace(/\\/g, "/").split("?")[0].split("#")[0].replace(/\/+$/, "");
|
||||
const match = path.match(LEGAL_STATIC_LINK_RE);
|
||||
if (!match) return null;
|
||||
return match[1].toUpperCase() === "TERMS" ? "/terms" : "/privacy";
|
||||
}
|
||||
|
||||
export function rewriteLegalLinksInHtml(html: string): string {
|
||||
return html.replace(/href="([^"]+)"/g, (full, href: string) => {
|
||||
const rewritten = rewriteLegalDocumentHref(href);
|
||||
return rewritten ? `href="${rewritten}"` : full;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Auto-generated from MaterialShapes via Robolectric — do not edit. */
|
||||
|
||||
export const MATERIAL_SHAPE_PATHS: Record<string, string> = {
|
||||
"Arch": "M 0.146 0.146 L 0.181 0.114 L 0.22 0.085 L 0.261 0.06 L 0.305 0.039 L 0.351 0.022 L 0.399 0.01 L 0.448 0.002 L 0.5 0 L 0.551 0.002 L 0.6 0.01 L 0.648 0.022 L 0.694 0.039 L 0.738 0.06 L 0.779 0.085 L 0.818 0.114 L 0.853 0.146 L 0.885 0.181 L 0.914 0.22 L 0.939 0.261 L 0.96 0.305 L 0.977 0.351 L 0.989 0.399 L 0.997 0.448 L 0.999 0.5 L 1 0.858 L 0.997 0.887 L 0.988 0.913 L 0.975 0.937 L 0.958 0.958 L 0.937 0.975 L 0.913 0.988 L 0.887 0.997 L 0.858 1 L 0.141 0.999 L 0.112 0.997 L 0.086 0.988 L 0.062 0.975 L 0.041 0.958 L 0.024 0.937 L 0.011 0.913 L 0.002 0.887 L 0 0.858 L 0 0.5 L 0.002 0.448 L 0.01 0.399 L 0.022 0.351 L 0.039 0.305 L 0.06 0.261 L 0.085 0.22 L 0.114 0.181 L 0.146 0.146 L 0.146 0.146 Z",
|
||||
"Arrow": "M 0.499 0.836 L 0.468 0.838 L 0.438 0.843 L 0.277 0.878 L 0.249 0.882 L 0.221 0.882 L 0.194 0.878 L 0.169 0.87 L 0.146 0.858 L 0.125 0.844 L 0.106 0.827 L 0.09 0.807 L 0.077 0.785 L 0.066 0.762 L 0.059 0.738 L 0.055 0.712 L 0.055 0.686 L 0.059 0.659 L 0.068 0.633 L 0.081 0.607 L 0.172 0.452 L 0.269 0.291 L 0.311 0.227 L 0.349 0.175 L 0.386 0.135 L 0.422 0.106 L 0.459 0.089 L 0.498 0.083 L 0.537 0.089 L 0.575 0.106 L 0.611 0.135 L 0.648 0.175 L 0.686 0.227 L 0.728 0.29 L 0.825 0.451 L 0.916 0.602 L 0.929 0.629 L 0.938 0.656 L 0.942 0.683 L 0.942 0.71 L 0.938 0.737 L 0.931 0.762 L 0.92 0.785 L 0.906 0.808 L 0.889 0.827 L 0.87 0.845 L 0.848 0.86 L 0.824 0.871 L 0.799 0.879 L 0.772 0.884 L 0.743 0.884 L 0.713 0.879 L 0.56 0.843 L 0.529 0.838 L 0.499 0.836 L 0.499 0.836 Z",
|
||||
"Boom": "M 0.454 0.287 L 0.459 0.281 L 0.493 0.01 L 0.495 0.006 L 0.5 0.004 L 0.504 0.006 L 0.506 0.01 L 0.541 0.281 L 0.546 0.287 L 0.553 0.284 L 0.694 0.05 L 0.698 0.047 L 0.703 0.048 L 0.706 0.051 L 0.707 0.056 L 0.628 0.317 L 0.63 0.325 L 0.638 0.325 L 0.862 0.169 L 0.867 0.167 L 0.871 0.17 L 0.873 0.174 L 0.871 0.179 L 0.693 0.385 L 0.692 0.394 L 0.699 0.397 L 0.967 0.345 L 0.972 0.346 L 0.975 0.35 L 0.975 0.355 L 0.971 0.358 L 0.725 0.474 L 0.721 0.481 L 0.726 0.488 L 0.991 0.549 L 0.996 0.552 L 0.997 0.557 L 0.995 0.561 L 0.99 0.563 L 0.717 0.569 L 0.711 0.573 L 0.713 0.581 L 0.931 0.745 L 0.933 0.75 L 0.933 0.754 L 0.929 0.758 L 0.924 0.757 L 0.672 0.652 L 0.664 0.653 L 0.664 0.661 L 0.795 0.9 L 0.796 0.905 L 0.793 0.909 L 0.789 0.91 L 0.784 0.908 L 0.598 0.709 L 0.59 0.708 L 0.585 0.714 L 0.609 0.986 L 0.607 0.991 L 0.603 0.994 L 0.599 0.993 L 0.595 0.989 L 0.506 0.731 L 0.499 0.727 L 0.493 0.731 L 0.404 0.989 L 0.4 0.993 L 0.395 0.994 L 0.391 0.991 L 0.39 0.986 L 0.413 0.714 L 0.409 0.707 L 0.401 0.709 L 0.215 0.908 L 0.21 0.91 L 0.206 0.909 L 0.203 0.905 L 0.204 0.9 L 0.335 0.661 L 0.334 0.653 L 0.326 0.651 L 0.075 0.757 L 0.07 0.757 L 0.066 0.754 L 0.065 0.75 L 0.068 0.745 L 0.286 0.58 L 0.288 0.573 L 0.282 0.568 L 0.009 0.563 L 0.004 0.561 L 0.002 0.557 L 0.003 0.552 L 0.008 0.549 L 0.273 0.487 L 0.279 0.481 L 0.275 0.474 L 0.028 0.358 L 0.024 0.355 L 0.024 0.35 L 0.027 0.346 L 0.032 0.345 L 0.3 0.396 L 0.307 0.393 L 0.306 0.385 L 0.128 0.179 L 0.126 0.174 L 0.128 0.17 L 0.132 0.167 L 0.137 0.169 L 0.361 0.324 L 0.369 0.324 L 0.372 0.317 L 0.292 0.056 L 0.293 0.051 L 0.296 0.047 L 0.301 0.047 L 0.305 0.05 L 0.446 0.284 L 0.454 0.287 L 0.454 0.287 Z",
|
||||
"Bun": "M 0.796 0.5 L 0.806 0.503 L 0.85 0.522 L 0.89 0.548 L 0.912 0.569 L 0.932 0.592 L 0.949 0.617 L 0.962 0.643 L 0.973 0.671 L 0.98 0.7 L 0.983 0.731 L 0.983 0.761 L 0.983 0.762 L 0.975 0.81 L 0.958 0.855 L 0.934 0.896 L 0.903 0.931 L 0.866 0.96 L 0.824 0.981 L 0.778 0.995 L 0.729 1 L 0.27 1 L 0.221 0.995 L 0.175 0.981 L 0.133 0.96 L 0.096 0.931 L 0.065 0.896 L 0.041 0.855 L 0.024 0.81 L 0.016 0.762 L 0.016 0.761 L 0.016 0.731 L 0.019 0.7 L 0.026 0.671 L 0.037 0.643 L 0.05 0.617 L 0.067 0.592 L 0.087 0.569 L 0.109 0.548 L 0.149 0.522 L 0.193 0.503 L 0.203 0.5 L 0.193 0.496 L 0.149 0.477 L 0.109 0.451 L 0.087 0.43 L 0.067 0.407 L 0.05 0.382 L 0.037 0.356 L 0.026 0.328 L 0.019 0.299 L 0.016 0.268 L 0.016 0.238 L 0.016 0.237 L 0.024 0.189 L 0.041 0.144 L 0.065 0.103 L 0.096 0.068 L 0.133 0.039 L 0.175 0.018 L 0.221 0.004 L 0.27 0 L 0.729 0 L 0.778 0.004 L 0.824 0.018 L 0.866 0.039 L 0.903 0.068 L 0.934 0.103 L 0.958 0.144 L 0.975 0.189 L 0.983 0.237 L 0.983 0.238 L 0.983 0.268 L 0.98 0.299 L 0.973 0.328 L 0.962 0.356 L 0.949 0.382 L 0.932 0.407 L 0.912 0.43 L 0.89 0.451 L 0.85 0.477 L 0.806 0.496 L 0.796 0.5 L 0.796 0.5 Z",
|
||||
"Burst": "M 0.5 0 L 0.505 0.003 L 0.588 0.152 L 0.592 0.155 L 0.597 0.154 L 0.743 0.067 L 0.749 0.067 L 0.752 0.072 L 0.75 0.243 L 0.752 0.247 L 0.756 0.249 L 0.926 0.247 L 0.932 0.25 L 0.932 0.256 L 0.844 0.403 L 0.844 0.407 L 0.847 0.411 L 0.995 0.494 L 0.998 0.499 L 0.995 0.504 L 0.846 0.588 L 0.844 0.592 L 0.844 0.596 L 0.932 0.742 L 0.932 0.748 L 0.926 0.751 L 0.755 0.749 L 0.751 0.751 L 0.749 0.755 L 0.752 0.926 L 0.749 0.931 L 0.743 0.931 L 0.596 0.844 L 0.591 0.843 L 0.588 0.846 L 0.505 0.995 L 0.499 0.998 L 0.494 0.995 L 0.411 0.846 L 0.407 0.843 L 0.402 0.844 L 0.256 0.931 L 0.25 0.931 L 0.247 0.926 L 0.249 0.755 L 0.247 0.751 L 0.243 0.749 L 0.073 0.751 L 0.067 0.748 L 0.067 0.742 L 0.155 0.595 L 0.155 0.591 L 0.152 0.587 L 0.004 0.504 L 0.001 0.499 L 0.004 0.494 L 0.153 0.41 L 0.155 0.406 L 0.155 0.402 L 0.067 0.256 L 0.067 0.249 L 0.073 0.246 L 0.244 0.249 L 0.248 0.247 L 0.25 0.243 L 0.247 0.072 L 0.25 0.067 L 0.256 0.067 L 0.403 0.154 L 0.408 0.155 L 0.411 0.152 L 0.494 0.003 L 0.5 0 L 0.5 0 Z",
|
||||
"Circle": "M 1 0.5 L 0.998 0.538 L 0.993 0.577 L 0.986 0.615 L 0.975 0.653 L 0.962 0.689 L 0.945 0.725 L 0.926 0.759 L 0.905 0.791 L 0.881 0.821 L 0.854 0.85 L 0.826 0.876 L 0.795 0.901 L 0.763 0.922 L 0.728 0.942 L 0.693 0.958 L 0.657 0.971 L 0.619 0.982 L 0.581 0.989 L 0.543 0.994 L 0.504 0.995 L 0.464 0.994 L 0.426 0.989 L 0.388 0.982 L 0.35 0.971 L 0.314 0.958 L 0.279 0.942 L 0.245 0.922 L 0.212 0.901 L 0.181 0.876 L 0.153 0.85 L 0.126 0.821 L 0.102 0.791 L 0.081 0.759 L 0.062 0.725 L 0.045 0.689 L 0.032 0.653 L 0.021 0.615 L 0.014 0.577 L 0.009 0.538 L 0.008 0.499 L 0.009 0.461 L 0.014 0.422 L 0.021 0.384 L 0.032 0.346 L 0.045 0.31 L 0.062 0.274 L 0.081 0.24 L 0.102 0.208 L 0.126 0.178 L 0.153 0.149 L 0.181 0.123 L 0.212 0.098 L 0.245 0.077 L 0.279 0.057 L 0.314 0.041 L 0.35 0.028 L 0.388 0.017 L 0.426 0.01 L 0.464 0.005 L 0.504 0.004 L 0.543 0.005 L 0.581 0.01 L 0.619 0.017 L 0.657 0.028 L 0.693 0.041 L 0.728 0.057 L 0.763 0.077 L 0.795 0.098 L 0.826 0.123 L 0.854 0.149 L 0.881 0.178 L 0.905 0.208 L 0.926 0.24 L 0.945 0.274 L 0.962 0.31 L 0.975 0.346 L 0.986 0.384 L 0.993 0.422 L 0.998 0.461 L 1 0.5 L 1 0.5 Z",
|
||||
"ClamShell": "M 0.187 0.815 L 0.154 0.79 L 0.129 0.756 L 0.023 0.567 L 0.01 0.534 L 0.005 0.499 L 0.01 0.465 L 0.023 0.432 L 0.128 0.243 L 0.153 0.209 L 0.186 0.184 L 0.224 0.168 L 0.266 0.162 L 0.733 0.162 L 0.774 0.168 L 0.812 0.184 L 0.845 0.209 L 0.87 0.243 L 0.976 0.432 L 0.989 0.465 L 0.994 0.5 L 0.989 0.534 L 0.976 0.567 L 0.871 0.756 L 0.846 0.79 L 0.813 0.815 L 0.775 0.831 L 0.733 0.837 L 0.266 0.837 L 0.225 0.831 L 0.187 0.815 L 0.187 0.815 Z",
|
||||
"Clover4Leaf": "M 0.5 0.098 L 0.514 0.086 L 0.558 0.058 L 0.606 0.039 L 0.655 0.029 L 0.706 0.028 L 0.755 0.036 L 0.803 0.052 L 0.848 0.077 L 0.888 0.111 L 0.922 0.151 L 0.947 0.196 L 0.963 0.244 L 0.971 0.293 L 0.97 0.344 L 0.96 0.393 L 0.941 0.441 L 0.913 0.485 L 0.901 0.5 L 0.913 0.514 L 0.941 0.558 L 0.96 0.606 L 0.97 0.655 L 0.971 0.706 L 0.963 0.755 L 0.947 0.803 L 0.922 0.848 L 0.888 0.888 L 0.848 0.922 L 0.803 0.947 L 0.755 0.963 L 0.706 0.971 L 0.655 0.97 L 0.606 0.96 L 0.558 0.941 L 0.514 0.913 L 0.5 0.901 L 0.485 0.913 L 0.441 0.941 L 0.393 0.96 L 0.344 0.97 L 0.293 0.971 L 0.244 0.963 L 0.196 0.947 L 0.151 0.922 L 0.111 0.888 L 0.077 0.848 L 0.052 0.803 L 0.036 0.755 L 0.028 0.706 L 0.029 0.655 L 0.039 0.606 L 0.058 0.558 L 0.086 0.514 L 0.098 0.5 L 0.086 0.485 L 0.058 0.441 L 0.039 0.393 L 0.029 0.344 L 0.028 0.293 L 0.036 0.244 L 0.052 0.196 L 0.077 0.151 L 0.111 0.111 L 0.151 0.077 L 0.196 0.052 L 0.244 0.036 L 0.293 0.028 L 0.344 0.029 L 0.393 0.039 L 0.441 0.058 L 0.485 0.086 L 0.5 0.098 L 0.5 0.098 Z",
|
||||
"Clover8Leaf": "M 0.499 0.071 L 0.521 0.059 L 0.564 0.043 L 0.607 0.037 L 0.649 0.04 L 0.69 0.053 L 0.726 0.074 L 0.758 0.103 L 0.783 0.139 L 0.799 0.182 L 0.803 0.196 L 0.826 0.204 L 0.868 0.222 L 0.903 0.248 L 0.93 0.281 L 0.95 0.318 L 0.961 0.359 L 0.962 0.402 L 0.954 0.445 L 0.936 0.487 L 0.928 0.499 L 0.94 0.521 L 0.956 0.564 L 0.962 0.607 L 0.959 0.649 L 0.946 0.69 L 0.925 0.726 L 0.896 0.758 L 0.86 0.783 L 0.817 0.799 L 0.803 0.803 L 0.795 0.826 L 0.777 0.868 L 0.751 0.903 L 0.718 0.93 L 0.681 0.95 L 0.64 0.961 L 0.597 0.962 L 0.554 0.954 L 0.512 0.936 L 0.499 0.928 L 0.478 0.94 L 0.435 0.956 L 0.392 0.962 L 0.35 0.959 L 0.309 0.946 L 0.273 0.925 L 0.241 0.896 L 0.216 0.86 L 0.2 0.817 L 0.196 0.803 L 0.173 0.795 L 0.131 0.777 L 0.096 0.751 L 0.069 0.718 L 0.049 0.681 L 0.038 0.64 L 0.037 0.597 L 0.045 0.554 L 0.063 0.512 L 0.071 0.499 L 0.059 0.478 L 0.043 0.435 L 0.037 0.392 L 0.04 0.35 L 0.053 0.309 L 0.074 0.273 L 0.103 0.241 L 0.139 0.216 L 0.182 0.2 L 0.196 0.196 L 0.204 0.173 L 0.222 0.131 L 0.248 0.096 L 0.281 0.069 L 0.318 0.049 L 0.359 0.038 L 0.402 0.037 L 0.445 0.045 L 0.487 0.063 L 0.499 0.071 L 0.499 0.071 Z",
|
||||
"Cookie12Sided": "M 0.5 0.005 L 0.519 0.007 L 0.537 0.012 L 0.554 0.022 L 0.57 0.036 L 0.59 0.053 L 0.615 0.063 L 0.641 0.066 L 0.668 0.062 L 0.688 0.058 L 0.708 0.058 L 0.727 0.062 L 0.744 0.07 L 0.76 0.081 L 0.773 0.096 L 0.783 0.113 L 0.79 0.132 L 0.799 0.157 L 0.815 0.179 L 0.836 0.195 L 0.862 0.204 L 0.881 0.211 L 0.898 0.221 L 0.912 0.234 L 0.924 0.25 L 0.931 0.267 L 0.936 0.286 L 0.936 0.306 L 0.932 0.326 L 0.927 0.352 L 0.931 0.379 L 0.941 0.403 L 0.958 0.424 L 0.972 0.44 L 0.981 0.457 L 0.987 0.475 L 0.989 0.494 L 0.987 0.513 L 0.981 0.532 L 0.972 0.549 L 0.958 0.564 L 0.941 0.585 L 0.931 0.61 L 0.927 0.636 L 0.932 0.663 L 0.936 0.683 L 0.936 0.703 L 0.931 0.722 L 0.924 0.739 L 0.912 0.755 L 0.898 0.768 L 0.881 0.778 L 0.862 0.784 L 0.836 0.794 L 0.815 0.81 L 0.799 0.831 L 0.79 0.857 L 0.783 0.876 L 0.773 0.893 L 0.76 0.907 L 0.744 0.918 L 0.727 0.926 L 0.708 0.931 L 0.688 0.931 L 0.668 0.927 L 0.641 0.922 L 0.615 0.925 L 0.59 0.936 L 0.57 0.953 L 0.554 0.967 L 0.537 0.976 L 0.519 0.982 L 0.499 0.984 L 0.48 0.982 L 0.462 0.976 L 0.445 0.967 L 0.429 0.953 L 0.409 0.936 L 0.384 0.925 L 0.358 0.922 L 0.331 0.927 L 0.311 0.931 L 0.291 0.931 L 0.272 0.926 L 0.255 0.918 L 0.239 0.907 L 0.226 0.893 L 0.216 0.876 L 0.209 0.857 L 0.2 0.831 L 0.184 0.81 L 0.163 0.794 L 0.137 0.784 L 0.118 0.778 L 0.101 0.768 L 0.087 0.755 L 0.075 0.739 L 0.068 0.722 L 0.063 0.703 L 0.063 0.683 L 0.067 0.663 L 0.072 0.636 L 0.068 0.61 L 0.058 0.585 L 0.041 0.564 L 0.027 0.549 L 0.018 0.532 L 0.012 0.513 L 0.01 0.494 L 0.012 0.475 L 0.018 0.457 L 0.027 0.44 L 0.041 0.424 L 0.058 0.403 L 0.068 0.379 L 0.072 0.352 L 0.067 0.326 L 0.063 0.306 L 0.063 0.286 L 0.068 0.267 L 0.075 0.25 L 0.087 0.234 L 0.101 0.221 L 0.118 0.211 L 0.137 0.204 L 0.163 0.195 L 0.184 0.179 L 0.2 0.157 L 0.209 0.132 L 0.216 0.113 L 0.226 0.096 L 0.239 0.081 L 0.255 0.07 L 0.272 0.062 L 0.291 0.058 L 0.311 0.058 L 0.331 0.062 L 0.358 0.066 L 0.384 0.063 L 0.409 0.053 L 0.429 0.036 L 0.445 0.022 L 0.462 0.012 L 0.48 0.007 L 0.5 0.005 L 0.5 0.005 Z",
|
||||
"Cookie4Sided": "M 0.871 0.87 L 0.847 0.892 L 0.819 0.909 L 0.79 0.923 L 0.759 0.932 L 0.726 0.937 L 0.692 0.936 L 0.657 0.93 L 0.621 0.918 L 0.581 0.9 L 0.541 0.888 L 0.5 0.884 L 0.459 0.888 L 0.419 0.901 L 0.378 0.918 L 0.343 0.93 L 0.308 0.936 L 0.274 0.937 L 0.241 0.932 L 0.21 0.923 L 0.18 0.91 L 0.153 0.892 L 0.129 0.871 L 0.108 0.846 L 0.09 0.819 L 0.076 0.79 L 0.067 0.758 L 0.062 0.725 L 0.063 0.691 L 0.069 0.657 L 0.081 0.621 L 0.099 0.581 L 0.111 0.541 L 0.115 0.5 L 0.111 0.458 L 0.099 0.419 L 0.081 0.378 L 0.069 0.343 L 0.063 0.308 L 0.062 0.274 L 0.067 0.241 L 0.076 0.21 L 0.09 0.18 L 0.107 0.153 L 0.128 0.129 L 0.153 0.107 L 0.18 0.09 L 0.209 0.076 L 0.241 0.067 L 0.274 0.062 L 0.308 0.063 L 0.343 0.069 L 0.378 0.081 L 0.419 0.099 L 0.459 0.111 L 0.5 0.115 L 0.541 0.111 L 0.581 0.098 L 0.621 0.081 L 0.656 0.069 L 0.691 0.063 L 0.725 0.062 L 0.758 0.067 L 0.789 0.076 L 0.819 0.089 L 0.846 0.107 L 0.87 0.128 L 0.892 0.153 L 0.909 0.18 L 0.923 0.209 L 0.932 0.241 L 0.937 0.274 L 0.936 0.308 L 0.93 0.342 L 0.918 0.378 L 0.901 0.418 L 0.888 0.458 L 0.884 0.499 L 0.888 0.541 L 0.901 0.58 L 0.918 0.621 L 0.93 0.656 L 0.937 0.691 L 0.937 0.725 L 0.933 0.758 L 0.923 0.789 L 0.91 0.819 L 0.892 0.846 L 0.871 0.87 L 0.871 0.87 Z",
|
||||
"Cookie6Sided": "M 0.716 0.872 L 0.692 0.889 L 0.669 0.908 L 0.668 0.909 L 0.63 0.939 L 0.589 0.96 L 0.545 0.973 L 0.5 0.977 L 0.454 0.972 L 0.41 0.96 L 0.369 0.938 L 0.331 0.909 L 0.309 0.89 L 0.285 0.873 L 0.259 0.86 L 0.231 0.851 L 0.229 0.85 L 0.185 0.832 L 0.145 0.807 L 0.112 0.775 L 0.086 0.738 L 0.067 0.697 L 0.056 0.652 L 0.054 0.606 L 0.061 0.559 L 0.066 0.53 L 0.068 0.501 L 0.067 0.471 L 0.061 0.443 L 0.061 0.441 L 0.054 0.393 L 0.056 0.347 L 0.067 0.302 L 0.086 0.261 L 0.112 0.224 L 0.146 0.192 L 0.185 0.167 L 0.229 0.149 L 0.257 0.14 L 0.283 0.127 L 0.307 0.11 L 0.33 0.091 L 0.331 0.09 L 0.369 0.06 L 0.41 0.039 L 0.454 0.026 L 0.499 0.022 L 0.545 0.027 L 0.589 0.039 L 0.63 0.061 L 0.668 0.09 L 0.69 0.109 L 0.714 0.126 L 0.74 0.139 L 0.768 0.148 L 0.77 0.149 L 0.814 0.167 L 0.854 0.192 L 0.887 0.224 L 0.913 0.261 L 0.932 0.302 L 0.943 0.347 L 0.945 0.393 L 0.938 0.44 L 0.933 0.469 L 0.931 0.498 L 0.932 0.528 L 0.938 0.556 L 0.938 0.558 L 0.945 0.606 L 0.943 0.652 L 0.932 0.697 L 0.913 0.738 L 0.887 0.775 L 0.853 0.807 L 0.814 0.832 L 0.77 0.85 L 0.742 0.859 L 0.716 0.872 L 0.716 0.872 Z",
|
||||
"Cookie7Sided": "M 0.5 0.021 L 0.536 0.025 L 0.571 0.035 L 0.604 0.053 L 0.634 0.077 L 0.659 0.098 L 0.686 0.114 L 0.716 0.125 L 0.748 0.132 L 0.785 0.14 L 0.82 0.155 L 0.85 0.176 L 0.875 0.202 L 0.895 0.233 L 0.909 0.267 L 0.916 0.304 L 0.916 0.342 L 0.915 0.374 L 0.919 0.406 L 0.929 0.436 L 0.944 0.465 L 0.961 0.499 L 0.97 0.536 L 0.973 0.572 L 0.968 0.609 L 0.956 0.643 L 0.938 0.676 L 0.914 0.704 L 0.884 0.728 L 0.858 0.747 L 0.836 0.77 L 0.818 0.797 L 0.805 0.826 L 0.789 0.861 L 0.767 0.891 L 0.739 0.916 L 0.708 0.935 L 0.674 0.947 L 0.637 0.953 L 0.6 0.952 L 0.562 0.943 L 0.531 0.935 L 0.5 0.932 L 0.468 0.935 L 0.437 0.943 L 0.399 0.952 L 0.362 0.953 L 0.325 0.947 L 0.291 0.935 L 0.26 0.916 L 0.232 0.891 L 0.21 0.861 L 0.194 0.826 L 0.181 0.797 L 0.163 0.77 L 0.141 0.747 L 0.115 0.728 L 0.085 0.704 L 0.061 0.676 L 0.043 0.643 L 0.031 0.609 L 0.026 0.572 L 0.029 0.536 L 0.038 0.499 L 0.055 0.465 L 0.07 0.436 L 0.08 0.406 L 0.084 0.374 L 0.083 0.342 L 0.083 0.304 L 0.09 0.267 L 0.104 0.233 L 0.124 0.202 L 0.149 0.176 L 0.179 0.155 L 0.214 0.14 L 0.251 0.132 L 0.283 0.125 L 0.313 0.114 L 0.34 0.098 L 0.365 0.077 L 0.395 0.053 L 0.428 0.035 L 0.463 0.025 L 0.5 0.021 L 0.5 0.021 Z",
|
||||
"Cookie9Sided": "M 0.5 0.014 L 0.527 0.016 L 0.553 0.023 L 0.578 0.036 L 0.601 0.053 L 0.625 0.071 L 0.651 0.083 L 0.68 0.09 L 0.709 0.092 L 0.738 0.094 L 0.765 0.101 L 0.79 0.112 L 0.812 0.128 L 0.832 0.147 L 0.847 0.17 L 0.859 0.195 L 0.865 0.223 L 0.872 0.252 L 0.884 0.278 L 0.901 0.302 L 0.923 0.322 L 0.943 0.342 L 0.96 0.365 L 0.972 0.39 L 0.979 0.416 L 0.981 0.443 L 0.979 0.471 L 0.971 0.497 L 0.958 0.523 L 0.945 0.549 L 0.937 0.578 L 0.935 0.607 L 0.938 0.636 L 0.941 0.665 L 0.939 0.692 L 0.933 0.719 L 0.921 0.744 L 0.905 0.766 L 0.886 0.786 L 0.863 0.801 L 0.836 0.812 L 0.809 0.824 L 0.785 0.841 L 0.764 0.862 L 0.748 0.886 L 0.733 0.91 L 0.713 0.93 L 0.691 0.946 L 0.666 0.958 L 0.64 0.965 L 0.612 0.967 L 0.584 0.964 L 0.557 0.956 L 0.529 0.947 L 0.499 0.945 L 0.47 0.947 L 0.442 0.956 L 0.415 0.964 L 0.387 0.967 L 0.359 0.965 L 0.333 0.958 L 0.308 0.946 L 0.286 0.93 L 0.266 0.91 L 0.251 0.886 L 0.235 0.862 L 0.214 0.841 L 0.19 0.824 L 0.163 0.812 L 0.136 0.801 L 0.113 0.786 L 0.094 0.766 L 0.078 0.744 L 0.066 0.719 L 0.06 0.692 L 0.058 0.665 L 0.061 0.636 L 0.064 0.607 L 0.062 0.578 L 0.054 0.549 L 0.041 0.523 L 0.028 0.497 L 0.02 0.471 L 0.018 0.443 L 0.02 0.416 L 0.027 0.39 L 0.039 0.365 L 0.056 0.342 L 0.076 0.322 L 0.098 0.302 L 0.115 0.278 L 0.127 0.252 L 0.134 0.223 L 0.14 0.195 L 0.152 0.17 L 0.167 0.147 L 0.187 0.128 L 0.209 0.112 L 0.234 0.101 L 0.261 0.094 L 0.29 0.092 L 0.319 0.09 L 0.348 0.083 L 0.374 0.071 L 0.398 0.053 L 0.421 0.036 L 0.446 0.023 L 0.472 0.016 L 0.5 0.014 L 0.5 0.014 Z",
|
||||
"Diamond": "M 0.499 1 L 0.459 0.994 L 0.421 0.977 L 0.402 0.962 L 0.381 0.939 L 0.319 0.861 L 0.117 0.6 L 0.103 0.577 L 0.093 0.554 L 0.086 0.529 L 0.084 0.503 L 0.086 0.478 L 0.093 0.453 L 0.103 0.429 L 0.117 0.407 L 0.319 0.146 L 0.381 0.067 L 0.402 0.044 L 0.421 0.029 L 0.459 0.013 L 0.5 0.007 L 0.54 0.013 L 0.578 0.029 L 0.597 0.044 L 0.618 0.067 L 0.68 0.146 L 0.882 0.407 L 0.896 0.429 L 0.906 0.453 L 0.913 0.478 L 0.915 0.503 L 0.913 0.529 L 0.906 0.554 L 0.896 0.577 L 0.882 0.6 L 0.68 0.861 L 0.618 0.939 L 0.597 0.962 L 0.578 0.977 L 0.54 0.994 L 0.499 1 L 0.499 1 Z",
|
||||
"Fan": "M 0.957 0.955 L 0.926 0.979 L 0.889 0.995 L 0.852 0.999 L 0.788 1 L 0.151 1 L 0.12 0.996 L 0.092 0.988 L 0.066 0.974 L 0.044 0.955 L 0.026 0.933 L 0.012 0.907 L 0.003 0.879 L 0 0.849 L 0 0.149 L 0.003 0.119 L 0.012 0.091 L 0.026 0.065 L 0.044 0.043 L 0.067 0.025 L 0.093 0.012 L 0.121 0.004 L 0.151 0.001 L 0.214 0.003 L 0.293 0.009 L 0.37 0.022 L 0.444 0.042 L 0.515 0.069 L 0.583 0.102 L 0.646 0.142 L 0.706 0.187 L 0.761 0.237 L 0.812 0.292 L 0.857 0.351 L 0.896 0.415 L 0.93 0.483 L 0.957 0.554 L 0.977 0.628 L 0.991 0.704 L 0.997 0.783 L 0.997 0.785 L 0.998 0.849 L 0.995 0.886 L 0.98 0.923 L 0.957 0.955 L 0.957 0.955 Z",
|
||||
"Flower": "M 0.369 0.186 L 0.396 0.107 L 0.407 0.079 L 0.423 0.053 L 0.442 0.03 L 0.465 0.01 L 0.479 0.002 L 0.495 0 L 0.503 0 L 0.519 0.002 L 0.533 0.01 L 0.556 0.03 L 0.575 0.053 L 0.591 0.079 L 0.603 0.107 L 0.629 0.186 L 0.704 0.148 L 0.732 0.137 L 0.761 0.13 L 0.791 0.127 L 0.821 0.129 L 0.837 0.134 L 0.85 0.143 L 0.855 0.148 L 0.865 0.161 L 0.87 0.177 L 0.871 0.207 L 0.869 0.237 L 0.862 0.267 L 0.85 0.295 L 0.813 0.369 L 0.892 0.396 L 0.92 0.407 L 0.946 0.423 L 0.969 0.442 L 0.989 0.465 L 0.997 0.479 L 0.999 0.495 L 0.999 0.503 L 0.997 0.519 L 0.989 0.533 L 0.969 0.556 L 0.946 0.575 L 0.92 0.591 L 0.892 0.603 L 0.813 0.629 L 0.851 0.704 L 0.862 0.732 L 0.869 0.761 L 0.872 0.791 L 0.87 0.821 L 0.865 0.837 L 0.856 0.85 L 0.851 0.855 L 0.838 0.865 L 0.822 0.87 L 0.792 0.871 L 0.762 0.869 L 0.732 0.862 L 0.704 0.85 L 0.63 0.813 L 0.603 0.892 L 0.592 0.92 L 0.576 0.946 L 0.557 0.969 L 0.534 0.989 L 0.52 0.997 L 0.504 0.999 L 0.496 0.999 L 0.48 0.997 L 0.466 0.989 L 0.443 0.969 L 0.424 0.946 L 0.408 0.92 L 0.396 0.892 L 0.37 0.813 L 0.295 0.851 L 0.267 0.862 L 0.238 0.869 L 0.208 0.872 L 0.178 0.87 L 0.162 0.865 L 0.149 0.856 L 0.144 0.851 L 0.134 0.838 L 0.129 0.822 L 0.128 0.792 L 0.13 0.762 L 0.137 0.732 L 0.149 0.704 L 0.186 0.63 L 0.107 0.603 L 0.079 0.592 L 0.053 0.576 L 0.03 0.557 L 0.01 0.534 L 0.002 0.52 L 0 0.504 L 0 0.496 L 0.002 0.48 L 0.01 0.466 L 0.03 0.443 L 0.053 0.424 L 0.079 0.408 L 0.107 0.396 L 0.186 0.37 L 0.148 0.295 L 0.137 0.267 L 0.13 0.238 L 0.127 0.208 L 0.129 0.178 L 0.134 0.162 L 0.143 0.149 L 0.148 0.144 L 0.161 0.134 L 0.177 0.129 L 0.207 0.128 L 0.237 0.13 L 0.267 0.137 L 0.295 0.149 L 0.369 0.186 L 0.369 0.186 Z",
|
||||
"Gem": "M 0.499 0.999 L 0.475 0.998 L 0.445 0.993 L 0.412 0.982 L 0.321 0.942 L 0.136 0.857 L 0.106 0.84 L 0.08 0.82 L 0.058 0.795 L 0.04 0.767 L 0.027 0.737 L 0.018 0.705 L 0.015 0.672 L 0.017 0.638 L 0.059 0.354 L 0.07 0.309 L 0.089 0.268 L 0.117 0.232 L 0.151 0.201 L 0.378 0.039 L 0.406 0.022 L 0.436 0.01 L 0.468 0.002 L 0.501 0 L 0.534 0.002 L 0.566 0.01 L 0.596 0.022 L 0.624 0.04 L 0.85 0.203 L 0.884 0.233 L 0.911 0.27 L 0.931 0.311 L 0.942 0.355 L 0.982 0.64 L 0.984 0.674 L 0.981 0.707 L 0.972 0.739 L 0.959 0.769 L 0.941 0.797 L 0.918 0.821 L 0.892 0.842 L 0.862 0.859 L 0.677 0.943 L 0.586 0.982 L 0.553 0.993 L 0.523 0.998 L 0.499 0.999 L 0.499 0.999 Z",
|
||||
"Ghostish": "M 0.5 0 L 0.548 0.002 L 0.596 0.009 L 0.641 0.021 L 0.685 0.037 L 0.727 0.057 L 0.766 0.081 L 0.803 0.108 L 0.837 0.139 L 0.867 0.173 L 0.895 0.21 L 0.919 0.249 L 0.939 0.291 L 0.955 0.334 L 0.966 0.38 L 0.974 0.427 L 0.976 0.476 L 0.976 0.76 L 0.974 0.786 L 0.969 0.812 L 0.961 0.836 L 0.95 0.858 L 0.936 0.878 L 0.92 0.896 L 0.881 0.926 L 0.837 0.945 L 0.813 0.95 L 0.789 0.953 L 0.764 0.952 L 0.739 0.948 L 0.714 0.94 L 0.69 0.929 L 0.624 0.892 L 0.597 0.88 L 0.569 0.871 L 0.54 0.865 L 0.51 0.863 L 0.489 0.863 L 0.459 0.865 L 0.43 0.871 L 0.402 0.88 L 0.375 0.892 L 0.309 0.929 L 0.285 0.94 L 0.26 0.948 L 0.235 0.952 L 0.21 0.953 L 0.186 0.95 L 0.162 0.945 L 0.118 0.926 L 0.079 0.896 L 0.063 0.878 L 0.049 0.858 L 0.038 0.836 L 0.03 0.812 L 0.025 0.786 L 0.023 0.76 L 0.023 0.476 L 0.025 0.427 L 0.033 0.38 L 0.044 0.334 L 0.06 0.291 L 0.08 0.249 L 0.104 0.21 L 0.132 0.173 L 0.162 0.139 L 0.196 0.108 L 0.233 0.081 L 0.272 0.057 L 0.314 0.037 L 0.358 0.021 L 0.403 0.009 L 0.451 0.002 L 0.5 0 L 0.5 0 Z",
|
||||
"Heart": "M 0.5 0.285 L 0.504 0.283 L 0.619 0.151 L 0.654 0.12 L 0.693 0.097 L 0.736 0.084 L 0.779 0.081 L 0.823 0.087 L 0.865 0.101 L 0.903 0.125 L 0.936 0.159 L 0.957 0.19 L 0.971 0.224 L 0.98 0.259 L 0.983 0.295 L 0.979 0.331 L 0.969 0.367 L 0.954 0.4 L 0.932 0.431 L 0.501 0.944 L 0.5 0.945 L 0.498 0.944 L 0.067 0.431 L 0.045 0.4 L 0.03 0.367 L 0.02 0.331 L 0.016 0.295 L 0.019 0.259 L 0.028 0.224 L 0.042 0.19 L 0.063 0.159 L 0.096 0.125 L 0.134 0.101 L 0.176 0.087 L 0.22 0.081 L 0.263 0.084 L 0.306 0.097 L 0.345 0.12 L 0.38 0.151 L 0.495 0.283 L 0.5 0.285 L 0.5 0.285 Z",
|
||||
"Oval": "M 0.908 0.091 L 0.931 0.118 L 0.951 0.15 L 0.966 0.184 L 0.977 0.222 L 0.983 0.263 L 0.984 0.306 L 0.981 0.35 L 0.973 0.396 L 0.961 0.442 L 0.944 0.489 L 0.923 0.537 L 0.897 0.585 L 0.868 0.631 L 0.835 0.677 L 0.799 0.72 L 0.761 0.761 L 0.72 0.799 L 0.677 0.835 L 0.631 0.868 L 0.585 0.897 L 0.537 0.923 L 0.489 0.944 L 0.442 0.961 L 0.396 0.973 L 0.35 0.981 L 0.306 0.984 L 0.263 0.983 L 0.222 0.977 L 0.184 0.966 L 0.15 0.951 L 0.118 0.931 L 0.091 0.908 L 0.068 0.881 L 0.048 0.849 L 0.033 0.815 L 0.022 0.777 L 0.016 0.736 L 0.015 0.693 L 0.018 0.649 L 0.026 0.603 L 0.038 0.557 L 0.055 0.51 L 0.076 0.462 L 0.102 0.414 L 0.131 0.368 L 0.164 0.322 L 0.2 0.279 L 0.238 0.238 L 0.279 0.2 L 0.322 0.164 L 0.368 0.131 L 0.414 0.102 L 0.462 0.076 L 0.51 0.055 L 0.557 0.038 L 0.603 0.026 L 0.649 0.018 L 0.693 0.015 L 0.736 0.016 L 0.777 0.022 L 0.815 0.033 L 0.849 0.048 L 0.881 0.068 L 0.908 0.091 L 0.908 0.091 Z",
|
||||
"Pentagon": "M 0.499 0.042 L 0.525 0.044 L 0.55 0.05 L 0.573 0.06 L 0.596 0.073 L 0.918 0.3 L 0.938 0.317 L 0.955 0.336 L 0.968 0.358 L 0.977 0.381 L 0.983 0.405 L 0.985 0.43 L 0.983 0.456 L 0.977 0.481 L 0.856 0.844 L 0.846 0.868 L 0.832 0.89 L 0.815 0.909 L 0.796 0.926 L 0.774 0.939 L 0.751 0.949 L 0.726 0.955 L 0.7 0.957 L 0.299 0.957 L 0.273 0.955 L 0.248 0.949 L 0.225 0.939 L 0.203 0.926 L 0.184 0.909 L 0.167 0.89 L 0.153 0.868 L 0.143 0.844 L 0.022 0.481 L 0.016 0.456 L 0.014 0.43 L 0.016 0.405 L 0.022 0.381 L 0.031 0.358 L 0.044 0.336 L 0.061 0.317 L 0.081 0.3 L 0.403 0.073 L 0.426 0.06 L 0.449 0.05 L 0.474 0.044 L 0.499 0.042 L 0.499 0.042 Z",
|
||||
"Pill": "M 0.873 0.126 L 0.919 0.181 L 0.938 0.211 L 0.955 0.243 L 0.969 0.276 L 0.981 0.31 L 0.99 0.346 L 0.995 0.383 L 1 0.428 L 0.997 0.471 L 0.991 0.513 L 0.98 0.554 L 0.966 0.595 L 0.947 0.633 L 0.925 0.67 L 0.9 0.704 L 0.871 0.736 L 0.736 0.871 L 0.704 0.9 L 0.67 0.925 L 0.633 0.947 L 0.595 0.966 L 0.554 0.98 L 0.513 0.991 L 0.471 0.997 L 0.428 1 L 0.383 0.995 L 0.346 0.99 L 0.31 0.981 L 0.276 0.969 L 0.243 0.955 L 0.211 0.938 L 0.181 0.919 L 0.126 0.873 L 0.08 0.818 L 0.061 0.788 L 0.044 0.756 L 0.03 0.723 L 0.018 0.689 L 0.009 0.653 L 0.004 0.616 L 0 0.571 L 0.002 0.528 L 0.008 0.486 L 0.019 0.445 L 0.033 0.404 L 0.052 0.366 L 0.074 0.329 L 0.099 0.295 L 0.128 0.263 L 0.263 0.128 L 0.295 0.099 L 0.329 0.074 L 0.366 0.052 L 0.404 0.033 L 0.445 0.019 L 0.486 0.008 L 0.528 0.002 L 0.571 0 L 0.616 0.004 L 0.653 0.009 L 0.689 0.018 L 0.723 0.03 L 0.756 0.044 L 0.788 0.061 L 0.818 0.08 L 0.873 0.126 L 0.873 0.126 Z",
|
||||
"PixelCircle": "M 0.499 0 L 0.704 0 L 0.704 0.065 L 0.843 0.065 L 0.843 0.148 L 0.926 0.148 L 0.926 0.296 L 1 0.296 L 1 0.704 L 0.926 0.704 L 0.926 0.852 L 0.843 0.852 L 0.843 0.935 L 0.704 0.934 L 0.704 1 L 0.499 1 L 0.295 0.999 L 0.295 0.934 L 0.157 0.935 L 0.156 0.851 L 0.073 0.851 L 0.074 0.704 L 0 0.704 L 0 0.295 L 0.074 0.295 L 0.074 0.148 L 0.157 0.147 L 0.157 0.064 L 0.296 0.065 L 0.295 0 L 0.499 0 L 0.499 0 Z",
|
||||
"PixelTriangle": "M 0.111 0.499 L 0.114 0 L 0.288 0 L 0.288 0.087 L 0.422 0.087 L 0.422 0.17 L 0.561 0.17 L 0.561 0.265 L 0.674 0.265 L 0.676 0.343 L 0.789 0.343 L 0.789 0.438 L 0.888 0.438 L 0.888 0.561 L 0.789 0.561 L 0.789 0.655 L 0.675 0.656 L 0.674 0.735 L 0.561 0.734 L 0.56 0.829 L 0.422 0.829 L 0.422 0.912 L 0.288 0.912 L 0.288 1 L 0.114 1 L 0.111 0.499 L 0.111 0.499 Z",
|
||||
"Puffy": "M 0.5 0.17 L 0.517 0.143 L 0.533 0.126 L 0.554 0.113 L 0.579 0.105 L 0.607 0.103 L 0.634 0.107 L 0.659 0.116 L 0.679 0.129 L 0.694 0.146 L 0.702 0.158 L 0.713 0.18 L 0.718 0.203 L 0.72 0.225 L 0.732 0.21 L 0.748 0.199 L 0.767 0.191 L 0.787 0.186 L 0.809 0.185 L 0.83 0.188 L 0.85 0.195 L 0.868 0.206 L 0.871 0.209 L 0.889 0.225 L 0.902 0.244 L 0.911 0.263 L 0.916 0.284 L 0.917 0.291 L 0.916 0.316 L 0.91 0.341 L 0.897 0.364 L 0.878 0.386 L 0.884 0.386 L 0.908 0.387 L 0.931 0.393 L 0.95 0.403 L 0.966 0.417 L 0.981 0.435 L 0.991 0.455 L 0.997 0.476 L 1 0.497 L 1 0.502 L 0.997 0.523 L 0.991 0.544 L 0.981 0.564 L 0.966 0.582 L 0.95 0.596 L 0.931 0.606 L 0.908 0.612 L 0.884 0.613 L 0.878 0.613 L 0.897 0.635 L 0.91 0.658 L 0.916 0.683 L 0.917 0.708 L 0.916 0.715 L 0.911 0.736 L 0.902 0.755 L 0.889 0.774 L 0.871 0.79 L 0.868 0.793 L 0.85 0.804 L 0.83 0.811 L 0.809 0.814 L 0.787 0.813 L 0.767 0.808 L 0.748 0.8 L 0.732 0.789 L 0.72 0.774 L 0.718 0.796 L 0.713 0.819 L 0.702 0.841 L 0.694 0.853 L 0.679 0.87 L 0.659 0.883 L 0.634 0.892 L 0.607 0.896 L 0.579 0.894 L 0.554 0.886 L 0.533 0.873 L 0.517 0.856 L 0.5 0.829 L 0.482 0.856 L 0.466 0.873 L 0.445 0.886 L 0.42 0.894 L 0.392 0.896 L 0.365 0.892 L 0.34 0.883 L 0.32 0.87 L 0.305 0.853 L 0.297 0.841 L 0.286 0.819 L 0.281 0.796 L 0.279 0.774 L 0.267 0.789 L 0.251 0.8 L 0.232 0.808 L 0.212 0.813 L 0.19 0.814 L 0.169 0.811 L 0.149 0.804 L 0.131 0.793 L 0.128 0.79 L 0.11 0.774 L 0.097 0.755 L 0.088 0.736 L 0.083 0.715 L 0.082 0.708 L 0.083 0.683 L 0.089 0.658 L 0.102 0.635 L 0.121 0.613 L 0.115 0.613 L 0.091 0.612 L 0.068 0.606 L 0.049 0.596 L 0.033 0.582 L 0.018 0.564 L 0.008 0.544 L 0.002 0.523 L 0 0.502 L 0 0.497 L 0.002 0.476 L 0.008 0.455 L 0.018 0.435 L 0.033 0.417 L 0.049 0.403 L 0.068 0.393 L 0.091 0.387 L 0.115 0.386 L 0.121 0.386 L 0.102 0.364 L 0.089 0.341 L 0.083 0.316 L 0.082 0.291 L 0.083 0.284 L 0.088 0.263 L 0.097 0.244 L 0.11 0.225 L 0.128 0.209 L 0.131 0.206 L 0.149 0.195 L 0.169 0.188 L 0.19 0.185 L 0.212 0.186 L 0.232 0.191 L 0.251 0.199 L 0.267 0.21 L 0.279 0.225 L 0.281 0.203 L 0.286 0.18 L 0.297 0.158 L 0.305 0.146 L 0.32 0.129 L 0.34 0.116 L 0.365 0.107 L 0.392 0.103 L 0.42 0.105 L 0.445 0.113 L 0.466 0.126 L 0.482 0.143 L 0.5 0.17 L 0.5 0.17 Z",
|
||||
"PuffyDiamond": "M 0.778 0.221 L 0.8 0.249 L 0.815 0.281 L 0.821 0.318 L 0.818 0.356 L 0.818 0.356 L 0.833 0.354 L 0.865 0.353 L 0.896 0.359 L 0.924 0.372 L 0.949 0.389 L 0.97 0.411 L 0.986 0.438 L 0.996 0.467 L 1 0.499 L 0.996 0.532 L 0.986 0.561 L 0.97 0.588 L 0.949 0.61 L 0.924 0.627 L 0.896 0.64 L 0.865 0.646 L 0.833 0.645 L 0.818 0.643 L 0.818 0.643 L 0.821 0.681 L 0.815 0.718 L 0.8 0.75 L 0.778 0.778 L 0.75 0.8 L 0.718 0.815 L 0.681 0.821 L 0.643 0.818 L 0.643 0.818 L 0.645 0.833 L 0.646 0.865 L 0.64 0.896 L 0.627 0.924 L 0.61 0.949 L 0.588 0.97 L 0.561 0.986 L 0.532 0.996 L 0.499 1 L 0.467 0.996 L 0.438 0.986 L 0.411 0.97 L 0.389 0.949 L 0.372 0.924 L 0.359 0.896 L 0.353 0.865 L 0.354 0.833 L 0.356 0.818 L 0.356 0.818 L 0.318 0.821 L 0.281 0.815 L 0.249 0.8 L 0.221 0.778 L 0.199 0.75 L 0.184 0.718 L 0.178 0.681 L 0.181 0.643 L 0.181 0.642 L 0.166 0.645 L 0.134 0.646 L 0.103 0.64 L 0.075 0.627 L 0.05 0.61 L 0.029 0.588 L 0.013 0.561 L 0.003 0.532 L 0 0.499 L 0.003 0.467 L 0.013 0.438 L 0.029 0.411 L 0.05 0.389 L 0.075 0.372 L 0.103 0.359 L 0.134 0.353 L 0.166 0.354 L 0.181 0.356 L 0.181 0.356 L 0.178 0.318 L 0.184 0.281 L 0.199 0.249 L 0.221 0.221 L 0.249 0.199 L 0.281 0.184 L 0.318 0.178 L 0.356 0.181 L 0.357 0.181 L 0.354 0.166 L 0.353 0.134 L 0.359 0.103 L 0.372 0.075 L 0.389 0.05 L 0.411 0.029 L 0.438 0.013 L 0.467 0.003 L 0.5 0 L 0.532 0.003 L 0.561 0.013 L 0.588 0.029 L 0.61 0.05 L 0.627 0.075 L 0.64 0.103 L 0.646 0.134 L 0.645 0.166 L 0.643 0.181 L 0.643 0.181 L 0.681 0.178 L 0.718 0.184 L 0.75 0.199 L 0.778 0.221 L 0.778 0.221 Z",
|
||||
"SemiCircle": "M 0.969 0.781 L 0.954 0.794 L 0.936 0.804 L 0.916 0.81 L 0.895 0.812 L 0.104 0.812 L 0.083 0.81 L 0.063 0.804 L 0.045 0.794 L 0.03 0.781 L 0.017 0.766 L 0.008 0.748 L 0.002 0.729 L 0 0.708 L 0 0.687 L 0.002 0.636 L 0.01 0.586 L 0.022 0.538 L 0.039 0.492 L 0.06 0.449 L 0.085 0.407 L 0.114 0.369 L 0.146 0.333 L 0.181 0.301 L 0.22 0.272 L 0.261 0.247 L 0.305 0.226 L 0.351 0.209 L 0.399 0.197 L 0.448 0.19 L 0.5 0.187 L 0.551 0.19 L 0.6 0.197 L 0.648 0.209 L 0.694 0.226 L 0.738 0.247 L 0.779 0.272 L 0.818 0.301 L 0.853 0.333 L 0.885 0.369 L 0.914 0.407 L 0.939 0.449 L 0.96 0.492 L 0.977 0.538 L 0.989 0.586 L 0.997 0.636 L 1 0.687 L 1 0.708 L 0.997 0.729 L 0.991 0.748 L 0.982 0.766 L 0.969 0.781 L 0.969 0.781 Z",
|
||||
"Slanted": "M 0.875 0.914 L 0.85 0.933 L 0.832 0.942 L 0.812 0.949 L 0.762 0.958 L 0.698 0.961 L 0.613 0.961 L 0.201 0.96 L 0.185 0.959 L 0.147 0.954 L 0.112 0.942 L 0.08 0.923 L 0.054 0.899 L 0.032 0.87 L 0.017 0.837 L 0.008 0.801 L 0.007 0.762 L 0.009 0.746 L 0.05 0.341 L 0.059 0.257 L 0.068 0.193 L 0.082 0.145 L 0.091 0.125 L 0.102 0.108 L 0.124 0.085 L 0.149 0.066 L 0.167 0.057 L 0.187 0.05 L 0.237 0.041 L 0.301 0.038 L 0.386 0.038 L 0.798 0.039 L 0.814 0.04 L 0.852 0.045 L 0.887 0.057 L 0.919 0.076 L 0.945 0.1 L 0.967 0.129 L 0.982 0.162 L 0.991 0.198 L 0.992 0.237 L 0.99 0.253 L 0.949 0.658 L 0.94 0.742 L 0.931 0.806 L 0.917 0.854 L 0.908 0.874 L 0.897 0.891 L 0.875 0.914 L 0.875 0.914 Z",
|
||||
"SoftBoom": "M 0.733 0.453 L 0.793 0.444 L 0.84 0.439 L 0.887 0.441 L 0.923 0.445 L 0.949 0.451 L 0.974 0.463 L 0.98 0.466 L 0.994 0.48 L 0.999 0.5 L 0.994 0.52 L 0.98 0.535 L 0.974 0.538 L 0.949 0.549 L 0.922 0.555 L 0.887 0.559 L 0.84 0.561 L 0.793 0.556 L 0.733 0.546 L 0.792 0.56 L 0.837 0.574 L 0.88 0.594 L 0.911 0.611 L 0.934 0.627 L 0.952 0.647 L 0.956 0.652 L 0.964 0.671 L 0.961 0.691 L 0.949 0.708 L 0.93 0.716 L 0.923 0.717 L 0.896 0.717 L 0.869 0.713 L 0.835 0.703 L 0.791 0.686 L 0.749 0.664 L 0.698 0.632 L 0.746 0.667 L 0.783 0.698 L 0.815 0.733 L 0.837 0.76 L 0.852 0.783 L 0.861 0.809 L 0.863 0.815 L 0.863 0.836 L 0.853 0.854 L 0.835 0.864 L 0.814 0.864 L 0.808 0.862 L 0.782 0.853 L 0.759 0.838 L 0.732 0.816 L 0.697 0.783 L 0.667 0.747 L 0.632 0.698 L 0.663 0.749 L 0.685 0.791 L 0.702 0.836 L 0.712 0.87 L 0.716 0.897 L 0.715 0.924 L 0.714 0.93 L 0.706 0.949 L 0.69 0.962 L 0.67 0.964 L 0.651 0.957 L 0.646 0.953 L 0.626 0.934 L 0.61 0.911 L 0.593 0.88 L 0.573 0.837 L 0.559 0.792 L 0.546 0.733 L 0.555 0.793 L 0.56 0.84 L 0.558 0.887 L 0.554 0.923 L 0.548 0.949 L 0.536 0.974 L 0.533 0.98 L 0.519 0.994 L 0.499 0.999 L 0.479 0.994 L 0.464 0.98 L 0.461 0.974 L 0.45 0.949 L 0.444 0.922 L 0.44 0.887 L 0.438 0.84 L 0.443 0.793 L 0.453 0.733 L 0.439 0.792 L 0.425 0.837 L 0.405 0.88 L 0.388 0.911 L 0.372 0.934 L 0.352 0.952 L 0.347 0.956 L 0.328 0.964 L 0.308 0.961 L 0.291 0.949 L 0.283 0.93 L 0.282 0.923 L 0.282 0.896 L 0.286 0.869 L 0.296 0.835 L 0.313 0.791 L 0.335 0.749 L 0.367 0.698 L 0.332 0.746 L 0.301 0.783 L 0.266 0.815 L 0.239 0.837 L 0.216 0.852 L 0.19 0.861 L 0.184 0.863 L 0.163 0.863 L 0.145 0.853 L 0.135 0.835 L 0.135 0.814 L 0.137 0.808 L 0.146 0.782 L 0.161 0.759 L 0.183 0.732 L 0.216 0.697 L 0.252 0.667 L 0.301 0.632 L 0.25 0.663 L 0.208 0.685 L 0.163 0.702 L 0.129 0.712 L 0.102 0.716 L 0.075 0.715 L 0.069 0.714 L 0.05 0.706 L 0.037 0.69 L 0.035 0.67 L 0.042 0.651 L 0.046 0.646 L 0.065 0.626 L 0.088 0.61 L 0.119 0.593 L 0.162 0.573 L 0.207 0.559 L 0.266 0.546 L 0.206 0.555 L 0.159 0.56 L 0.112 0.558 L 0.076 0.554 L 0.05 0.548 L 0.025 0.536 L 0.019 0.533 L 0.005 0.519 L 0 0.499 L 0.005 0.479 L 0.019 0.464 L 0.025 0.461 L 0.05 0.45 L 0.077 0.444 L 0.112 0.44 L 0.159 0.438 L 0.206 0.443 L 0.266 0.453 L 0.207 0.439 L 0.162 0.425 L 0.119 0.405 L 0.088 0.388 L 0.065 0.372 L 0.047 0.352 L 0.043 0.347 L 0.035 0.328 L 0.038 0.308 L 0.05 0.291 L 0.069 0.283 L 0.076 0.282 L 0.103 0.282 L 0.13 0.286 L 0.164 0.296 L 0.208 0.313 L 0.25 0.335 L 0.301 0.367 L 0.253 0.332 L 0.216 0.301 L 0.184 0.266 L 0.162 0.239 L 0.147 0.216 L 0.138 0.19 L 0.136 0.184 L 0.136 0.163 L 0.146 0.145 L 0.164 0.135 L 0.185 0.135 L 0.191 0.137 L 0.217 0.146 L 0.24 0.161 L 0.267 0.183 L 0.302 0.216 L 0.332 0.252 L 0.367 0.301 L 0.336 0.25 L 0.314 0.208 L 0.297 0.163 L 0.287 0.129 L 0.283 0.102 L 0.284 0.075 L 0.285 0.069 L 0.293 0.05 L 0.309 0.037 L 0.329 0.035 L 0.348 0.042 L 0.353 0.046 L 0.373 0.065 L 0.389 0.088 L 0.406 0.119 L 0.426 0.162 L 0.44 0.207 L 0.453 0.266 L 0.444 0.206 L 0.439 0.159 L 0.441 0.112 L 0.445 0.076 L 0.451 0.05 L 0.463 0.025 L 0.466 0.019 L 0.48 0.005 L 0.5 0 L 0.52 0.005 L 0.535 0.019 L 0.538 0.025 L 0.549 0.05 L 0.555 0.077 L 0.559 0.112 L 0.561 0.159 L 0.556 0.206 L 0.546 0.266 L 0.56 0.207 L 0.574 0.162 L 0.594 0.119 L 0.611 0.088 L 0.627 0.065 L 0.647 0.047 L 0.652 0.043 L 0.671 0.035 L 0.691 0.038 L 0.708 0.05 L 0.716 0.069 L 0.717 0.076 L 0.717 0.103 L 0.713 0.13 L 0.703 0.164 L 0.686 0.208 L 0.664 0.25 L 0.632 0.301 L 0.667 0.253 L 0.698 0.216 L 0.733 0.184 L 0.76 0.162 L 0.783 0.147 L 0.809 0.138 L 0.815 0.136 L 0.836 0.136 L 0.854 0.146 L 0.864 0.164 L 0.864 0.185 L 0.862 0.191 L 0.853 0.217 L 0.838 0.24 L 0.816 0.267 L 0.783 0.302 L 0.747 0.332 L 0.698 0.367 L 0.749 0.336 L 0.791 0.314 L 0.836 0.297 L 0.87 0.287 L 0.897 0.283 L 0.924 0.284 L 0.93 0.285 L 0.949 0.293 L 0.962 0.309 L 0.964 0.329 L 0.957 0.348 L 0.953 0.353 L 0.934 0.373 L 0.911 0.389 L 0.88 0.406 L 0.837 0.426 L 0.792 0.44 L 0.733 0.453 L 0.733 0.453 Z",
|
||||
"SoftBurst": "M 0.186 0.272 L 0.194 0.256 L 0.196 0.238 L 0.189 0.148 L 0.19 0.134 L 0.194 0.121 L 0.201 0.111 L 0.21 0.102 L 0.221 0.096 L 0.234 0.092 L 0.247 0.092 L 0.26 0.096 L 0.344 0.13 L 0.362 0.134 L 0.38 0.131 L 0.396 0.123 L 0.408 0.109 L 0.455 0.032 L 0.464 0.022 L 0.474 0.014 L 0.486 0.009 L 0.499 0.008 L 0.512 0.009 L 0.524 0.014 L 0.534 0.021 L 0.543 0.032 L 0.591 0.109 L 0.603 0.123 L 0.619 0.131 L 0.637 0.134 L 0.655 0.13 L 0.738 0.095 L 0.751 0.092 L 0.765 0.092 L 0.777 0.095 L 0.788 0.101 L 0.798 0.11 L 0.805 0.121 L 0.809 0.133 L 0.81 0.147 L 0.803 0.237 L 0.805 0.256 L 0.813 0.272 L 0.826 0.284 L 0.842 0.292 L 0.93 0.313 L 0.943 0.318 L 0.954 0.326 L 0.962 0.336 L 0.967 0.347 L 0.97 0.36 L 0.969 0.372 L 0.965 0.385 L 0.957 0.397 L 0.899 0.466 L 0.89 0.482 L 0.887 0.499 L 0.89 0.517 L 0.899 0.533 L 0.957 0.602 L 0.965 0.613 L 0.969 0.626 L 0.97 0.639 L 0.967 0.651 L 0.962 0.663 L 0.954 0.673 L 0.943 0.68 L 0.93 0.685 L 0.842 0.707 L 0.826 0.715 L 0.813 0.727 L 0.805 0.743 L 0.803 0.761 L 0.81 0.851 L 0.809 0.865 L 0.805 0.878 L 0.798 0.888 L 0.789 0.897 L 0.778 0.903 L 0.765 0.907 L 0.752 0.907 L 0.739 0.903 L 0.655 0.869 L 0.637 0.865 L 0.619 0.868 L 0.603 0.876 L 0.591 0.89 L 0.544 0.967 L 0.535 0.977 L 0.525 0.985 L 0.513 0.99 L 0.5 0.991 L 0.487 0.99 L 0.475 0.985 L 0.465 0.978 L 0.456 0.967 L 0.408 0.89 L 0.396 0.876 L 0.38 0.868 L 0.362 0.865 L 0.344 0.869 L 0.261 0.904 L 0.248 0.907 L 0.234 0.907 L 0.222 0.904 L 0.211 0.898 L 0.201 0.889 L 0.194 0.878 L 0.19 0.866 L 0.189 0.852 L 0.196 0.762 L 0.194 0.743 L 0.186 0.727 L 0.173 0.715 L 0.157 0.707 L 0.069 0.686 L 0.056 0.681 L 0.045 0.673 L 0.037 0.663 L 0.032 0.652 L 0.029 0.639 L 0.03 0.627 L 0.034 0.614 L 0.042 0.602 L 0.1 0.533 L 0.109 0.517 L 0.112 0.5 L 0.109 0.482 L 0.1 0.466 L 0.042 0.397 L 0.034 0.386 L 0.03 0.373 L 0.029 0.36 L 0.032 0.348 L 0.037 0.336 L 0.045 0.326 L 0.056 0.319 L 0.069 0.314 L 0.157 0.292 L 0.173 0.284 L 0.186 0.272 L 0.186 0.272 Z",
|
||||
"Square": "M 0.912 0.912 L 0.867 0.948 L 0.816 0.976 L 0.76 0.993 L 0.73 0.998 L 0.7 1 L 0.3 1 L 0.269 0.998 L 0.239 0.993 L 0.183 0.976 L 0.132 0.948 L 0.087 0.912 L 0.051 0.867 L 0.023 0.816 L 0.006 0.76 L 0.001 0.73 L 0 0.7 L 0 0.3 L 0.001 0.269 L 0.006 0.239 L 0.023 0.183 L 0.051 0.132 L 0.087 0.087 L 0.132 0.051 L 0.183 0.023 L 0.239 0.006 L 0.269 0.001 L 0.3 0 L 0.7 0 L 0.73 0.001 L 0.76 0.006 L 0.816 0.023 L 0.867 0.051 L 0.912 0.087 L 0.948 0.132 L 0.976 0.183 L 0.993 0.239 L 0.998 0.269 L 1 0.3 L 1 0.7 L 0.998 0.73 L 0.993 0.76 L 0.976 0.816 L 0.948 0.867 L 0.912 0.912 L 0.912 0.912 Z",
|
||||
"Sunny": "M 0.996 0.5 L 0.992 0.526 L 0.978 0.55 L 0.902 0.639 L 0.889 0.66 L 0.884 0.683 L 0.874 0.8 L 0.867 0.827 L 0.852 0.849 L 0.83 0.864 L 0.803 0.871 L 0.686 0.881 L 0.663 0.886 L 0.642 0.899 L 0.553 0.975 L 0.529 0.989 L 0.503 0.993 L 0.476 0.989 L 0.452 0.975 L 0.363 0.899 L 0.342 0.886 L 0.319 0.881 L 0.202 0.871 L 0.175 0.864 L 0.153 0.849 L 0.138 0.827 L 0.131 0.8 L 0.122 0.683 L 0.116 0.66 L 0.103 0.639 L 0.027 0.55 L 0.013 0.526 L 0.009 0.499 L 0.013 0.473 L 0.027 0.449 L 0.103 0.36 L 0.116 0.339 L 0.122 0.316 L 0.131 0.199 L 0.138 0.172 L 0.153 0.15 L 0.175 0.135 L 0.202 0.128 L 0.319 0.118 L 0.342 0.113 L 0.363 0.1 L 0.452 0.024 L 0.476 0.01 L 0.503 0.006 L 0.529 0.01 L 0.553 0.024 L 0.642 0.1 L 0.663 0.113 L 0.686 0.118 L 0.803 0.128 L 0.83 0.135 L 0.852 0.15 L 0.867 0.172 L 0.874 0.199 L 0.884 0.316 L 0.889 0.339 L 0.902 0.36 L 0.978 0.449 L 0.992 0.473 L 0.996 0.5 L 0.996 0.5 Z",
|
||||
"Triangle": "M 0.5 0.077 L 0.532 0.081 L 0.563 0.094 L 0.59 0.114 L 0.612 0.142 L 0.95 0.727 L 0.963 0.76 L 0.967 0.794 L 0.962 0.827 L 0.95 0.857 L 0.93 0.883 L 0.904 0.903 L 0.873 0.917 L 0.837 0.922 L 0.162 0.922 L 0.126 0.917 L 0.095 0.903 L 0.069 0.883 L 0.049 0.857 L 0.037 0.827 L 0.032 0.794 L 0.036 0.76 L 0.049 0.727 L 0.387 0.142 L 0.409 0.114 L 0.436 0.094 L 0.467 0.081 L 0.5 0.077 L 0.5 0.077 Z",
|
||||
"VerySunny": "M 0.5 0.993 L 0.479 0.99 L 0.46 0.983 L 0.443 0.97 L 0.429 0.953 L 0.393 0.893 L 0.376 0.873 L 0.353 0.859 L 0.328 0.853 L 0.302 0.855 L 0.234 0.872 L 0.212 0.875 L 0.191 0.871 L 0.172 0.863 L 0.155 0.85 L 0.143 0.834 L 0.134 0.815 L 0.131 0.794 L 0.134 0.772 L 0.151 0.704 L 0.153 0.678 L 0.147 0.652 L 0.133 0.63 L 0.113 0.613 L 0.053 0.577 L 0.036 0.563 L 0.023 0.546 L 0.015 0.527 L 0.013 0.506 L 0.015 0.486 L 0.023 0.466 L 0.036 0.449 L 0.053 0.435 L 0.113 0.399 L 0.133 0.382 L 0.147 0.36 L 0.153 0.335 L 0.151 0.308 L 0.134 0.241 L 0.131 0.218 L 0.134 0.197 L 0.143 0.178 L 0.155 0.162 L 0.172 0.149 L 0.191 0.141 L 0.212 0.138 L 0.234 0.14 L 0.302 0.157 L 0.328 0.16 L 0.353 0.153 L 0.375 0.14 L 0.393 0.12 L 0.428 0.06 L 0.442 0.042 L 0.46 0.03 L 0.479 0.022 L 0.499 0.02 L 0.52 0.022 L 0.539 0.03 L 0.556 0.042 L 0.57 0.06 L 0.606 0.12 L 0.623 0.14 L 0.646 0.153 L 0.671 0.16 L 0.697 0.157 L 0.765 0.14 L 0.787 0.138 L 0.808 0.141 L 0.827 0.149 L 0.844 0.162 L 0.856 0.178 L 0.865 0.197 L 0.868 0.218 L 0.865 0.241 L 0.848 0.308 L 0.846 0.335 L 0.852 0.36 L 0.866 0.382 L 0.886 0.399 L 0.946 0.435 L 0.963 0.449 L 0.976 0.466 L 0.984 0.486 L 0.986 0.506 L 0.984 0.527 L 0.976 0.546 L 0.963 0.563 L 0.946 0.577 L 0.886 0.613 L 0.866 0.63 L 0.852 0.652 L 0.846 0.678 L 0.848 0.704 L 0.865 0.772 L 0.868 0.794 L 0.865 0.815 L 0.856 0.834 L 0.844 0.85 L 0.827 0.863 L 0.808 0.871 L 0.787 0.875 L 0.765 0.872 L 0.697 0.855 L 0.671 0.853 L 0.646 0.859 L 0.624 0.872 L 0.606 0.893 L 0.571 0.953 L 0.557 0.97 L 0.539 0.983 L 0.52 0.99 L 0.5 0.993 L 0.5 0.993 Z",
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Material expressive shape SVG paths — generated from Compose MaterialShapes. */
|
||||
|
||||
export { MATERIAL_SHAPE_PATHS } from "./materialShapes.generated";
|
||||
|
||||
import { MATERIAL_SHAPE_PATHS } from "./materialShapes.generated";
|
||||
|
||||
export type MaterialShapeName = keyof typeof MATERIAL_SHAPE_PATHS;
|
||||
|
||||
export function getMaterialShapePath(name: string): string {
|
||||
return MATERIAL_SHAPE_PATHS[name] ?? MATERIAL_SHAPE_PATHS.Circle;
|
||||
}
|
||||
|
||||
interface PathBounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export interface PathUnitSquareFit {
|
||||
transform: string;
|
||||
}
|
||||
|
||||
const pathFitCache = new Map<string, PathUnitSquareFit>();
|
||||
|
||||
/**
|
||||
* Parses M/L/Z path commands and returns axis-aligned bounds of all vertices.
|
||||
* Generated Material shape paths use only these commands.
|
||||
*/
|
||||
function computePathBounds(pathD: string): PathBounds {
|
||||
const tokens = pathD.trim().match(/[MLZmlz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?/g);
|
||||
if (!tokens?.length) {
|
||||
return { minX: 0, minY: 0, maxX: 1, maxY: 1 };
|
||||
}
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
let curX = 0;
|
||||
let curY = 0;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let cmd = "";
|
||||
let i = 0;
|
||||
|
||||
const extend = (x: number, y: number) => {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
curX = x;
|
||||
curY = y;
|
||||
};
|
||||
|
||||
while (i < tokens.length) {
|
||||
const token = tokens[i];
|
||||
if (/^[A-Za-z]$/.test(token)) {
|
||||
cmd = token;
|
||||
i++;
|
||||
if (cmd === "Z" || cmd === "z") {
|
||||
extend(startX, startY);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = Number(tokens[i++]);
|
||||
const y = Number(tokens[i++]);
|
||||
|
||||
let absX = x;
|
||||
let absY = y;
|
||||
switch (cmd) {
|
||||
case "M":
|
||||
absX = x;
|
||||
absY = y;
|
||||
startX = absX;
|
||||
startY = absY;
|
||||
cmd = "L";
|
||||
break;
|
||||
case "m":
|
||||
absX = curX + x;
|
||||
absY = curY + y;
|
||||
startX = absX;
|
||||
startY = absY;
|
||||
cmd = "l";
|
||||
break;
|
||||
case "L":
|
||||
absX = x;
|
||||
absY = y;
|
||||
break;
|
||||
case "l":
|
||||
absX = curX + x;
|
||||
absY = curY + y;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
extend(absX, absY);
|
||||
}
|
||||
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a normalized Material shape path to fill viewBox `0 0 1 1` edge-to-edge.
|
||||
*/
|
||||
export function fitPathToUnitSquare(pathD: string): PathUnitSquareFit {
|
||||
const cached = pathFitCache.get(pathD);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const { minX, minY, maxX, maxY } = computePathBounds(pathD);
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
const fallback = { transform: "" };
|
||||
pathFitCache.set(pathD, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const sx = 1 / width;
|
||||
const sy = 1 / height;
|
||||
const fit: PathUnitSquareFit = {
|
||||
transform: `scale(${sx}, ${sy}) translate(${-minX}, ${-minY})`,
|
||||
};
|
||||
pathFitCache.set(pathD, fit);
|
||||
return fit;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @fileoverview Online status manager for real-time user status tracking
|
||||
* @description Handles subscription to user online statuses via WebSocket
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
StatusUpdateWebSocketMessage,
|
||||
SubscribeStatusWebSocketMessage,
|
||||
UnsubscribeStatusWebSocketMessage
|
||||
} from "./types";
|
||||
import { usePresenceStore } from "@/state/presence";
|
||||
|
||||
export interface UserStatus {
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages online status subscriptions and updates
|
||||
*/
|
||||
export class OnlineStatusManager {
|
||||
private subscribedUsers: Set<number> = new Set();
|
||||
private statusCache: Map<number, UserStatus> = new Map();
|
||||
private authToken: string | null = null;
|
||||
|
||||
/**
|
||||
* Set the authentication token for WebSocket requests
|
||||
*/
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a user's online status
|
||||
*/
|
||||
async subscribe(userId: number): Promise<void> {
|
||||
if (!this.authToken || this.subscribedUsers.has(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message: SubscribeStatusWebSocketMessage = {
|
||||
type: "subscribeStatus",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
userId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.subscribedUsers.add(userId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to subscribe to user ${userId} status:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a user's online status
|
||||
*/
|
||||
async unsubscribe(userId: number): Promise<void> {
|
||||
if (!this.authToken || !this.subscribedUsers.has(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message: UnsubscribeStatusWebSocketMessage = {
|
||||
type: "unsubscribeStatus",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
userId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.subscribedUsers.delete(userId);
|
||||
this.statusCache.delete(userId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to unsubscribe from user ${userId} status:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming status update from WebSocket
|
||||
*/
|
||||
handleStatusUpdate(message: StatusUpdateWebSocketMessage): void {
|
||||
const { userId, online, lastSeen } = message.data;
|
||||
this.statusCache.set(userId, { online, lastSeen });
|
||||
|
||||
// Update the global state
|
||||
const { updateOnlineStatus } = usePresenceStore.getState();
|
||||
updateOnlineStatus(userId, online, lastSeen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached status for a user
|
||||
*/
|
||||
getStatus(userId: number): UserStatus | undefined {
|
||||
return this.statusCache.get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached statuses
|
||||
*/
|
||||
getAllStatuses(): Map<number, UserStatus> {
|
||||
return new Map(this.statusCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if subscribed to a user's status
|
||||
*/
|
||||
isSubscribed(userId: number): boolean {
|
||||
return this.subscribedUsers.has(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscribed user IDs
|
||||
*/
|
||||
getSubscribedUsers(): Set<number> {
|
||||
return new Set(this.subscribedUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all users and clear cache
|
||||
*/
|
||||
async unsubscribeAll(): Promise<void> {
|
||||
const unsubscribePromises = Array.from(this.subscribedUsers).map(userId =>
|
||||
this.unsubscribe(userId)
|
||||
);
|
||||
await Promise.all(unsubscribePromises);
|
||||
this.subscribedUsers.clear();
|
||||
this.statusCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when component unmounts
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.unsubscribeAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const onlineStatusManager = new OnlineStatusManager();
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @fileoverview Utility functions for handling profile links
|
||||
* @description Functions to parse and handle profile links in markdown content.
|
||||
* Supports two formats:
|
||||
* - fromchat.ru/@username (e.g., fromchat.ru/@john_doe)
|
||||
* - fromchat.ru/?u=<userId> (e.g., fromchat.ru/?u=123)
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import escapeStringRegexp from "escape-string-regexp";
|
||||
|
||||
/**
|
||||
* Parses a profile link URL and extracts user information
|
||||
* @param url - The URL to parse
|
||||
* @returns Object with user ID and username if it's a valid profile link, null otherwise
|
||||
*/
|
||||
export function parseProfileLink(url: string = location.pathname): { userId?: number; username?: string } | null {
|
||||
try {
|
||||
let host: string = url.startsWith("@") ? "" : !url.startsWith("/") ? "https://fromchat.ru/" : "/";
|
||||
|
||||
// Handle fromchat.ru/@username format
|
||||
const usernameMatch = url.match(new RegExp(`${escapeStringRegexp(host)}@([a-zA-Z0-9_-]+)`));
|
||||
if (usernameMatch) {
|
||||
return { username: usernameMatch[1] };
|
||||
}
|
||||
|
||||
// Handle fromchat.ru/?u=<userId> format
|
||||
const userIdMatch = url.match(new RegExp(`${escapeStringRegexp(host)}\\?u=(\\d+)`));
|
||||
if (userIdMatch) {
|
||||
return { userId: Number(userIdMatch[1]) };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error parsing profile link:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import api from "@/core/api";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import { websocket } from "@/core/websocket";
|
||||
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
|
||||
import serviceWorker from "./service-worker?worker&url";
|
||||
import logo from "@/images/logo.svg";
|
||||
|
||||
export interface PushSubscriptionData {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Global state
|
||||
let isInitialized = false;
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let subscription: PushSubscription | null = null;
|
||||
let isElectronReceiverRunning = false;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
// Helper functions
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
async function subscribeToWebPush(): Promise<PushSubscription | null> {
|
||||
if (!registration) {
|
||||
throw new Error("Service Worker not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(
|
||||
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
|
||||
).slice().buffer
|
||||
});
|
||||
|
||||
console.log("Push subscription successful");
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error("Push subscription failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSubscriptionToServer(token: string): Promise<boolean> {
|
||||
if (!subscription) {
|
||||
throw new Error("No push subscription available");
|
||||
}
|
||||
|
||||
const subscriptionData: PushSubscriptionData = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: arrayBufferToBase64(subscription.getKey("p256dh")!),
|
||||
auth: arrayBufferToBase64(subscription.getKey("auth")!)
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await api.push.subscription.subscribe(subscriptionData, token);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to send subscription to server:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function showMessageNotification(message: any): Promise<void> {
|
||||
try {
|
||||
await showNotification({
|
||||
title: `New message from ${message.username}`,
|
||||
body: message.content.length > 100
|
||||
? message.content.substring(0, 100) + "..."
|
||||
: message.content,
|
||||
icon: message.profile_picture || logo,
|
||||
tag: `message_${message.id}`,
|
||||
data: {
|
||||
type: "public_message",
|
||||
message_id: message.id,
|
||||
sender_id: message.user_id,
|
||||
sender_username: message.username
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show message notification:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
|
||||
// Handle notifications for new messages
|
||||
if (response.type === "newMessage" && response.data) {
|
||||
const newResponse = response as NewMessageWebSocketMessage;
|
||||
await showMessageNotification(newResponse.data);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API functions
|
||||
export async function initialize(): Promise<boolean> {
|
||||
if (isInitialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isElectron) {
|
||||
// For Electron, we just need to request permission
|
||||
const permission = await window.electronInterface.notifications.requestPermission();
|
||||
isInitialized = permission === "granted";
|
||||
return isInitialized;
|
||||
} else {
|
||||
// For web browsers, initialize service worker and push manager
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
console.log("Push messaging is not supported");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
registration = await navigator.serviceWorker.register(serviceWorker, { type: "module" });
|
||||
console.log("Service Worker registered successfully");
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === "granted") {
|
||||
await subscribeToWebPush();
|
||||
isInitialized = true;
|
||||
}
|
||||
return isInitialized;
|
||||
} catch (error) {
|
||||
console.error("Service Worker registration failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize notification service:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribe(token: string): Promise<boolean> {
|
||||
if (!isInitialized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need server-side subscription
|
||||
return true;
|
||||
}
|
||||
|
||||
// If subscription doesn't exist, try to get it from the push manager or create a new one
|
||||
if (!subscription && registration) {
|
||||
try {
|
||||
// Try to get existing subscription first
|
||||
subscription = await registration.pushManager.getSubscription();
|
||||
// If no existing subscription, create a new one
|
||||
if (!subscription) {
|
||||
subscription = await subscribeToWebPush();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get or create subscription:", error);
|
||||
subscription = await subscribeToWebPush();
|
||||
}
|
||||
}
|
||||
|
||||
return await sendSubscriptionToServer(token);
|
||||
}
|
||||
|
||||
export async function showNotification(payload: NotificationPayload): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
try {
|
||||
return await window.electronInterface.notifications.show({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
icon: payload.icon,
|
||||
tag: payload.tag
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to show Electron notification:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For web browsers, notifications are handled by the service worker
|
||||
// when push messages are received from the server
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function unsubscribe(): Promise<boolean> {
|
||||
if (isElectron) {
|
||||
// In Electron, we don't need to unsubscribe from server
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await subscription.unsubscribe();
|
||||
subscription = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error("Failed to unsubscribe:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupported(): boolean {
|
||||
if (isElectron) {
|
||||
return true; // Electron always supports notifications
|
||||
}
|
||||
return "serviceWorker" in navigator && "PushManager" in window;
|
||||
}
|
||||
|
||||
// Electron-specific functions
|
||||
export async function startElectronReceiver(): Promise<void> {
|
||||
if (!isElectron || isElectronReceiverRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = true;
|
||||
|
||||
// Add our own message listener to the existing WebSocket
|
||||
messageListener = (event: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(event.data);
|
||||
handleWebSocketMessage(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener('message', messageListener);
|
||||
}
|
||||
|
||||
export function stopElectronReceiver(): void {
|
||||
if (!isElectron) {
|
||||
return;
|
||||
}
|
||||
|
||||
isElectronReceiverRunning = false;
|
||||
|
||||
// Remove our message listener
|
||||
if (messageListener) {
|
||||
websocket.removeEventListener('message', messageListener);
|
||||
messageListener = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import logo from "@/images/logo.svg";
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
interface NotificationPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
tag?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface NotificationAction {
|
||||
action: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface NotificationOptions {
|
||||
body: string;
|
||||
icon: string;
|
||||
badge: string;
|
||||
image?: string;
|
||||
tag: string;
|
||||
data?: any;
|
||||
actions: NotificationAction[];
|
||||
requireInteraction: boolean;
|
||||
silent: boolean;
|
||||
}
|
||||
|
||||
// Service Worker for Push Notifications
|
||||
self.addEventListener("push", function(event: ExtendableEvent) {
|
||||
const pushEvent = event as PushEvent;
|
||||
if (pushEvent.data) {
|
||||
const data: NotificationPayload = pushEvent.data.json();
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: data.body,
|
||||
icon: data.icon || logo,
|
||||
badge: logo,
|
||||
image: data.image,
|
||||
tag: data.tag || "message",
|
||||
data: data.data,
|
||||
actions: [
|
||||
{
|
||||
action: "open",
|
||||
title: "Open Chat"
|
||||
},
|
||||
{
|
||||
action: "close",
|
||||
title: "Close"
|
||||
}
|
||||
],
|
||||
requireInteraction: true,
|
||||
silent: false
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, options)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", function(event: ExtendableEvent) {
|
||||
const notificationEvent = event as NotificationEvent;
|
||||
notificationEvent.notification.close();
|
||||
|
||||
if (notificationEvent.action === "open" || !notificationEvent.action) {
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window" }).then(function(clientList: readonly WindowClient[]) {
|
||||
// If there's already a window open, focus it
|
||||
for (let i = 0; i < clientList.length; i++) {
|
||||
const client = clientList[i];
|
||||
if (client.url === self.location.origin && "focus" in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Otherwise, open a new window
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(self.location.origin);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclose", function(_event: ExtendableEvent) {
|
||||
// Handle notification close if needed
|
||||
});
|
||||
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* @fileoverview Global TypeScript type definitions
|
||||
* @description Contains all type definitions used throughout the application
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* HTTP headers object type
|
||||
* @typedef {Object.<string, string>} Headers
|
||||
*/
|
||||
export type Headers = {[x: string]: string}
|
||||
|
||||
/**
|
||||
* API error response structure
|
||||
* @interface ErrorResponse
|
||||
* @property {string} message - Error message from the server
|
||||
*/
|
||||
export interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D coordinate structure
|
||||
* @interface Size2D
|
||||
* @property {number} x - X coordinate
|
||||
* @property {number} y - Y coordinate
|
||||
*/
|
||||
export interface Size2D {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect extends Size2D {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// App types
|
||||
|
||||
export type VerificationStatus = "verified" | "warning" | "blocked" | "none";
|
||||
|
||||
/**
|
||||
* Chat message structure
|
||||
* @interface Message
|
||||
* @property {number} id - Unique message identifier
|
||||
* @property {string} username - Username of the message sender
|
||||
* @property {string} content - Message content
|
||||
* @property {boolean} is_read - Whether the message has been read
|
||||
* @property {boolean} is_edited - Whether the message has been edited
|
||||
* @property {string} timestamp - ISO timestamp of the message
|
||||
* @property {string} [profile_picture] - URL to sender's profile picture
|
||||
* @property {Message} [reply_to] - The message this is replying to
|
||||
*/
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
user_id: number;
|
||||
username: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
is_edited: boolean;
|
||||
timestamp: string;
|
||||
profile_picture?: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
reply_to?: Message;
|
||||
files?: Attachment[];
|
||||
reactions?: Reaction[];
|
||||
|
||||
runtimeData?: {
|
||||
dmEnvelope?: DmEnvelope;
|
||||
sendingState?: {
|
||||
status: 'sending' | 'sent' | 'failed';
|
||||
tempId?: string; // Temporary ID for tracking until server confirms
|
||||
retryData?: {
|
||||
content: string;
|
||||
replyToId?: number;
|
||||
files?: File[];
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of messages
|
||||
* @interface Messages
|
||||
* @property {Message[]} messages - Array of message objects
|
||||
*/
|
||||
export interface Messages {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* User information structure
|
||||
* @interface User
|
||||
* @property {number} id - Unique user identifier
|
||||
* @property {string} created_at - ISO timestamp of account creation
|
||||
* @property {string} last_seen - ISO timestamp of last activity
|
||||
* @property {boolean} online - Whether the user is currently online
|
||||
* @property {string} username - Username
|
||||
* @property {string} [bio] - User biography
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
created_at: string;
|
||||
last_seen: string;
|
||||
online: boolean;
|
||||
username: string;
|
||||
display_name: string;
|
||||
admin?: boolean;
|
||||
bio?: string;
|
||||
profile_picture: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
suspended?: boolean;
|
||||
suspension_reason?: string | null;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User profile response structure
|
||||
* @interface UserProfile
|
||||
* @property {number} id - Unique user identifier
|
||||
* @property {string} username - Username
|
||||
* @property {string} [profile_picture] - URL to user's profile picture
|
||||
* @property {string} [bio] - User biography
|
||||
* @property {boolean} online - Whether the user is currently online
|
||||
* @property {string} last_seen - ISO timestamp of last activity
|
||||
* @property {string} created_at - ISO timestamp of account creation
|
||||
*/
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
profile_picture?: string;
|
||||
bio?: string;
|
||||
online: boolean;
|
||||
last_seen: string;
|
||||
created_at: string;
|
||||
verified?: boolean;
|
||||
verification_status?: VerificationStatus;
|
||||
deleted?: boolean;
|
||||
suspended?: boolean;
|
||||
}
|
||||
|
||||
// ----------
|
||||
// API models
|
||||
// ----------
|
||||
|
||||
// Requests
|
||||
|
||||
/**
|
||||
* Login request structure
|
||||
* @interface LoginRequest
|
||||
* @property {string} username - Username for authentication
|
||||
* @property {string} password - Password for authentication
|
||||
*/
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration request structure
|
||||
* @interface RegisterRequest
|
||||
* @property {string} username - Desired username
|
||||
* @property {string} password - Desired password
|
||||
* @property {string} confirm_password - Password confirmation
|
||||
*/
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
display_name: string;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
export interface UploadPublicKeyRequest {
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
export interface SendDMRequest {
|
||||
recipientId: number;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
// Responses
|
||||
|
||||
/**
|
||||
* Login response structure
|
||||
* @interface LoginResponse
|
||||
* @property {User} user - User information
|
||||
* @property {string} token - JWT authentication token
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface BackupBlob {
|
||||
blob: string;
|
||||
}
|
||||
|
||||
export interface BaseDmEnvelope {
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
recipientId: number;
|
||||
}
|
||||
|
||||
export interface DmEnvelope extends BaseDmEnvelope {
|
||||
id: number;
|
||||
senderId: number;
|
||||
files?: DmFile[];
|
||||
timestamp: string;
|
||||
reactions?: Reaction[];
|
||||
replyToId?: number;
|
||||
}
|
||||
|
||||
export interface DmFile {
|
||||
name: string;
|
||||
id: number;
|
||||
path: string;
|
||||
dm_envelope_id?: number;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: string;
|
||||
}
|
||||
|
||||
export interface DmEditedPayload {
|
||||
id: number;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface DmDeletedPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number
|
||||
}
|
||||
|
||||
export interface FetchDMResponse {
|
||||
messages: DmEnvelope[]
|
||||
}
|
||||
|
||||
export interface DmEncryptedJSON {
|
||||
type: "text",
|
||||
data: {
|
||||
content: string;
|
||||
reply_to_id?: number;
|
||||
files?: Attachment[];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------
|
||||
// WebSocket types
|
||||
// ---------------
|
||||
|
||||
/**
|
||||
* WebSocket message structure
|
||||
* @interface WebSocketMessage
|
||||
* @property {string} type - Message type identifier
|
||||
* @property {WebSocketCredentials} [credentials] - Authentication credentials
|
||||
* @property {any} [data] - Message payload data
|
||||
* @property {WebSocketError} [error] - Error information if applicable
|
||||
*/
|
||||
export interface WebSocketMessage<T> {
|
||||
type: string;
|
||||
credentials?: WebSocketCredentials;
|
||||
data?: T;
|
||||
error?: WebSocketError;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket error structure
|
||||
* @interface WebSocketError
|
||||
* @property {number} code - Error code
|
||||
* @property {string} detail - Error detail message
|
||||
*/
|
||||
export interface WebSocketError {
|
||||
code: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket authentication credentials
|
||||
* @interface WebSocketCredentials
|
||||
* @property {string} scheme - Authentication scheme (e.g., "Bearer")
|
||||
* @property {string} credentials - Authentication token or credentials
|
||||
*/
|
||||
export interface WebSocketCredentials {
|
||||
scheme: string;
|
||||
credentials: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
path: string;
|
||||
encrypted: boolean;
|
||||
name: string;
|
||||
wrapped_mek_b64?: string;
|
||||
nonce_b64?: string;
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// WebSocket message types
|
||||
// -----------------------
|
||||
|
||||
// Utils
|
||||
export interface DMEditPayload {
|
||||
id: number;
|
||||
senderId: number;
|
||||
recipientId: number;
|
||||
iv_b64: string;
|
||||
ciphertext_b64: string;
|
||||
wrapped_mek_b64: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Requests
|
||||
export interface DMEditRequest extends WebSocketMessage {
|
||||
type: "dmEdit",
|
||||
credentials: WebSocketCredentials;
|
||||
data: DMEditPayload
|
||||
}
|
||||
|
||||
export interface SendMessageRequest extends WebSocketMessage {
|
||||
type: "sendMessage",
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
content: string;
|
||||
reply_to_id: number | null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddReactionRequest extends WebSocketMessage {
|
||||
type: "addReaction",
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
message_id: number;
|
||||
emoji: string;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AddDmReactionRequest extends WebSocketMessage {
|
||||
type: "addDmReaction",
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
dm_envelope_id: number;
|
||||
emoji: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
export interface DMNewWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmNew",
|
||||
data: DmEnvelope
|
||||
}
|
||||
|
||||
export interface DMEditedWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmEdited",
|
||||
data: DMEditPayload
|
||||
}
|
||||
|
||||
export interface DMDeletedWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmDeleted",
|
||||
data: {
|
||||
id: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageEditedWebSocketMessage extends WebSocketMessage {
|
||||
type: "messageEdited",
|
||||
data: Partial<Message> & { id: number }
|
||||
}
|
||||
|
||||
export interface MessageDeletedWebSocketMessage extends WebSocketMessage {
|
||||
type: "messageDeleted",
|
||||
data: {
|
||||
message_id: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewMessageWebSocketMessage extends WebSocketMessage {
|
||||
type: "newMessage",
|
||||
data: Message
|
||||
}
|
||||
|
||||
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "reactionUpdate",
|
||||
data: {
|
||||
message_id: number;
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
user_id: number;
|
||||
username: string;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmReactionUpdate",
|
||||
data: {
|
||||
dm_envelope_id: number;
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
user_id: number;
|
||||
username: string;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
}
|
||||
|
||||
// Shared types
|
||||
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage
|
||||
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage
|
||||
|
||||
// -----------
|
||||
// Encrypted message JSON (plaintext structure before encryption)
|
||||
// -----------
|
||||
|
||||
export type ChatMessageKind = "text"; // Extendable for future kinds
|
||||
|
||||
export interface EncryptedTextMessageData {
|
||||
content: string;
|
||||
files?: Attachment[];
|
||||
reply_to_id?: number | null;
|
||||
}
|
||||
|
||||
export interface EncryptedMessageJson {
|
||||
type: ChatMessageKind;
|
||||
data: EncryptedTextMessageData;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// React types
|
||||
// -----------
|
||||
export interface DialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
// Call types
|
||||
export interface CallSignalingData {
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
}
|
||||
|
||||
export interface CallInviteData {
|
||||
fromUsername: string;
|
||||
}
|
||||
|
||||
export interface CallInviteMessageData {
|
||||
fromUsername: string;
|
||||
}
|
||||
|
||||
export type CallSignalingDataType = "call_offer" | "call_answer" | "call_ice_candidate" | "call_end" | "call_invite" | "call_accept" | "call_reject" | "call_session_key" | "call_signaling" | "call_video_toggle" | "call_screen_share_toggle";
|
||||
|
||||
export interface CallSignalingMessage extends WebSocketMessage {
|
||||
type: CallSignalingDataType;
|
||||
fromUserId: number;
|
||||
toUserId: number;
|
||||
sessionKeyHash?: string;
|
||||
data: CallSignalingMessageData;
|
||||
}
|
||||
|
||||
export type CallSignalingMessageData =
|
||||
| CallInviteMessageData
|
||||
| CallAcceptData
|
||||
| CallRejectData
|
||||
| CallOfferData
|
||||
| CallAnswerData
|
||||
| CallIceCandidateData
|
||||
| CallEndData
|
||||
| CallSessionKeyData
|
||||
| CallVideoToggleData
|
||||
| CallScreenShareToggleData;
|
||||
|
||||
export interface CallAcceptData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallRejectData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallOfferData extends RTCSessionDescriptionInit {
|
||||
}
|
||||
|
||||
export interface CallAnswerData extends RTCSessionDescriptionInit {
|
||||
}
|
||||
|
||||
export interface CallIceCandidateData extends RTCIceCandidateInit {
|
||||
}
|
||||
|
||||
export interface CallEndData {
|
||||
fromUserId: number;
|
||||
}
|
||||
|
||||
export interface CallSessionKeyData {
|
||||
wrappedSessionKey?: WrappedSessionKeyPayload;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleData {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessageData {
|
||||
fromUserId: number;
|
||||
data: CallVideoToggleData;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleMessageData {
|
||||
fromUserId: number;
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
export interface WrappedSessionKeyPayload {
|
||||
salt: string;
|
||||
iv2: string;
|
||||
wrapped: string;
|
||||
}
|
||||
|
||||
export interface CallVideoToggleMessage extends CallSignalingMessage {
|
||||
type: "call_video_toggle";
|
||||
data: CallVideoToggleData;
|
||||
}
|
||||
|
||||
export interface CallScreenShareToggleMessage extends CallSignalingMessage {
|
||||
type: "call_screen_share_toggle";
|
||||
data: CallScreenShareToggleData;
|
||||
}
|
||||
|
||||
// -----------
|
||||
// Online Status & Typing WebSocket Messages
|
||||
// -----------
|
||||
|
||||
export interface StatusUpdateWebSocketMessage extends WebSocketMessage {
|
||||
type: "statusUpdate";
|
||||
data: {
|
||||
userId: number;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "subscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnsubscribeStatusWebSocketMessage extends WebSocketMessage {
|
||||
type: "unsubscribeStatus";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
userId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "typing";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingWebSocketMessage extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
data: {
|
||||
userId: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Request types for sending typing/status messages
|
||||
export interface TypingRequest extends WebSocketMessage {
|
||||
type: "typing";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface StopTypingRequest extends WebSocketMessage {
|
||||
type: "stopTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {};
|
||||
}
|
||||
|
||||
export interface DmTypingRequest extends WebSocketMessage {
|
||||
type: "dmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StopDmTypingRequest extends WebSocketMessage {
|
||||
type: "stopDmTyping";
|
||||
credentials: WebSocketCredentials;
|
||||
data: {
|
||||
recipientId: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// -------------
|
||||
// Utility types
|
||||
// -------------
|
||||
|
||||
export type Override<TBase, TExt> = Omit<TBase, keyof TExt> & TExt;
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @fileoverview Typing indicator manager for real-time typing status
|
||||
* @description Handles typing indicators for public chat and DMs via WebSocket
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { request } from "./websocket";
|
||||
import type {
|
||||
TypingWebSocketMessage,
|
||||
StopTypingWebSocketMessage,
|
||||
DmTypingWebSocketMessage,
|
||||
StopDmTypingWebSocketMessage,
|
||||
TypingRequest,
|
||||
StopTypingRequest,
|
||||
DmTypingRequest,
|
||||
StopDmTypingRequest
|
||||
} from "./types";
|
||||
import { usePresenceStore } from "@/state/presence";
|
||||
|
||||
/**
|
||||
* Manages typing indicators for public chat and DMs
|
||||
*/
|
||||
export class TypingManager {
|
||||
private authToken: string | null = null;
|
||||
private typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
private readonly TYPING_TIMEOUT = 3000; // 3 seconds
|
||||
|
||||
/**
|
||||
* Set the authentication token for WebSocket requests
|
||||
*/
|
||||
setAuthToken(token: string | null): void {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send typing indicator for public chat
|
||||
*/
|
||||
async sendTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: TypingRequest = {
|
||||
type: "typing",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopTyping("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stop typing indicator for public chat
|
||||
*/
|
||||
async sendStopTyping(): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopTypingRequest = {
|
||||
type: "stopTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout("public");
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send typing indicator for DM
|
||||
*/
|
||||
async sendDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: DmTypingRequest = {
|
||||
type: "dmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.scheduleStopDmTyping(recipientId);
|
||||
} catch (error) {
|
||||
console.error("Failed to send DM typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send stop typing indicator for DM
|
||||
*/
|
||||
async sendStopDmTyping(recipientId: number): Promise<void> {
|
||||
if (!this.authToken) return;
|
||||
|
||||
try {
|
||||
const message: StopDmTypingRequest = {
|
||||
type: "stopDmTyping",
|
||||
credentials: {
|
||||
scheme: "Bearer",
|
||||
credentials: this.authToken
|
||||
},
|
||||
data: {
|
||||
recipientId
|
||||
}
|
||||
};
|
||||
|
||||
await request(message);
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop DM typing indicator:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming typing indicator from WebSocket
|
||||
*/
|
||||
handleTyping(message: TypingWebSocketMessage): void {
|
||||
const { addTypingUser } = usePresenceStore.getState();
|
||||
addTypingUser(message.data.userId, message.data.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stop typing indicator from WebSocket
|
||||
*/
|
||||
handleStopTyping(message: StopTypingWebSocketMessage): void {
|
||||
const { removeTypingUser } = usePresenceStore.getState();
|
||||
removeTypingUser(message.data.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming DM typing indicator from WebSocket
|
||||
*/
|
||||
handleDmTyping(message: DmTypingWebSocketMessage): void {
|
||||
const { setDmTypingUser } = usePresenceStore.getState();
|
||||
setDmTypingUser(message.data.userId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stop DM typing indicator from WebSocket
|
||||
*/
|
||||
handleStopDmTyping(message: StopDmTypingWebSocketMessage): void {
|
||||
const { setDmTypingUser } = usePresenceStore.getState();
|
||||
setDmTypingUser(message.data.userId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic stop typing after timeout
|
||||
*/
|
||||
private scheduleStopTyping(context: string): void {
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (context === "public") {
|
||||
await this.sendStopTyping();
|
||||
}
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic stop DM typing after timeout
|
||||
*/
|
||||
private scheduleStopDmTyping(recipientId: number): void {
|
||||
const context = `dm_${recipientId}`;
|
||||
this.clearStopTypingTimeout(context);
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
await this.sendStopDmTyping(recipientId);
|
||||
this.typingTimeouts.delete(context);
|
||||
}, this.TYPING_TIMEOUT);
|
||||
|
||||
this.typingTimeouts.set(context, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stop typing timeout
|
||||
*/
|
||||
private clearStopTypingTimeout(context: string): void {
|
||||
const timeout = this.typingTimeouts.get(context);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
this.typingTimeouts.delete(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop typing for public chat (called when message is sent)
|
||||
*/
|
||||
async stopTypingOnMessage(): Promise<void> {
|
||||
this.clearStopTypingTimeout("public");
|
||||
await this.sendStopTyping();
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop DM typing (called when message is sent)
|
||||
*/
|
||||
async stopDmTypingOnMessage(recipientId: number): Promise<void> {
|
||||
this.clearStopTypingTimeout(`dm_${recipientId}`);
|
||||
await this.sendStopDmTyping(recipientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all timeouts
|
||||
*/
|
||||
cleanup(): void {
|
||||
this.typingTimeouts.forEach(timeout => clearTimeout(timeout));
|
||||
this.typingTimeouts.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const typingManager = new TypingManager();
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @fileoverview Update Manager for Telegram-like update system
|
||||
* @description Handles update sequence numbers, batching, and gap detection
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { openDB, type IDBPDatabase } from "idb";
|
||||
import type { WebSocketCredentials, WebSocketMessage } from "./types";
|
||||
|
||||
interface UpdateMessage<T = any> {
|
||||
type: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface BatchedUpdatesMessage {
|
||||
type: "updates";
|
||||
seq: number;
|
||||
updates: UpdateMessage[];
|
||||
}
|
||||
|
||||
const DB_NAME = "fromchat-updates";
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = "lastSequence";
|
||||
|
||||
let db: IDBPDatabase | null = null;
|
||||
|
||||
/**
|
||||
* Initialize IndexedDB for storing last sequence number
|
||||
*/
|
||||
async function initDB(): Promise<IDBPDatabase> {
|
||||
if (db) return db;
|
||||
|
||||
db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(database) {
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
database.createObjectStore(STORE_NAME);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last received sequence number from IndexedDB
|
||||
*/
|
||||
export async function getLastSequence(): Promise<number> {
|
||||
try {
|
||||
return (await initDB())
|
||||
.transaction(STORE_NAME, "readonly")
|
||||
.objectStore(STORE_NAME)
|
||||
.get("lastSeq") || 0;
|
||||
} catch (error) {
|
||||
console.error("Failed to get last sequence:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the last received sequence number in IndexedDB
|
||||
*/
|
||||
export async function setLastSequence(seq: number): Promise<void> {
|
||||
try {
|
||||
(await initDB()).transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(seq, "lastSeq");
|
||||
} catch (error) {
|
||||
console.error("Failed to set last sequence:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batched updates message
|
||||
* @param message - The batched updates message from the server
|
||||
* @param handler - Function to handle individual updates
|
||||
* @param requestMissedFn - Optional function to request missed updates (for gap detection)
|
||||
*/
|
||||
export async function processBatchedUpdates(
|
||||
message: BatchedUpdatesMessage,
|
||||
handler: (update: UpdateMessage) => void,
|
||||
requestMissedFn?: (lastSeq: number) => Promise<void>
|
||||
): Promise<void> {
|
||||
const { seq, updates } = message;
|
||||
const lastSeq = await getLastSequence();
|
||||
|
||||
// Check for gap
|
||||
if (seq !== lastSeq + 1 && lastSeq > 0) {
|
||||
console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`);
|
||||
|
||||
// Request missing updates if function provided
|
||||
if (requestMissedFn) {
|
||||
try {
|
||||
await requestMissedFn(lastSeq);
|
||||
} catch (error) {
|
||||
console.error("Failed to request missed updates for gap:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process all updates in the batch
|
||||
for (const update of updates) {
|
||||
handler(update);
|
||||
}
|
||||
|
||||
// Update last sequence number
|
||||
await setLastSequence(seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request missed updates from the server
|
||||
* @param lastSeq - The last sequence number we received
|
||||
* @param requestFn - Function to send the request to the server
|
||||
* @param credentials - Optional WebSocket credentials for authentication
|
||||
*/
|
||||
export async function requestMissedUpdates(
|
||||
lastSeq: number,
|
||||
requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise<void>,
|
||||
credentials?: WebSocketCredentials
|
||||
): Promise<void> {
|
||||
if (lastSeq > 0) {
|
||||
await requestFn({
|
||||
type: "getUpdates",
|
||||
data: { lastSeq },
|
||||
credentials
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { parseApiTimestamp } from "@/utils/utils";
|
||||
|
||||
const DELETED_USERNAME_PREFIX = "#deleted";
|
||||
|
||||
export function isDeletedUser(user: { deleted?: boolean }): boolean {
|
||||
return Boolean(user.deleted);
|
||||
}
|
||||
|
||||
export function isSuspendedUser(user: { suspended?: boolean; deleted?: boolean }): boolean {
|
||||
return Boolean(user.suspended) && !user.deleted;
|
||||
}
|
||||
|
||||
export function isDeletedAccountUsername(username: string | undefined | null): boolean {
|
||||
return Boolean(username?.startsWith(DELETED_USERNAME_PREFIX));
|
||||
}
|
||||
|
||||
export function isDeletedPeer(user: {
|
||||
id?: number;
|
||||
deleted?: boolean;
|
||||
username?: string | null;
|
||||
}): boolean {
|
||||
return isDeletedUser(user) || isDeletedAccountUsername(user.username);
|
||||
}
|
||||
|
||||
export const DELETED_ACCOUNT_LABEL = "Deleted account";
|
||||
|
||||
export function deletedUserLabel(): string {
|
||||
return DELETED_ACCOUNT_LABEL;
|
||||
}
|
||||
|
||||
export function displayNameForUser(user: {
|
||||
id?: number;
|
||||
display_name?: string | null;
|
||||
username?: string | null;
|
||||
deleted?: boolean;
|
||||
}): string {
|
||||
if (isDeletedPeer(user)) {
|
||||
return deletedUserLabel();
|
||||
}
|
||||
return user.display_name?.trim() || user.username?.trim() || "";
|
||||
}
|
||||
|
||||
export function isEpochLastSeen(lastSeen: string | undefined | null): boolean {
|
||||
if (!lastSeen) return false;
|
||||
const time = parseApiTimestamp(lastSeen).getTime();
|
||||
return !Number.isNaN(time) && time <= 0;
|
||||
}
|
||||
|
||||
export function formatDeletedUserLastSeen(): string {
|
||||
return "был(а) давно";
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @fileoverview WebSocket connection management for real-time chat
|
||||
* @description Handles WebSocket connections, message processing, and auto-reconnection
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { getChatWebSocketUrl } from "./config";
|
||||
import type { WebSocketMessage } from "./types";
|
||||
import { delay } from "@/utils/utils";
|
||||
import { CallSignalingHandler } from "./calls/signaling";
|
||||
import { onlineStatusManager } from "./onlineStatusManager";
|
||||
import { typingManager } from "./typingManager";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager";
|
||||
import { getAuthToken } from "@/core/api/user/auth";
|
||||
|
||||
interface HttpError extends Error {
|
||||
status?: number;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WebSocket connection to the chat server
|
||||
* @returns {WebSocket} New WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function create(): WebSocket {
|
||||
return new WebSocket(getChatWebSocketUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Global WebSocket instance
|
||||
* @type {WebSocket}
|
||||
*/
|
||||
export let websocket: WebSocket = create();
|
||||
|
||||
/**
|
||||
* Global WebSocket message handler reference
|
||||
* This will be set by the active panel to handle incoming messages
|
||||
*/
|
||||
let globalMessageHandler: ((response: WebSocketMessage<any>) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Call signaling handler
|
||||
*/
|
||||
let callSignalingHandler: CallSignalingHandler | null = null;
|
||||
|
||||
/**
|
||||
* Reconnection state
|
||||
*/
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_DELAY = 30000; // 30 seconds max delay
|
||||
const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second
|
||||
let isReconnecting = false;
|
||||
let messageHandler: ((e: MessageEvent) => void) | null = null;
|
||||
let errorHandler: ((e: Event) => void) | null = null;
|
||||
let closeHandler: ((e: CloseEvent) => void) | null = null;
|
||||
let openHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Set the global WebSocket message handler
|
||||
* @param handler - Function to handle WebSocket messages
|
||||
*/
|
||||
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<any>) => void) | null): void {
|
||||
globalMessageHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the call signaling handler
|
||||
* @param handler - Call signaling handler instance
|
||||
*/
|
||||
export function setCallSignalingHandler(handler: CallSignalingHandler | null): void {
|
||||
callSignalingHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all event listeners from the current WebSocket instance
|
||||
* @private
|
||||
*/
|
||||
function cleanupWebSocket(): void {
|
||||
if (websocket) {
|
||||
if (messageHandler) {
|
||||
websocket.removeEventListener("message", messageHandler);
|
||||
}
|
||||
if (errorHandler) {
|
||||
websocket.removeEventListener("error", errorHandler);
|
||||
}
|
||||
if (closeHandler) {
|
||||
websocket.removeEventListener("close", closeHandler);
|
||||
}
|
||||
if (openHandler) {
|
||||
websocket.removeEventListener("open", openHandler);
|
||||
}
|
||||
|
||||
// Close if still connected
|
||||
if (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) {
|
||||
try {
|
||||
websocket.close();
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay
|
||||
* @param attempt - Current reconnection attempt number
|
||||
* @returns Delay in milliseconds
|
||||
* @private
|
||||
*/
|
||||
function getReconnectDelay(attempt: number): number {
|
||||
const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt);
|
||||
return Math.min(delay, MAX_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket reconnection with exponential backoff
|
||||
* @private
|
||||
*/
|
||||
async function reconnect(): Promise<void> {
|
||||
if (isReconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
isReconnecting = true;
|
||||
|
||||
// Clean up old connection
|
||||
cleanupWebSocket();
|
||||
|
||||
const delayMs = getReconnectDelay(reconnectAttempts);
|
||||
reconnectAttempts++;
|
||||
|
||||
await delay(delayMs);
|
||||
|
||||
try {
|
||||
websocket = create();
|
||||
setupEventHandlers();
|
||||
} catch (error) {
|
||||
// If creation fails, try again
|
||||
isReconnecting = false;
|
||||
reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for the WebSocket connection
|
||||
* @private
|
||||
*/
|
||||
function setupEventHandlers(): void {
|
||||
// Message handler
|
||||
messageHandler = async (e: MessageEvent) => {
|
||||
try {
|
||||
const response: WebSocketMessage<any> = JSON.parse(e.data);
|
||||
|
||||
// Handle batched updates
|
||||
if (response.type === "updates" && "seq" in response && "updates" in response) {
|
||||
// Create function to request missed updates with credentials
|
||||
const token = getAuthToken();
|
||||
const requestMissedFn = token ? async (lastSeq: number) => {
|
||||
await requestMissedUpdates(lastSeq, async (req) => {
|
||||
await request(req);
|
||||
}, {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
});
|
||||
} : undefined;
|
||||
|
||||
await processBatchedUpdates(response as any, (update) => {
|
||||
// Route individual updates to appropriate handlers
|
||||
handleUpdate(update);
|
||||
}, requestMissedFn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle call signaling messages
|
||||
if (callSignalingHandler && response.type === "call_signaling" && response.data) {
|
||||
callSignalingHandler.handleWebSocketMessage(response.data);
|
||||
}
|
||||
|
||||
// Handle status and typing messages (these may come as immediate messages or in batches)
|
||||
handleUpdate(response);
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to handle individual updates
|
||||
function handleUpdate(response: WebSocketMessage<any>): void {
|
||||
if (response.type === "statusUpdate") {
|
||||
onlineStatusManager.handleStatusUpdate(response as any);
|
||||
} else if (response.type === "typing") {
|
||||
typingManager.handleTyping(response as any);
|
||||
} else if (response.type === "stopTyping") {
|
||||
typingManager.handleStopTyping(response as any);
|
||||
} else if (response.type === "dmTyping") {
|
||||
typingManager.handleDmTyping(response as any);
|
||||
} else if (response.type === "stopDmTyping") {
|
||||
typingManager.handleStopDmTyping(response as any);
|
||||
} else if (response.type === "suspended") {
|
||||
// Handle account suspension
|
||||
const { setSuspended } = useUserStore.getState();
|
||||
const reason = response.data?.reason || "No reason provided";
|
||||
setSuspended(reason);
|
||||
// Close WebSocket connection
|
||||
websocket.close();
|
||||
} else if (response.type === "account_deleted") {
|
||||
// Handle account deletion - silent logout
|
||||
const { logout } = useUserStore.getState();
|
||||
logout();
|
||||
// Close WebSocket connection
|
||||
websocket.close();
|
||||
}
|
||||
|
||||
// Route message to global handler if set
|
||||
if (globalMessageHandler) {
|
||||
globalMessageHandler(response);
|
||||
}
|
||||
}
|
||||
websocket.addEventListener("message", messageHandler);
|
||||
|
||||
// Open handler
|
||||
openHandler = async () => {
|
||||
reconnectAttempts = 0; // Reset on successful connection
|
||||
isReconnecting = false;
|
||||
|
||||
// Authenticate by sending ping with credentials and request missed updates
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
const credentials = {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
};
|
||||
|
||||
// Send ping to authenticate and set user_by_ws on the server
|
||||
try {
|
||||
await request({
|
||||
type: "ping",
|
||||
credentials,
|
||||
data: {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send ping on reconnect:", error);
|
||||
}
|
||||
|
||||
// Send last sequence number and request missed updates on reconnect
|
||||
// Wait a bit for ping to complete authentication
|
||||
await delay(100);
|
||||
|
||||
try {
|
||||
const lastSeq = await getLastSequence();
|
||||
if (lastSeq > 0) {
|
||||
await requestMissedUpdates(lastSeq, async (req) => {
|
||||
await request(req);
|
||||
}, credentials);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to request missed updates:", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to authenticate on reconnect:", error);
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("open", openHandler);
|
||||
|
||||
// Error handler
|
||||
errorHandler = () => {
|
||||
// Don't reconnect immediately on error - let close handler handle it
|
||||
// This prevents double reconnection attempts
|
||||
};
|
||||
websocket.addEventListener("error", errorHandler);
|
||||
|
||||
// Close handler
|
||||
closeHandler = (e: CloseEvent) => {
|
||||
// Don't reconnect if it was a clean close (e.g., logout, suspension)
|
||||
if (e.code === 1000 || e.code === 1001) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect for unexpected closes
|
||||
if (!isReconnecting) {
|
||||
reconnect();
|
||||
}
|
||||
};
|
||||
websocket.addEventListener("close", closeHandler);
|
||||
}
|
||||
|
||||
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
|
||||
console.log("WebSocket request:", payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error("Request timed out"));
|
||||
}, 10000);
|
||||
|
||||
function requestInner() {
|
||||
if (websocket.readyState !== WebSocket.OPEN) {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error("WebSocket is not open"));
|
||||
return;
|
||||
}
|
||||
|
||||
const listener = (e: MessageEvent) => {
|
||||
try {
|
||||
const response = JSON.parse(e.data);
|
||||
// Only handle responses that match our request type or have an error
|
||||
if (response.type === payload.type || response.error) {
|
||||
clearTimeout(timeoutId);
|
||||
websocket.removeEventListener("message", listener);
|
||||
|
||||
// Check if the response contains an error field
|
||||
if (response.error) {
|
||||
const error = new Error(response.error.detail || "WebSocket request failed");
|
||||
(error as HttpError).status = response.error.code;
|
||||
(error as HttpError).detail = response.error.detail || "";
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
}
|
||||
// If it doesn't match, let other handlers process it
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
websocket.removeEventListener("message", listener);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.addEventListener("message", listener);
|
||||
|
||||
try {
|
||||
websocket.send(JSON.stringify(payload));
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
websocket.removeEventListener("message", listener);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (websocket.readyState === WebSocket.CONNECTING) {
|
||||
const openListener = () => {
|
||||
websocket.removeEventListener("open", openListener);
|
||||
requestInner();
|
||||
};
|
||||
websocket.addEventListener("open", openListener);
|
||||
} else if (websocket.readyState === WebSocket.OPEN) {
|
||||
requestInner();
|
||||
} else {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error("WebSocket is closed"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Initialization
|
||||
// --------------
|
||||
|
||||
/**
|
||||
* Ensure WebSocket is connected and authenticated after login
|
||||
* This should be called after successful authentication
|
||||
*/
|
||||
export async function ensureAuthenticated(): Promise<void> {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If WebSocket is not connected, wait for it to connect
|
||||
if (websocket.readyState === WebSocket.CONNECTING) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkConnection = () => {
|
||||
if (websocket.readyState === WebSocket.OPEN) {
|
||||
resolve();
|
||||
} else if (websocket.readyState === WebSocket.CLOSED) {
|
||||
// Connection failed, try to reconnect
|
||||
reconnect().then(() => {
|
||||
setTimeout(checkConnection, 100);
|
||||
});
|
||||
} else {
|
||||
setTimeout(checkConnection, 100);
|
||||
}
|
||||
};
|
||||
checkConnection();
|
||||
});
|
||||
} else if (websocket.readyState === WebSocket.CLOSED) {
|
||||
// Reconnect if closed
|
||||
await reconnect();
|
||||
}
|
||||
|
||||
// If WebSocket is open, send ping to authenticate
|
||||
if (websocket.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
const credentials = {
|
||||
scheme: "Bearer",
|
||||
credentials: token
|
||||
};
|
||||
|
||||
await request({
|
||||
type: "ping",
|
||||
credentials,
|
||||
data: {}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send ping after login:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupEventHandlers();
|
||||
@@ -0,0 +1,2 @@
|
||||
$success: #48BB78;
|
||||
$danger: #F56565;
|
||||
@@ -0,0 +1,101 @@
|
||||
@use "material" as *;
|
||||
@use "sass:color";
|
||||
|
||||
.link {
|
||||
color: $color-dark-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rich-text-area {
|
||||
width: 100%;
|
||||
resize: none;
|
||||
transition: height 0.2s ease;
|
||||
overflow-y: hidden;
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quote {
|
||||
background-color: $color-dark-surface-primary-container-lightened;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
|
||||
&.bg-surfaceContainer {
|
||||
background-color: $color-dark-secondary-container;
|
||||
|
||||
.quote-inner {
|
||||
border-left: 3px solid $color-dark-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.quote-inner {
|
||||
border-left: 3px solid $color-dark-primary;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge styles (unified for verified and warning)
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
|
||||
&.verified {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $color-dark-tertiary; // Purple-themed warning color
|
||||
}
|
||||
|
||||
&.small mdui-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&.medium mdui-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&.large mdui-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.similarity-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background-color: $color-dark-error-container;
|
||||
color: $color-dark-on-error-container;
|
||||
border-radius: 8px;
|
||||
margin: 12px 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.verify-section {
|
||||
margin: 16px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dm-list-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@use "sass:color";
|
||||
|
||||
// Dark
|
||||
// Generated from base color #9333EA (rgb(147, 51, 234))
|
||||
$color-dark-primary: rgb(219 185 249);
|
||||
$color-dark-surface-tint: rgb(219 185 249);
|
||||
$color-dark-on-primary: rgb(62 36 88);
|
||||
$color-dark-primary-container: rgb(86 59 113);
|
||||
$color-dark-on-primary-container: rgb(240 219 255);
|
||||
$color-dark-secondary: rgb(208 193 218);
|
||||
$color-dark-on-secondary: rgb(54 44 63);
|
||||
$color-dark-secondary-container: rgb(77 67 86);
|
||||
$color-dark-on-secondary-container: rgb(237 221 246);
|
||||
$color-dark-tertiary: rgb(243 183 190);
|
||||
$color-dark-on-tertiary: rgb(75 37 43);
|
||||
$color-dark-tertiary-container: rgb(101 58 64);
|
||||
$color-dark-on-tertiary-container: rgb(255 217 221);
|
||||
$color-dark-error: rgb(255 180 171);
|
||||
$color-dark-on-error: rgb(105 0 5);
|
||||
$color-dark-error-container: rgb(147 0 10);
|
||||
$color-dark-on-error-container: rgb(255 218 214);
|
||||
$color-dark-background: rgb(21 18 24);
|
||||
$color-dark-on-background: rgb(232 224 232);
|
||||
$color-dark-surface: rgb(21 18 24);
|
||||
$color-dark-on-surface: rgb(232 224 232);
|
||||
$color-dark-surface-variant: rgb(74 69 78);
|
||||
$color-dark-on-surface-variant: rgb(204 196 206);
|
||||
$color-dark-outline: rgb(150 142 152);
|
||||
$color-dark-outline-variant: rgb(74 69 78);
|
||||
$color-dark-shadow: rgb(0 0 0);
|
||||
$color-dark-scrim: rgb(0 0 0);
|
||||
$color-dark-inverse-surface: rgb(232 224 232);
|
||||
$color-dark-inverse-on-surface: rgb(51 47 53);
|
||||
$color-dark-inverse-primary: rgb(111 82 138);
|
||||
$color-dark-primary-fixed: rgb(240 219 255);
|
||||
$color-dark-on-primary-fixed: rgb(40 13 66);
|
||||
$color-dark-primary-fixed-dim: rgb(219 185 249);
|
||||
$color-dark-on-primary-fixed-variant: rgb(86 59 113);
|
||||
$color-dark-secondary-fixed: rgb(237 221 246);
|
||||
$color-dark-on-secondary-fixed: rgb(33 24 41);
|
||||
$color-dark-secondary-fixed-dim: rgb(208 193 218);
|
||||
$color-dark-on-secondary-fixed-variant: rgb(77 67 86);
|
||||
$color-dark-tertiary-fixed: rgb(255 217 221);
|
||||
$color-dark-on-tertiary-fixed: rgb(50 16 22);
|
||||
$color-dark-tertiary-fixed-dim: rgb(243 183 190);
|
||||
$color-dark-on-tertiary-fixed-variant: rgb(101 58 64);
|
||||
$color-dark-surface-dim: rgb(21 18 24);
|
||||
$color-dark-surface-bright: rgb(60 56 62);
|
||||
$color-dark-surface-container-lowest: rgb(16 13 18);
|
||||
$color-dark-surface-container-low: rgb(30 26 32);
|
||||
$color-dark-surface-container: rgb(34 30 36);
|
||||
$color-dark-surface-container-high: rgb(44 41 46);
|
||||
$color-dark-surface-container-highest: rgb(55 51 57);
|
||||
$color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%);
|
||||
$color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%);
|
||||
|
||||
// Light
|
||||
$color-light-primary: rgb(31 101 134);
|
||||
$color-light-surface-tint: rgb(31 101 134);
|
||||
$color-light-on-primary: rgb(255 255 255);
|
||||
$color-light-primary-container: rgb(197 231 255);
|
||||
$color-light-on-primary-container: rgb(0 76 106);
|
||||
$color-light-secondary: rgb(78 97 109);
|
||||
$color-light-on-secondary: rgb(255 255 255);
|
||||
$color-light-secondary-container: rgb(210 229 244);
|
||||
$color-light-on-secondary-container: rgb(55 73 85);
|
||||
$color-light-tertiary: rgb(97 89 124);
|
||||
$color-light-on-tertiary: rgb(255 255 255);
|
||||
$color-light-tertiary-container: rgb(231 222 255);
|
||||
$color-light-on-tertiary-container: rgb(73 66 99);
|
||||
$color-light-error: rgb(186 26 26);
|
||||
$color-light-on-error: rgb(255 255 255);
|
||||
$color-light-error-container: rgb(255 218 214);
|
||||
$color-light-on-error-container: rgb(147 0 10);
|
||||
$color-light-background: rgb(246 250 254);
|
||||
$color-light-on-background: rgb(24 28 31);
|
||||
$color-light-surface: rgb(246 250 254);
|
||||
$color-light-on-surface: rgb(24 28 31);
|
||||
$color-light-surface-variant: rgb(221 227 234);
|
||||
$color-light-on-surface-variant: rgb(65 72 77);
|
||||
$color-light-outline: rgb(113 120 126);
|
||||
$color-light-outline-variant: rgb(193 199 206);
|
||||
$color-light-shadow: rgb(0 0 0);
|
||||
$color-light-scrim: rgb(0 0 0);
|
||||
$color-light-inverse-surface: rgb(44 49 52);
|
||||
$color-light-inverse-on-surface: rgb(237 241 246);
|
||||
$color-light-inverse-primary: rgb(145 206 244);
|
||||
$color-light-primary-fixed: rgb(197 231 255);
|
||||
$color-light-on-primary-fixed: rgb(0 30 45);
|
||||
$color-light-primary-fixed-dim: rgb(145 206 244);
|
||||
$color-light-on-primary-fixed-variant: rgb(0 76 106);
|
||||
$color-light-secondary-fixed: rgb(210 229 244);
|
||||
$color-light-on-secondary-fixed: rgb(10 30 40);
|
||||
$color-light-secondary-fixed-dim: rgb(182 201 216);
|
||||
$color-light-on-secondary-fixed-variant: rgb(55 73 85);
|
||||
$color-light-tertiary-fixed: rgb(231 222 255);
|
||||
$color-light-on-tertiary-fixed: rgb(29 23 53);
|
||||
$color-light-tertiary-fixed-dim: rgb(203 193 233);
|
||||
$color-light-on-tertiary-fixed-variant: rgb(73 66 99);
|
||||
$color-light-surface-dim: rgb(215 218 223);
|
||||
$color-light-surface-bright: rgb(246 250 254);
|
||||
$color-light-surface-container-lowest: rgb(255 255 255);
|
||||
$color-light-surface-container-low: rgb(240 244 248);
|
||||
$color-light-surface-container: rgb(235 238 243);
|
||||
$color-light-surface-container-high: rgb(229 232 237);
|
||||
$color-light-surface-container-highest: rgb(223 227 231);
|
||||
|
||||
@mixin hoverStateLayer($background: $color-surface, $overlayColor: $color-dark-on-primary) {
|
||||
&:hover {
|
||||
background-color: color.mix($overlayColor, $background, 8%);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: color.mix($overlayColor, $background, 18%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@mixin iconsFontStyle() {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(material-symbols.woff2) format('woff2');
|
||||
}
|
||||
|
||||
/* fallback */
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
@include iconsFontStyle();
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
@include iconsFontStyle();
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
@include iconsFontStyle();
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
$normal-size: 24;
|
||||
$large-size: 30;
|
||||
|
||||
&.outlined {
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' $normal-size;
|
||||
|
||||
&.large {
|
||||
font-size: #{$large-size}px;
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' $large-size;
|
||||
}
|
||||
}
|
||||
|
||||
&.filled {
|
||||
font-variation-settings:
|
||||
'FILL' 1,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' $normal-size;
|
||||
|
||||
&.large {
|
||||
font-size: #{$large-size}px;
|
||||
font-variation-settings:
|
||||
'FILL' 1,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' $large-size;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WRhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459W1hyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WZhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WdhyyTh89ZNpQ.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(montserrat/JTUSjIg1_i6t8kCHKm459WlhyyTh89Y.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@use "components";
|
||||
@use "colors" as *;
|
||||
@use "material" as *;
|
||||
|
||||
@use "fonts/montserrat";
|
||||
@use "fonts/material-symbols";
|
||||
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
background-color: $color-dark-surface;
|
||||
color: $color-dark-on-surface;
|
||||
line-height: 1.6;
|
||||
|
||||
#main-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body, #root {
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
mdui-icon {
|
||||
user-select: none;
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="256" height="256" viewBox="0 0 256 256">
|
||||
<line x1="1.407" y1="1.353" x2="1.407" y2="1.46" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0, 0, 0); fill-rule: nonzero; opacity: 1;"/>
|
||||
<g transform="matrix(1.329817, 0, 0, 1.329817, -42.024433, -38.371166)" style="">
|
||||
<g transform="matrix(1, 0, 0, 1, -0.000001, 0.000008)">
|
||||
<path d="M 187.533 167.601 C 184.614 169.278 180.394 175.015 176.028 176.522 L 173.703 176.879 C 173.692 176.878 173.682 176.878 173.671 176.877 C 170.911 176.954 167.048 175.793 164.888 173.621 C 164.62 173.351 164.378 173.064 164.167 172.763 C 163.081 170.76 162.591 168.959 162.078 167.519 C 163.681 138.029 142.006 97.682 141.345 89.031 L 140.535 89.081 L 143.379 87.758 C 146.633 86.246 146.577 81.601 143.289 80.165 L 126.072 72.64 C 124.802 72.084 123.338 72.196 122.169 72.941 L 109.131 81.222 C 106.644 82.801 106.537 86.395 108.928 88.118 L 120.16 96.21 C 121.382 97.093 122.987 97.244 124.353 96.607 L 131.993 93.054 L 124.35 96.609 C 122.984 97.244 121.38 97.093 120.157 96.213 L 112.629 90.787 L 112.182 90.815 C 112.182 96.766 92.754 126.354 85.61 153.184 C 82.2 144.438 85.83 134.593 92.372 122.051 C 83.667 131.338 75.779 146.462 83.605 158.699 C 87.499 164.679 93.454 168.284 99.138 171.235 C 103.255 173.904 113.854 176.441 110.918 182.885 C 110.925 182.876 110.931 182.867 110.938 182.859 C 110.931 182.869 110.925 182.88 110.918 182.89 C 108.679 187.681 104.866 190.521 100.012 192.077 C 98.585 189.825 96.894 187.456 94.937 184.967 L 90.627 179.198 C 89.739 177.993 88.66 176.155 87.266 173.682 C 85.934 171.212 84.729 169.31 83.779 167.978 C 82.956 166.519 81.624 165.061 79.912 163.603 C 79.401 163.15 78.861 162.743 78.299 162.386 C 75.967 160.899 72.963 160.587 70.538 161.919 C 69.819 162.313 69.262 162.771 68.883 163.288 C 67.998 164.493 67.489 165.825 67.363 167.219 C 67.172 168.551 66.792 169.439 66.157 169.883 C 66.152 169.886 66.141 169.891 66.135 169.894 C 65.64 163.51 67.408 157.038 70.867 151.489 C 73.595 147.111 75.664 142.699 76.428 138.228 C 76.779 130.993 78.71 123.934 82.113 117.038 C 86.159 107.861 95.837 101.976 98.644 92.659 C 102.474 80.66 83.964 28.579 126.067 28.984 C 150.53 28.984 159.32 50.152 159.32 84.644 C 159.32 107.391 201.316 122.18 187.533 167.601 Z M 151.486 211.429 C 151.801 212.025 152.146 212.581 152.52 213.101 C 136.094 207.361 120.468 206.548 104.498 212.435 C 105.326 211.043 105.778 209.435 105.778 207.781 C 105.778 207.779 105.778 207.778 105.778 207.776 C 105.778 207.761 105.778 207.746 105.778 207.731 C 105.778 207.729 105.778 207.727 105.778 207.725 C 105.778 204.739 104.88 201.359 103.085 197.582 C 107.83 198.93 113.536 199.677 120.393 199.677 C 136.171 200.012 146.507 196.345 152.958 190.163 C 151.666 200.295 149.855 207.938 151.486 211.429 Z M 168.963 155.718 C 167.694 156.465 166.457 157.278 165.336 158.247 C 164.063 159.306 162.925 160.573 162.122 161.99 C 164.732 160.051 167.618 158.932 170.555 157.952 L 170.855 157.851 C 172.058 156.221 173.112 154.49 173.983 152.649 C 179.496 140.288 172.1 126.454 162.265 118.749 C 168.908 130.233 175.535 143.047 168.963 155.718 Z M 110.325 80.46 L 121.571 73.32 C 121.829 72.168 121.972 70.94 121.972 69.664 C 121.972 63.111 118.283 57.8 113.731 57.8 C 109.179 57.8 105.489 63.111 105.489 69.664 C 105.489 74.469 107.476 78.594 110.325 80.46 Z M 140.237 78.831 C 142.075 76.656 143.244 73.357 143.244 69.664 C 143.244 63.111 139.555 57.8 135.002 57.8 C 130.45 57.8 126.761 63.111 126.761 69.664 C 126.761 70.859 126.887 72.008 127.115 73.095 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 97.708 188.673 C 97.691 188.648 97.674 188.626 97.658 188.6 C 97.677 188.626 97.691 188.651 97.708 188.673 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 178.569 117.583 L 162.265 118.749 C 173.339 131.799 177.532 144.523 169.998 156.693 C 166.446 158.264 163.822 160.028 162.122 161.987 C 162.189 163.875 162.178 165.735 162.08 167.551 C 161.65 166.34 161.203 165.379 160.377 164.766 C 160.374 164.774 160.368 164.786 160.363 164.794 C 161.832 165.882 162.13 168.073 163.274 170.855 C 163.316 170.956 163.353 171.052 163.397 171.156 C 163.619 171.676 163.867 172.21 164.167 172.763 C 166.07 175.489 170.571 176.97 173.676 176.883 C 174.039 176.905 174.404 176.88 174.767 176.829 C 174.882 176.812 175 176.781 175.115 176.759 C 175.365 176.708 175.615 176.649 175.863 176.571 C 175.995 176.529 176.124 176.481 176.256 176.43 C 176.495 176.34 176.734 176.236 176.973 176.124 C 177.099 176.065 177.223 176.006 177.349 175.941 C 177.616 175.801 177.883 175.646 178.147 175.48 C 178.237 175.424 178.327 175.376 178.414 175.317 C 178.768 175.087 179.122 174.84 179.471 174.576 C 179.532 174.531 179.591 174.48 179.651 174.432 C 179.943 174.208 180.229 173.977 180.516 173.738 C 180.628 173.646 180.738 173.55 180.848 173.454 C 181.086 173.249 181.325 173.041 181.558 172.831 C 181.668 172.732 181.778 172.631 181.887 172.533 C 182.14 172.302 182.39 172.069 182.638 171.836 C 182.713 171.766 182.789 171.693 182.865 171.622 C 183.202 171.302 183.537 170.987 183.863 170.675 C 184.141 170.411 184.413 170.153 184.68 169.903 C 184.773 169.818 184.86 169.737 184.95 169.655 C 185.155 169.467 185.358 169.282 185.557 169.107 C 185.658 169.017 185.759 168.933 185.861 168.849 C 186.043 168.694 186.223 168.545 186.397 168.408 C 186.496 168.329 186.594 168.253 186.69 168.18 C 186.869 168.045 187.046 167.924 187.218 167.812 C 187.297 167.761 187.378 167.705 187.454 167.66 C 187.482 167.643 187.513 167.621 187.538 167.604 C 194.434 144.871 187.358 129.81 178.569 117.583 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 97.708 188.676 C 97.708 188.676 97.708 188.676 97.708 188.676 C 97.711 188.679 97.711 188.682 97.714 188.685 C 97.714 188.685 97.714 188.685 97.714 188.685 C 98.54 189.845 99.296 190.975 99.995 192.079 C 104.857 190.525 108.676 187.684 110.918 182.888 C 119.916 168.72 59.028 169.675 92.372 122.051 L 80.221 121.306 C 79.123 124.043 78.257 126.806 77.622 129.593 C 77.611 129.638 77.6 129.683 77.589 129.728 C 77.299 131.021 77.069 132.322 76.883 133.628 C 76.849 133.864 76.821 134.103 76.79 134.339 C 76.625 135.629 76.493 136.924 76.431 138.228 C 75.666 142.699 73.598 147.111 70.87 151.489 C 70.46 152.149 70.075 152.823 69.715 153.506 C 69.414 154.08 69.136 154.661 68.872 155.248 C 68.827 155.344 68.776 155.44 68.734 155.535 C 68.127 156.915 67.616 158.328 67.208 159.767 C 66.273 163.066 65.876 166.488 66.141 169.894 C 66.146 169.891 66.157 169.886 66.163 169.883 C 66.798 169.439 67.177 168.551 67.369 167.219 C 67.495 165.825 68.004 164.493 68.889 163.288 C 69.268 162.771 69.824 162.313 70.544 161.919 C 72.969 160.587 75.973 160.899 78.305 162.386 C 78.867 162.743 79.407 163.15 79.918 163.603 C 81.629 165.061 82.961 166.519 83.785 167.978 C 84.734 169.31 85.94 171.212 87.272 173.682 C 88.666 176.155 89.745 177.993 90.633 179.198 L 94.943 184.967 C 95.688 185.914 96.373 186.836 97.037 187.746 C 97.261 188.058 97.492 188.37 97.708 188.676 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 122.169 72.941 C 118.834 75.093 115.509 77.26 112.241 79.513 L 109.8 81.219 C 107.29 82.543 107.108 86.283 109.592 87.601 C 110.213 88.014 120.685 94.772 120.823 94.921 C 121.686 95.556 122.672 95.629 123.597 95.235 C 123.597 95.235 128.913 92.698 128.913 92.698 C 132.521 91.046 136.14 89.416 139.799 87.879 C 140.951 87.263 143.674 86.679 144.295 85.44 C 145.102 84.229 144.885 82.292 143.618 81.301 C 140.94 79.665 136.031 77.493 133.153 75.984 C 133.153 75.984 127.767 73.449 127.767 73.449 C 126.033 72.567 123.979 71.716 122.169 72.941 Z M 122.169 72.941 C 123.948 71.676 126.072 72.486 127.817 73.337 C 127.817 73.337 133.339 75.568 133.339 75.568 C 136.525 76.889 141.575 78.485 144.585 79.985 C 147.94 82.014 147.237 87.637 143.626 89.039 C 139.26 91.341 134.738 93.535 130.282 95.634 C 127.727 96.584 123.675 99.481 120.826 98.191 C 118.682 97.396 110.668 90.321 108.647 88.91 C 105.613 86.881 106.132 81.958 109.417 80.618 C 109.417 80.618 112 79.134 112 79.134 C 115.428 77.13 118.803 75.043 122.169 72.941 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 137.309 83.658 C 129.815 90.793 116.833 92.487 108.451 85.83 C 118.092 89.511 128.081 88.126 137.309 83.658 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 135.177 76.619 C 136.199 75.787 136.995 74.354 137.234 72.634 C 137.638 69.737 136.337 67.158 134.322 66.877 C 132.31 66.596 130.349 68.717 129.942 71.614 C 129.801 72.612 129.877 73.562 130.102 74.402 L 135.177 76.619 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 115.226 77.35 L 118.477 75.284 C 118.94 74.267 119.14 72.98 118.949 71.612 C 118.544 68.715 116.583 66.593 114.568 66.874 C 112.556 67.155 111.252 69.735 111.657 72.632 C 112.008 75.138 113.523 77.055 115.226 77.35 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 59.565 208.06 C 67.843 208.624 75.959 211.078 83.813 213.494 C 87.536 214.444 91.254 216.223 95.12 215.916 C 110.114 213.418 101.209 197.528 95.48 190.025 C 91.948 185.386 87.606 179.844 84.661 174.59 C 82.773 171.676 80.913 167.472 77.864 165.353 C 76.043 163.476 71.974 162.855 70.552 165.308 C 69.26 167.528 70.055 170.085 67.534 171.853 C 66.008 172.746 64.775 172.898 63.246 173.241 C 63.03 173.227 61.69 173.283 61.453 173.275 C 58.157 173.502 53.65 172.238 52.563 176.27 C 51.548 180.165 53.532 184.234 53.389 188.345 C 53.512 192.045 51.677 195.729 49.702 198.539 C 48.876 199.939 48.05 201.462 47.923 202.836 C 47.923 202.836 47.895 202.625 47.895 202.625 C 48.39 204.522 51 205.533 52.799 206.213 C 54.976 206.989 57.264 207.565 59.565 208.043 C 57.219 207.877 54.858 207.683 52.526 207.127 C 50.104 206.433 47.33 205.812 46.257 203.049 C 46.176 198.978 48.98 195.946 49.725 192.984 C 51.425 187.937 48.719 183.45 48.387 178.296 C 48.379 169.56 54.704 168.554 61.099 168.652 C 61.099 168.652 62.988 168.554 62.988 168.554 L 62.49 168.607 C 63.215 168.411 64.415 168.155 64.924 167.826 C 64.362 168.315 64.946 167.716 64.949 166.739 C 65.267 162.259 69.116 158.446 73.638 158.494 C 76.42 158.37 79.069 159.514 81.166 161.259 C 84.762 163.752 87.005 168.211 89.362 171.945 C 94.294 181.041 102.184 188.401 106.051 198.258 C 110.586 208.352 107.428 218.445 95.457 219.623 C 91.046 219.825 86.937 217.515 82.888 216.127 C 75.248 212.887 67.68 209.875 59.565 208.06 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
<path d="M 173.676 176.883 C 170.04 177.085 166.101 176.093 163.628 173.137 C 162.616 171.504 161.905 169.658 161.242 167.975 C 160.756 166.418 159.579 165.201 158.46 165.297 C 154.203 166.106 156.179 175.222 155.81 178.734 C 155.754 184.413 155.22 190.07 154.523 195.628 C 154.133 200.068 152.424 206.837 153.947 210.561 C 155.895 214.048 159.787 216.161 163.743 215.908 C 166.519 215.635 168.883 213.823 171.541 212.008 C 178.285 206.981 184.38 201.231 191.84 196.904 C 194.948 195.108 198.368 193.566 201.861 192.501 C 203.075 192.231 204.952 190.952 205.103 190.143 C 205.053 189.705 204.471 189.193 204.145 188.879 C 201.195 186.423 195.49 184.627 193.473 182.365 C 190.983 180.314 189.73 176.486 189.986 173.477 C 189.927 170.948 189.772 167.112 188.134 168.394 C 183.601 170.858 179.6 177.209 173.676 176.883 C 176.63 176.759 178.805 174.629 180.834 172.746 C 183.436 170.431 189.337 161.785 191.947 168.818 C 193.237 172.586 192.152 177.414 195.718 179.766 C 196.949 180.974 200.987 182.446 202.777 183.326 C 209.203 185.979 212.499 191.309 205.674 195.839 C 201.861 197.685 197.972 198.767 194.414 201.184 C 187.519 205.522 181.325 211.283 174.736 216.332 C 171.822 218.443 168.059 221.036 163.892 221.219 C 157.221 221.539 149.907 217.024 148.541 210.28 C 147.889 205.092 149.28 199.972 149.974 194.959 C 150.904 189.491 151.747 184.065 152.079 178.594 C 152.748 173.682 151.132 163.049 158.578 162.858 C 163.322 163.372 162.987 169.245 164.743 172.446 C 166.393 174.955 170.386 176.7 173.676 176.883 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 824 KiB |
|
After Width: | Height: | Size: 879 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200px" height="200px" viewBox="0 0 200 200">
|
||||
<path d="M 176.226 68.183 C 175.066 69.083 154.581 80.625 154.581 106.291 C 154.581 135.977 180.646 146.48 181.427 146.739 C 181.307 147.38 177.286 161.122 167.684 175.125 C 159.122 187.448 150.18 199.75 136.577 199.75 C 122.974 199.75 119.474 191.849 103.771 191.849 C 88.467 191.849 83.027 200.011 70.583 200.011 C 58.141 200.011 49.46 188.608 39.478 174.605 C 27.915 158.162 18.573 132.617 18.573 108.372 C 18.573 69.484 43.858 48.859 68.744 48.859 C 81.966 48.859 92.988 57.541 101.29 57.541 C 109.191 57.541 121.514 48.339 136.557 48.339 C 142.259 48.339 162.743 48.859 176.226 68.183 Z M 129.416 31.876 C 135.637 24.494 140.038 14.252 140.038 4.01 C 140.038 2.59 139.918 1.149 139.658 -0.011 C 129.536 0.369 117.493 6.73 110.232 15.152 C 104.531 21.634 99.209 31.876 99.209 42.257 C 99.209 43.818 99.47 45.379 99.59 45.879 C 100.23 45.998 101.27 46.139 102.31 46.139 C 111.393 46.139 122.815 40.057 129.416 31.876 Z" style="fill: rgb(255, 255, 255);" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42">
|
||||
<path fill="#FFFFFF" fill-rule="evenodd" d="M21.47 41.88c-4.11 0-6.02-.6-9.34-3-2.1 2.7-8.75 4.81-9.04 1.2 0-2.71-.6-5-1.28-7.5C1 29.5.08 26.07.08 21.1.08 9.23 9.82.3 21.36.3c11.55 0 20.6 9.37 20.6 20.91a20.6 20.6 0 0 1-20.49 20.67Zm.17-31.32c-5.62-.29-10 3.6-10.97 9.7-.8 5.05.62 11.2 1.83 11.52.58.14 2.04-1.04 2.95-1.95a10.4 10.4 0 0 0 5.08 1.81 10.7 10.7 0 0 0 11.19-9.97 10.7 10.7 0 0 0-10.08-11.1Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 231 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" width="200px" height="200px">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M 13.693 88.614 C 67.369 65.304 103.102 49.815 121.044 42.299 C 172.114 20.984 182.849 17.303 189.751 17.149 C 191.284 17.149 194.658 17.456 196.958 19.295 C 198.799 20.829 199.259 22.823 199.565 24.356 C 199.873 25.889 200.18 29.111 199.873 31.565 C 197.112 60.703 185.15 131.402 179.016 163.916 C 176.408 177.717 171.347 182.317 166.441 182.778 C 155.705 183.699 147.577 175.724 137.302 168.976 C 121.044 158.395 111.997 151.8 96.202 141.371 C 77.951 129.408 89.76 122.815 100.188 112.08 C 102.948 109.319 150.031 66.378 150.95 62.545 C 151.104 62.084 151.104 60.243 150.031 59.323 C 148.956 58.403 147.423 58.71 146.197 59.016 C 144.509 59.323 118.745 76.5 68.596 110.391 C 61.235 115.453 54.64 117.906 48.659 117.754 C 42.065 117.6 29.49 114.072 19.981 111.005 C 8.479 107.325 -0.723 105.331 0.045 98.89 C 0.503 95.516 5.105 92.142 13.693 88.614 Z" style="fill: rgb(255, 255, 255);"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="95" height="95" fill="#ffffff"/>
|
||||
<rect x="105" y="0" width="95" height="95" fill="#ffffff"/>
|
||||
<rect x="0" y="105" width="95" height="95" fill="#ffffff"/>
|
||||
<rect x="105" y="105" width="95" height="95" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 318 B |
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview Application entry point for FromChat frontend
|
||||
* @description Main module that initializes all required components and styles
|
||||
* @author Cursor
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import './css/style.scss';
|
||||
|
||||
import "./utils/material";
|
||||
import "./core/init";
|
||||
import "./core/electron/electron";
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { user } = useUserStore();
|
||||
|
||||
return !user.authToken ? <Navigate to="/login" /> : children;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type React from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import styles from "./auth.module.scss";
|
||||
|
||||
export function AuthContainer({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div className={styles.gradientBackground} />
|
||||
<motion.div
|
||||
className={styles.authCard}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: 10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type IconType = "filled" | "outlined";
|
||||
|
||||
export interface AuthHeaderIcon {
|
||||
name: string;
|
||||
type: IconType
|
||||
}
|
||||
|
||||
export interface AuthHeaderProps {
|
||||
title: string;
|
||||
icon: string | AuthHeaderIcon;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
|
||||
const iconType = typeof icon == "string" ? "filled" : icon.type;
|
||||
const iconName = typeof icon == "string" ? icon : icon.name;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={styles.authHeader}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.1,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
<h2>
|
||||
<motion.span
|
||||
className={`material-symbols ${iconType} large`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
rotate: -10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotate: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.2,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
>
|
||||
{iconName}
|
||||
</motion.span>
|
||||
{title}
|
||||
</h2>
|
||||
<motion.p
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 10
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
delay: 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export type AlertType = "success" | "danger"
|
||||
|
||||
export interface Alert {
|
||||
type: AlertType;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function AlertsContainer({ alerts }: { alerts: Alert[]}) {
|
||||
const displayAlerts = alerts.slice(-3);
|
||||
|
||||
return (
|
||||
<div className={styles.alertContainer}>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{displayAlerts.map((alert, i) => (
|
||||
<motion.div
|
||||
key={`${i}-${alert.message}`}
|
||||
className={`${styles.alert} alert-${alert.type}`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
scale: 0.95
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
scale: 0.95
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
layout
|
||||
>
|
||||
{alert.message}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { AuthContainer } from "./Auth";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
import { LoginForm } from "./LoginForm";
|
||||
import { RegisterForm } from "./RegisterForm";
|
||||
import type { Variants, Transition } from "motion/react";
|
||||
import styles from "./auth.module.scss";
|
||||
|
||||
const MIN_HEIGHT = 400;
|
||||
|
||||
const slideVariants: Variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 300 : -300,
|
||||
opacity: 0,
|
||||
y: 0 // Ensure no vertical movement
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
y: 0 // Ensure no vertical movement
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction > 0 ? -300 : 300,
|
||||
opacity: 0,
|
||||
y: 0 // Ensure no vertical movement
|
||||
})
|
||||
};
|
||||
|
||||
const slideTransition: Transition = {
|
||||
x: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
},
|
||||
opacity: { duration: 0.2 }
|
||||
};
|
||||
|
||||
|
||||
|
||||
export default function AuthPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
|
||||
if (navigateDownloadApp) return navigateDownloadApp;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [direction, setDirection] = useState(0);
|
||||
const prevMode = useRef(searchParams.get("mode") || "login");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loginFormRef = useRef<HTMLDivElement>(null);
|
||||
const registerFormRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState<number>(400);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const currentMode = searchParams.get("mode") || "login";
|
||||
|
||||
useEffect(() => {
|
||||
if (prevMode.current !== currentMode) {
|
||||
setDirection(currentMode === "register" ? 1 : -1);
|
||||
prevMode.current = currentMode;
|
||||
setIsTransitioning(true);
|
||||
}
|
||||
}, [currentMode]);
|
||||
|
||||
// Setup ResizeObserver to watch for content changes
|
||||
useEffect(() => {
|
||||
const activeRef = currentMode === "login" ? loginFormRef : registerFormRef;
|
||||
|
||||
if (activeRef.current) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const height = entry.contentRect.height;
|
||||
if (height > 0) {
|
||||
setContainerHeight(Math.max(height, MIN_HEIGHT));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(activeRef.current);
|
||||
|
||||
// Initial measurement
|
||||
const initialHeight = activeRef.current.scrollHeight;
|
||||
if (initialHeight > 0) {
|
||||
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [currentMode]);
|
||||
|
||||
|
||||
function switchMode(newMode: "login" | "register") {
|
||||
navigate(`/auth?mode=${newMode}`, { replace: true });
|
||||
}
|
||||
|
||||
function handleAnimationComplete() {
|
||||
setIsTransitioning(false);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: `${containerHeight}px`,
|
||||
transition: isTransitioning ? "height 0.3s ease" : "none"
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="sync" custom={direction}>
|
||||
{currentMode === "login" ? (
|
||||
<motion.div
|
||||
key="login"
|
||||
ref={loginFormRef}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
className={styles.formWrapper}
|
||||
>
|
||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="register"
|
||||
ref={registerFormRef}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
className={styles.formWrapper}
|
||||
>
|
||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</AuthContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import styles from "./auth.module.scss";
|
||||
|
||||
export interface AuthTextFieldHandle {
|
||||
value: string;
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
export interface AuthTextFieldProps {
|
||||
label: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
icon?: string;
|
||||
autocomplete?: string;
|
||||
required?: boolean;
|
||||
maxlength?: number;
|
||||
counter?: boolean;
|
||||
"toggle-password"?: boolean;
|
||||
defaultValue?: string;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AuthTextField = forwardRef<AuthTextFieldHandle, AuthTextFieldProps>(
|
||||
({
|
||||
label,
|
||||
name,
|
||||
type = "text",
|
||||
icon,
|
||||
autocomplete,
|
||||
required = false,
|
||||
maxlength,
|
||||
counter = false,
|
||||
"toggle-password": togglePassword = false,
|
||||
defaultValue = "",
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
className = ""
|
||||
}, ref) => {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [charCount, setCharCount] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
const displayType = togglePassword && type === "password" ? (showPassword ? "text" : "password") : type;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) {
|
||||
setInternalValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, isControlled]);
|
||||
|
||||
useEffect(() => {
|
||||
setCharCount(value.length);
|
||||
}, [value]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
focus: () => {
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
blur: () => {
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}));
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
if (!isControlled) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const hasError = false; // Can be extended for validation
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`${styles.authTextField} ${className}`}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
whileFocus={{ scale: 1.01 }}
|
||||
>
|
||||
<div className={`${styles.fieldContainer} ${isFocused ? styles.focused : ""} ${hasError ? styles.error : ""} ${!icon ? styles.noIcon : ""} ${togglePassword && type === "password" ? styles.hasToggle : ""}`}>
|
||||
{icon && (
|
||||
<span className={`material-symbols filled ${styles.fieldIcon}`}>
|
||||
{icon.replace("--filled", "").replace("--outlined", "")}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.inputWrapper}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={displayType}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
autoComplete={autocomplete}
|
||||
required={required}
|
||||
maxLength={maxlength}
|
||||
placeholder={label + (required ? " *" : "")}
|
||||
className={styles.input}
|
||||
/>
|
||||
</div>
|
||||
{togglePassword && type === "password" && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePassword}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<span className="material-symbols filled">
|
||||
{showPassword ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{counter && maxlength && (
|
||||
<div className={styles.counter}>
|
||||
{charCount} / {maxlength}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AuthTextField.displayName = "AuthTextField";
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { motion, type Transition, type Variants } from "motion/react";
|
||||
import { useImmer } from "use-immer";
|
||||
import type { LoginRequest } from "@/core/types";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { MaterialButton } from "@/utils/material";
|
||||
import api from "@/core/api";
|
||||
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
|
||||
import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications";
|
||||
import { isElectron } from "@/core/electron/electron";
|
||||
import type { Alert, AlertType } from "./Auth";
|
||||
import { AuthHeader, AlertsContainer } from "./Auth";
|
||||
import styles from "./auth.module.scss";
|
||||
import { ensureAuthenticated } from "@/core/websocket";
|
||||
|
||||
const loginFieldVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 10
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}
|
||||
};
|
||||
|
||||
const loginFieldTransition: Transition = {
|
||||
duration: 0.3,
|
||||
ease: "easeInOut"
|
||||
};
|
||||
|
||||
const loginButtonVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 10
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}
|
||||
};
|
||||
|
||||
const loginButtonTransition: Transition = {
|
||||
duration: 0.3,
|
||||
delay: 0.4,
|
||||
ease: "easeInOut"
|
||||
};
|
||||
|
||||
interface LoginFormProps {
|
||||
onSwitchMode: () => void;
|
||||
}
|
||||
|
||||
export function LoginForm({ onSwitchMode }: LoginFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const usernameElement = useRef<AuthTextFieldHandle>(null);
|
||||
const passwordElement = useRef<AuthTextFieldHandle>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isLoading) return;
|
||||
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const derived = await api.user.auth.deriveAuthSecret(username, password);
|
||||
const request: LoginRequest = {
|
||||
username: username,
|
||||
password: derived
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.user.auth.login(request);
|
||||
setUser(data.token, data.user);
|
||||
|
||||
try {
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
try {
|
||||
await api.user.auth.syncPublicKeyToServerIfMissing(data.token);
|
||||
} catch (e2) {
|
||||
console.error("Public key re-sync failed:", e2);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure WebSocket is connected and authenticated
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
} catch (e) {
|
||||
console.error("WebSocket authentication failed:", e);
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
|
||||
try {
|
||||
if (isSupported()) {
|
||||
const initialized = await initialize();
|
||||
if (initialized) {
|
||||
await subscribe(data.token);
|
||||
|
||||
if (isElectron) {
|
||||
await startElectronReceiver();
|
||||
}
|
||||
|
||||
console.log("Notifications enabled");
|
||||
} else {
|
||||
console.log("Notification permission denied");
|
||||
}
|
||||
} else {
|
||||
console.log("Notifications not supported");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Notification setup failed:", e);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message && error.message.includes("suspension")) {
|
||||
const setSuspended = useUserStore.getState().setSuspended;
|
||||
setSuspended(error.message || "No reason provided");
|
||||
return;
|
||||
}
|
||||
showAlert("danger", error.message || "Неверное имя пользователя или пароль");
|
||||
}
|
||||
} catch (error: any) {
|
||||
showAlert("danger", error.message || "Ошибка соединения с сервером");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
icon="login"
|
||||
title="Добро пожаловать!"
|
||||
subtitle="Войдите в свой аккаунт"
|
||||
/>
|
||||
<div className={styles.authBody}>
|
||||
<AlertsContainer alerts={alerts} />
|
||||
<motion.form onSubmit={handleSubmit}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={loginFieldVariants}
|
||||
transition={loginFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="@Имя пользователя"
|
||||
name="username"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
required
|
||||
ref={usernameElement} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={loginFieldVariants}
|
||||
transition={loginFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Пароль"
|
||||
name="password"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
</motion.div>
|
||||
|
||||
<div className={styles.authButtons}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={loginButtonVariants}
|
||||
transition={loginButtonTransition}
|
||||
>
|
||||
<MaterialButton type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Вход..." : "Войти"}
|
||||
</MaterialButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.form>
|
||||
|
||||
<p className={styles.registerLink}>
|
||||
Ещё нет аккаунта?
|
||||
<a
|
||||
href="#"
|
||||
className="link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onSwitchMode();
|
||||
}}>
|
||||
Зарегистрируйтесь
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { motion, type Transition, type Variants } from "motion/react";
|
||||
import { useImmer } from "use-immer";
|
||||
import type { RegisterRequest } from "@/core/types";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { MaterialButton, MaterialIconButton } from "@/utils/material";
|
||||
import api from "@/core/api";
|
||||
import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField";
|
||||
import type { Alert, AlertType } from "./Auth";
|
||||
import { AuthHeader, AlertsContainer } from "./Auth";
|
||||
import { LegalInlineLinks } from "@/core/legal/LegalInlineLinks";
|
||||
import styles from "./auth.module.scss";
|
||||
|
||||
const registerFieldVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 10
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}
|
||||
};
|
||||
|
||||
const registerFieldTransition: Transition = {
|
||||
duration: 0.3,
|
||||
ease: "easeInOut"
|
||||
};
|
||||
|
||||
const registerButtonVariants: Variants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 10
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0
|
||||
}
|
||||
};
|
||||
|
||||
const registerButtonTransition: Transition = {
|
||||
duration: 0.3,
|
||||
delay: 0.6,
|
||||
ease: "easeInOut"
|
||||
};
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSwitchMode: () => void;
|
||||
}
|
||||
|
||||
export function RegisterForm({ onSwitchMode }: RegisterFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [alerts, updateAlerts] = useImmer<Alert[]>([]);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
|
||||
function showAlert(type: AlertType, message: string) {
|
||||
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
|
||||
}
|
||||
|
||||
const displayNameElement = useRef<AuthTextFieldHandle>(null);
|
||||
const usernameElement = useRef<AuthTextFieldHandle>(null);
|
||||
const passwordElement = useRef<AuthTextFieldHandle>(null);
|
||||
const confirmPasswordElement = useRef<AuthTextFieldHandle>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (isLoading) return;
|
||||
|
||||
const displayName = displayNameElement.current!.value.trim();
|
||||
const username = usernameElement.current!.value.trim();
|
||||
const password = passwordElement.current!.value.trim();
|
||||
const confirmPassword = confirmPasswordElement.current!.value.trim();
|
||||
|
||||
if (!displayName || !username || !password || !confirmPassword) {
|
||||
showAlert("danger", "Пожалуйста, заполните все поля");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
showAlert("danger", "Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
|
||||
if (displayName.length < 1 || displayName.length > 64) {
|
||||
showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 5 || password.length > 50) {
|
||||
showAlert("danger", "Пароль должен быть от 5 до 50 символов");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const derived = await api.user.auth.deriveAuthSecret(username, password);
|
||||
const request: RegisterRequest = {
|
||||
display_name: displayName,
|
||||
username: username,
|
||||
password: derived,
|
||||
confirm_password: derived
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.user.auth.register(request);
|
||||
setUser(data.token, data.user);
|
||||
|
||||
try {
|
||||
await api.user.auth.ensureKeysOnLogin(password, data.token);
|
||||
} catch (e) {
|
||||
console.error("Key setup failed:", e);
|
||||
try {
|
||||
await api.user.auth.syncPublicKeyToServerIfMissing(data.token);
|
||||
} catch (e2) {
|
||||
console.error("Public key re-sync failed:", e2);
|
||||
}
|
||||
}
|
||||
|
||||
navigate("/chat");
|
||||
} catch (error: any) {
|
||||
showAlert("danger", error.message || "Ошибка при регистрации");
|
||||
}
|
||||
} catch (error: any) {
|
||||
showAlert("danger", error.message || "Ошибка соединения с сервером");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
icon="person_add"
|
||||
title="Регистрация"
|
||||
subtitle="Создайте новый аккаунт"
|
||||
/>
|
||||
<div className={styles.authBody}>
|
||||
<AlertsContainer alerts={alerts} />
|
||||
<motion.form onSubmit={handleSubmit}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Отображаемое имя"
|
||||
name="display_name"
|
||||
icon="badge--filled"
|
||||
autocomplete="name"
|
||||
maxlength={64}
|
||||
counter
|
||||
required
|
||||
ref={displayNameElement} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="@Имя пользователя"
|
||||
name="username"
|
||||
icon="person--filled"
|
||||
autocomplete="username"
|
||||
maxlength={20}
|
||||
counter
|
||||
required
|
||||
ref={usernameElement} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Пароль"
|
||||
name="password"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={passwordElement} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerFieldVariants}
|
||||
transition={registerFieldTransition}
|
||||
>
|
||||
<AuthTextField
|
||||
label="Подтвердите пароль"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
toggle-password
|
||||
icon="password--filled"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
ref={confirmPasswordElement} />
|
||||
</motion.div>
|
||||
|
||||
<div className={styles.authButtons}>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerButtonVariants}
|
||||
transition={registerButtonTransition}
|
||||
>
|
||||
<MaterialIconButton icon="arrow_back" onClick={onSwitchMode} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
variants={registerButtonVariants}
|
||||
transition={registerButtonTransition}
|
||||
>
|
||||
<MaterialButton type="submit" disabled={isLoading} loading={isLoading} icon="person_add">
|
||||
{isLoading ? "Регистрация..." : "Зарегистрироваться"}
|
||||
</MaterialButton>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<LegalInlineLinks />
|
||||
|
||||
</motion.form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
@use "sass:color";
|
||||
@use "../../css/colors" as *;
|
||||
@use "../../css/material" as *;
|
||||
|
||||
@keyframes rotateGradient {
|
||||
from {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.authContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
padding: 2rem;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
background: $color-dark-surface;
|
||||
|
||||
.gradientBackground {
|
||||
$size: 550px;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: $size;
|
||||
height: $size;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
rgba(147, 51, 234, 0.5) 0%,
|
||||
rgba(99, 102, 241, 0.6) 12.5%,
|
||||
rgba(59, 130, 246, 0.55) 25%,
|
||||
rgba(168, 85, 247, 0.5) 37.5%,
|
||||
rgba(217, 70, 239, 0.6) 50%,
|
||||
rgba(236, 72, 153, 0.55) 62.5%,
|
||||
rgba(192, 132, 252, 0.5) 75%,
|
||||
rgba(126, 34, 206, 0.6) 87.5%,
|
||||
rgba(147, 51, 234, 0.5) 100%
|
||||
);
|
||||
animation: rotateGradient 8s linear infinite;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.authCard {
|
||||
background: rgba($color-dark-surface-container, 0.7);
|
||||
backdrop-filter: blur(20px);
|
||||
color: $color-dark-on-surface;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba($color-dark-outline, 0.1);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba($color-dark-primary, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.formWrapper {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
&.relative {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.authHeader {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
padding-bottom: 8px;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
margin: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
|
||||
.material-symbols {
|
||||
color: $color-dark-primary;
|
||||
filter: drop-shadow(0 0 8px rgba($color-dark-primary, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.authBody {
|
||||
padding: 24px;
|
||||
padding-bottom: 20px;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.authButtons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.registerLink {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
font-size: 0.9rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
|
||||
a {
|
||||
color: $color-dark-primary;
|
||||
margin-inline-start: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AuthTextField Styles
|
||||
.authTextField {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.fieldContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba($color-dark-surface-variant, 0.3);
|
||||
border: 1px solid rgba($color-dark-outline, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 0 0 0 12px;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 44px;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba($color-dark-outline, 0.4);
|
||||
background: rgba($color-dark-surface-variant, 0.4);
|
||||
}
|
||||
|
||||
&.focused {
|
||||
border-color: $color-dark-primary;
|
||||
background: rgba($color-dark-surface-variant, 0.5);
|
||||
box-shadow:
|
||||
0 0 0 4px rgba($color-dark-primary, 0.1),
|
||||
0 4px 12px rgba($color-dark-primary, 0.2);
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: $color-dark-error;
|
||||
animation: shake 0.4s ease;
|
||||
|
||||
&.focused {
|
||||
box-shadow:
|
||||
0 0 0 4px rgba($color-dark-error, 0.1),
|
||||
0 4px 12px rgba($color-dark-error, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.noIcon {
|
||||
gap: 0;
|
||||
|
||||
.inputWrapper {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.hasToggle {
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldIcon {
|
||||
color: $color-dark-on-surface-variant;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
.fieldContainer.focused & {
|
||||
color: $color-dark-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: $color-dark-on-surface;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
padding: 12px 0 12px 0;
|
||||
line-height: 1.4;
|
||||
height: auto;
|
||||
min-height: 20px;
|
||||
|
||||
&::placeholder {
|
||||
color: $color-dark-on-surface-variant;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.togglePassword {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $color-dark-on-surface-variant;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba($color-dark-on-surface, 0.1);
|
||||
color: $color-dark-on-surface;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
margin-top: 4px;
|
||||
padding-left: 16px;
|
||||
font-size: 0.75rem;
|
||||
color: $color-dark-on-surface-variant;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// Alert Styles
|
||||
.alertContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
animation: slideInDown 0.3s ease;
|
||||
|
||||
&.alert-success {
|
||||
background: rgba($color-dark-primary-container, 0.3);
|
||||
color: $color-dark-on-primary-container;
|
||||
border: 1px solid rgba($color-dark-primary, 0.3);
|
||||
}
|
||||
|
||||
&.alert-danger {
|
||||
background: rgba($color-dark-error-container, 0.3);
|
||||
color: $color-dark-on-error-container;
|
||||
border: 1px solid rgba($color-dark-error, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||