3 Commits

50 changed files with 637 additions and 498 deletions
+141
View File
@@ -0,0 +1,141 @@
import js from "@eslint/js";
import typescript from "@typescript-eslint/eslint-plugin";
import typescriptParser from "@typescript-eslint/parser";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
js.configs.recommended,
{
files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true
}
}
},
plugins: {
"@typescript-eslint": typescript,
"react": react,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"jsx-a11y": jsxA11y
},
rules: {
// TypeScript rules
...typescript.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "off",
// React rules
...react.configs.recommended.rules,
"react/react-in-jsx-scope": "off", // Not needed with React 17+
"react/prop-types": "off", // Using TypeScript instead
"react/jsx-uses-react": "off", // Not needed with React 17+
"react/jsx-uses-vars": "error",
"react/jsx-no-undef": "error",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-pascal-case": "error",
"react/no-array-index-key": "off",
"react/no-danger": "off",
"react/no-deprecated": "error",
"react/no-direct-mutation-state": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "error",
"react/require-render-return": "error",
"react/self-closing-comp": "error",
"react/jsx-wrap-multilines": "error",
"react/jsx-closing-bracket-location": "off",
"react/jsx-closing-tag-location": "error",
"react/jsx-curly-spacing": ["error", "never"],
"react/jsx-equals-spacing": ["error", "never"],
"react/jsx-first-prop-new-line": ["off", "multiline-multiprop"],
"react/jsx-max-props-per-line": ["error", { maximum: 2, when: "multiline" }],
"react/jsx-no-bind": "off",
"react/jsx-no-literals": "off",
"react/jsx-sort-props": "off",
// React Hooks rules
...reactHooks.configs.recommended.rules,
// React Refresh rules
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true }
],
// Accessibility rules
...jsxA11y.configs.recommended.rules,
"jsx-a11y/alt-text": "off",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/img-redundant-alt": "warn",
"jsx-a11y/no-access-key": "error",
"jsx-a11y/role-has-required-aria-props": "error",
"jsx-a11y/role-supports-aria-props": "error",
"jsx-a11y/scope": "error",
"jsx-a11y/tabindex-no-positive": "error",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/anchor-is-valid": "off",
// General JavaScript/TypeScript rules
"no-console": "off",
"no-debugger": "error",
"no-unused-vars": "off", // Handled by TypeScript version
"prefer-const": "error",
"no-var": "error",
"no-undef": "off", // Handled by TypeScript version
"eqeqeq": ["error", "always"],
"curly": "off", // Changed from error to warn
"brace-style": ["off", "1tbs"],
"comma-dangle": "warn", // Changed from error to warn
"comma-spacing": ["error", { before: false, after: true }],
"comma-style": ["error", "last"],
"computed-property-spacing": ["error", "never"],
"func-call-spacing": ["off", "never"],
"key-spacing": ["error", { beforeColon: false, afterColon: true }],
"keyword-spacing": ["error", { before: true, after: true }],
"object-curly-spacing": ["error", "always"],
"semi-spacing": ["error", { before: false, after: true }],
"space-before-blocks": "error",
"space-before-function-paren": ["off", "never"],
"space-in-parens": ["error", "never"],
"space-infix-ops": "error",
"space-unary-ops": ["error", { words: true, nonwords: false }],
"quotes": "warn", // Changed from error to warn
"max-len": ["warn", { code: 150, ignoreUrls: true, ignoreStrings: true }],
"no-empty": "off"
},
settings: {
react: {
version: "detect"
}
}
},
{
ignores: [
"node_modules/**",
"dist/**",
"build/**",
"out/**",
"*.min.js",
"coverage/**",
".nyc_output/**",
"backend/**",
"deployment/**",
"web-calls/**"
]
}
];
+1 -1
View File
@@ -4,7 +4,7 @@ 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>}
{window.electronInterface.platform === "darwin" && <div className="macos-padding" />}
<div id="window-title">{PRODUCT_NAME}</div>
</div>
)
+5 -8
View File
@@ -17,7 +17,7 @@ export function getAuthHeaders(token: string | null, json: boolean = true): Head
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
@@ -35,15 +35,12 @@ async function fetchPublicKey(token: string): Promise<Uint8Array | null> {
}
async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise<void> {
const payload: UploadPublicKeyRequest = {
publicKey: b64(publicKey)
}
const headers = getAuthHeaders(token, true);
await fetch(`${API_BASE_URL}/crypto/public-key`, {
method: "POST",
headers,
body: JSON.stringify(payload)
headers: getAuthHeaders(token, true),
body: JSON.stringify({
publicKey: b64(publicKey)
} satisfies UploadPublicKeyRequest)
});
}
+14 -2
View File
@@ -46,7 +46,13 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe
return data.messages || [];
}
export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
export async function sendDMViaWebSocket(
recipientId: number,
recipientPublicKeyB64: string,
plaintext: string,
authToken: string,
replyToId?: number
): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
@@ -81,7 +87,13 @@ export async function sendDMViaWebSocket(recipientId: number, recipientPublicKey
});
}
export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
export async function sendDmWithFiles(
recipientId: number,
recipientPublicKeyB64: string,
plaintextJson: string,
files: File[],
token: string
): Promise<void> {
const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized");
+10 -10
View File
@@ -33,7 +33,7 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
return null;
} catch (error) {
console.error('Error loading profile:', error);
console.error("Error loading profile:", error);
return null;
}
}
@@ -44,10 +44,10 @@ export async function loadProfile(token: string): Promise<ProfileData | null> {
export async function uploadProfilePicture(token: string, file: Blob): Promise<UploadResponse | null> {
try {
const formData = new FormData();
formData.append('profile_picture', file, 'profile_picture.jpg');
formData.append("profile_picture", file, "profile_picture.jpg");
const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, {
method: 'POST',
method: "POST",
body: formData,
headers: getAuthHeaders(token, false)
});
@@ -57,7 +57,7 @@ export async function uploadProfilePicture(token: string, file: Blob): Promise<U
}
return null;
} catch (error) {
console.error('Upload error:', error);
console.error("Upload error:", error);
return null;
}
}
@@ -74,17 +74,17 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
};
const response = await fetch(`${API_BASE_URL}/user/profile`, {
method: 'PUT',
method: "PUT",
headers: {
...getAuthHeaders(token),
'Content-Type': 'application/json'
"Content-Type": "application/json"
},
body: JSON.stringify(backendData)
});
return response.ok;
} catch (error) {
console.error('Error updating profile:', error);
console.error("Error updating profile:", error);
return false;
}
}
@@ -95,14 +95,14 @@ export async function updateProfile(token: string, data: Partial<ProfileData>):
export async function updateBio(token: string, bio: string): Promise<boolean> {
try {
const response = await fetch(`${API_BASE_URL}/user/bio`, {
method: 'PUT',
method: "PUT",
headers: getAuthHeaders(token),
body: JSON.stringify({ bio })
});
return response.ok;
} catch (error) {
console.error('Error updating bio:', error);
console.error("Error updating bio:", error);
return false;
}
}
@@ -122,7 +122,7 @@ export async function fetchUserProfile(token: string, username: string): Promise
return null;
} catch (error) {
console.error('Error fetching user profile:', error);
console.error("Error fetching user profile:", error);
return null;
}
}
+6 -3
View File
@@ -12,7 +12,9 @@ export interface BaseDialogProps {
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
export function MaterialDialog(props: FullDialogProps) {
// eslint-disable-next-line react-hooks/refs
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
const { open, onOpenChange } = props;
useEffect(() => {
const dialog = dialogRef.current;
@@ -22,8 +24,8 @@ export function MaterialDialog(props: FullDialogProps) {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open") {
const isOpen = dialog.hasAttribute("open");
if (isOpen !== props.open) {
props.onOpenChange(isOpen);
if (isOpen !== open) {
onOpenChange(isOpen);
}
}
});
@@ -39,7 +41,8 @@ export function MaterialDialog(props: FullDialogProps) {
return () => {
observer.disconnect();
};
}, [dialogRef.current, props.open, props.onOpenChange]);
}, [open, onOpenChange, dialogRef]);
// eslint-disable-next-line react-hooks/refs
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
}
@@ -20,7 +20,7 @@ export function RichTextArea({
placeholder,
className,
rows = 1,
autoComplete = "off",
autoComplete = "off"
}: RichTextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -28,7 +28,7 @@ export function RichTextArea({
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
const raw = computedStyle[prop] as string | number | undefined;
if (raw == null) return 0;
if (raw === null) return 0;
const str = String(raw);
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
}
@@ -202,7 +202,7 @@ export function RichTextArea({
height: "auto",
minHeight: 0,
maxHeight: "none",
overflow: "hidden",
overflow: "hidden"
}}
rows={1}
/>
+3 -1
View File
@@ -3,7 +3,9 @@ import type { TextField } from "mdui/components/text-field";
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
return <mdui-text-field
return (
<mdui-text-field
autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
);
}
@@ -10,6 +10,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
useEffect(() => {
if (visible) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldRender(true);
setIsAnimating(true);
// Wait for content to render, then measure
@@ -33,6 +34,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
requestAnimationFrame(() => {
// Read layout to ensure the previous height assignment is flushed
if (containerRef.current) {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
containerRef.current.offsetHeight;
}
// Use a second frame to ensure the measured pixel height is applied before collapsing
@@ -50,7 +52,7 @@ export default function AnimatedHeight({ visible, duration = 0.25, onFinish, chi
}
}, duration * 1000);
}
}, [visible, shouldRender]);
}, [visible, shouldRender, duration, onFinish]);
return (visible || shouldRender || isAnimating) && (
<div
@@ -7,6 +7,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
useEffect(() => {
if (visible) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShouldRender(true);
setOpacity(0);
@@ -36,6 +37,7 @@ export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, chi
transition: `opacity ${duration}s ease`,
...props.style
}}
>{children}</div>
>{children}
</div>
);
}
+1
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
export interface BaseAnimatedPropertyProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
visible: any;
duration?: number;
onFinish?: () => void
+1 -1
View File
@@ -7,7 +7,7 @@
import "./electron.scss";
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface != undefined;
export const isElectron = import.meta.env.VITE_ELECTRON && window.electronInterface !== undefined;
if (isElectron) {
console.log("Running in Electron");
+2 -2
View File
@@ -1,4 +1,4 @@
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
import { useRef, useCallback, type RefCallback, type Ref } from "react";
// Определяем тип для ref, который может быть либо функцией, либо объектом
type PossibleRef<T> = Ref<T> | undefined;
@@ -14,7 +14,7 @@ export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallb
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
if (typeof ref === "function") {
// Если ref - это функция, вызываем её
ref(node);
} else {
@@ -1,7 +1,7 @@
import { API_BASE_URL } from "@/core/config";
import { isElectron } from "@/core/electron/electron";
import { websocket } from "@/core/websocket";
import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
import type { Message, NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types";
import serviceWorker from "./service-worker?worker&url";
export interface PushSubscriptionData {
@@ -18,7 +18,6 @@ export interface NotificationPayload {
icon?: string;
image?: string;
tag?: string;
data?: any;
}
// Global state
@@ -104,7 +103,7 @@ async function sendSubscriptionToServer(token: string): Promise<boolean> {
}
}
async function showMessageNotification(message: any): Promise<void> {
async function showMessageNotification(message: Message): Promise<void> {
try {
await showNotification({
title: `New message from ${message.username}`,
@@ -112,20 +111,14 @@ async function showMessageNotification(message: any): Promise<void> {
? message.content.substring(0, 100) + "..."
: message.content,
icon: message.profile_picture || "/logo.png",
tag: `message_${message.id}`,
data: {
type: "public_message",
message_id: message.id,
sender_id: message.user_id,
sender_username: message.username
}
tag: `message_${message.id}`
});
} catch (error) {
console.error("Failed to show message notification:", error);
}
}
async function handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void> {
async function handleWebSocketMessage(response: WebSocketMessage<object>): Promise<void> {
// Handle notifications for new messages
if (response.type === "newMessage" && response.data) {
const newResponse = response as NewMessageWebSocketMessage;
@@ -189,12 +182,7 @@ export async function subscribe(token: string): Promise<boolean> {
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
});
return await window.electronInterface.notifications.show(payload);
} catch (error) {
console.error("Failed to show Electron notification:", error);
return false;
@@ -244,14 +232,14 @@ export async function startElectronReceiver(): Promise<void> {
// Add our own message listener to the existing WebSocket
messageListener = (event: MessageEvent) => {
try {
const response: WebSocketMessage<any> = JSON.parse(event.data);
const response: WebSocketMessage<object> = JSON.parse(event.data);
handleWebSocketMessage(response);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
console.error("Failed to parse WebSocket message:", error);
}
};
websocket.addEventListener('message', messageListener);
websocket.addEventListener("message", messageListener);
}
export function stopElectronReceiver(): void {
@@ -263,7 +251,7 @@ export function stopElectronReceiver(): void {
// Remove our message listener
if (messageListener) {
websocket.removeEventListener('message', messageListener);
websocket.removeEventListener("message", messageListener);
messageListener = null;
}
}
@@ -8,7 +8,7 @@ interface NotificationPayload {
icon?: string;
image?: string;
tag?: string;
data?: any;
data?: object;
}
interface NotificationAction {
@@ -22,7 +22,7 @@ interface NotificationOptions {
badge: string;
image?: string;
tag: string;
data?: any;
data?: object;
actions: NotificationAction[];
requireInteraction: boolean;
silent: boolean;
+23 -15
View File
@@ -75,7 +75,7 @@ export interface Message {
runtimeData?: {
dmEnvelope?: DmEnvelope;
sendingState?: {
status: 'sending' | 'sent' | 'failed';
status: "sending" | "sent" | "failed";
tempId?: string; // Temporary ID for tracking until server confirms
retryData?: {
content: string;
@@ -239,7 +239,7 @@ export interface FetchDMResponse {
}
export interface DmEncryptedJSON {
type: "text",
type: "text";
data: {
content: string;
reply_to_id?: number;
@@ -310,13 +310,13 @@ export interface DMEditPayload {
// Requests
export interface DMEditRequest extends WebSocketMessage {
type: "dmEdit",
type: "dmEdit";
credentials: WebSocketCredentials;
data: DMEditPayload
}
export interface SendMessageRequest extends WebSocketMessage {
type: "sendMessage",
type: "sendMessage";
credentials: WebSocketCredentials;
data: {
content: string;
@@ -325,7 +325,7 @@ export interface SendMessageRequest extends WebSocketMessage {
}
export interface AddReactionRequest extends WebSocketMessage {
type: "addReaction",
type: "addReaction";
credentials: WebSocketCredentials;
data: {
message_id: number;
@@ -334,7 +334,7 @@ export interface AddReactionRequest extends WebSocketMessage {
}
export interface AddDmReactionRequest extends WebSocketMessage {
type: "addDmReaction",
type: "addDmReaction";
credentials: WebSocketCredentials;
data: {
dm_envelope_id: number;
@@ -344,41 +344,41 @@ export interface AddDmReactionRequest extends WebSocketMessage {
// Messages
export interface DMNewWebSocketMessage extends WebSocketMessage {
type: "dmNew",
type: "dmNew";
data: DmEnvelope
}
export interface DMEditedWebSocketMessage extends WebSocketMessage {
type: "dmEdited",
type: "dmEdited";
data: DMEditPayload
}
export interface DMDeletedWebSocketMessage extends WebSocketMessage {
type: "dmDeleted",
type: "dmDeleted";
data: {
id: number;
}
}
export interface MessageEditedWebSocketMessage extends WebSocketMessage {
type: "messageEdited",
type: "messageEdited";
data: Partial<Message> & { id: number }
}
export interface MessageDeletedWebSocketMessage extends WebSocketMessage {
type: "messageDeleted",
type: "messageDeleted";
data: {
message_id: number;
}
}
export interface NewMessageWebSocketMessage extends WebSocketMessage {
type: "newMessage",
type: "newMessage";
data: Message
}
export interface ReactionUpdateWebSocketMessage extends WebSocketMessage {
type: "reactionUpdate",
type: "reactionUpdate";
data: {
message_id: number;
emoji: string;
@@ -402,8 +402,16 @@ export interface DMReactionUpdateWebSocketMessage extends WebSocketMessage {
}
// Shared types
export type DMWebSocketMessage = DMNewWebSocketMessage | DMEditedWebSocketMessage | DMDeletedWebSocketMessage | DMReactionUpdateWebSocketMessage
export type ChatWebSocketMessage = MessageEditedWebSocketMessage | MessageDeletedWebSocketMessage | NewMessageWebSocketMessage | ReactionUpdateWebSocketMessage
export type DMWebSocketMessage =
DMNewWebSocketMessage |
DMEditedWebSocketMessage |
DMDeletedWebSocketMessage |
DMReactionUpdateWebSocketMessage;
export type ChatWebSocketMessage =
MessageEditedWebSocketMessage |
MessageDeletedWebSocketMessage |
NewMessageWebSocketMessage |
ReactionUpdateWebSocketMessage;
// -----------
// Encrypted message JSON (plaintext structure before encryption)
+7 -7
View File
@@ -33,17 +33,17 @@ 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;
let globalMessageHandler: ((response: WebSocketMessage<object>) => 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 {
export function setGlobalMessageHandler(handler: ((response: WebSocketMessage<object>) => void) | null): void {
globalMessageHandler = handler;
}
export function request<Request, Response = any>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
export function request<Request, Response = object>(payload: WebSocketMessage<Request>): Promise<WebSocketMessage<Response>> {
console.log("WebSocket request:", payload);
return new Promise((resolve, reject) => {
function requestInner() {
@@ -58,7 +58,7 @@ export function request<Request, Response = any>(payload: WebSocketMessage<Reque
setTimeout(() => reject("Request timed out"), 10000);
}
if (websocket.readyState == 0) {
if (websocket.readyState === 0) {
websocket.addEventListener("open", requestInner);
setTimeout(() => reject("Request timed out"), 10000);
} else {
@@ -79,10 +79,10 @@ async function onError() {
await delay(3000);
websocket = create();
let listener: () => void | null;
let listener: (() => void) | null = null;
listener = () => {
console.log("WebSocket successfully reconnected!");
websocket.removeEventListener("open", listener);
websocket.removeEventListener("open", listener!);
}
websocket.addEventListener("open", listener);
@@ -95,7 +95,7 @@ async function onError() {
websocket.addEventListener("message", (e) => {
try {
const response: WebSocketMessage<any> = JSON.parse(e.data);
const response: WebSocketMessage<object> = JSON.parse(e.data);
// Route message to global handler if set
if (globalMessageHandler) {
+4 -4
View File
@@ -5,14 +5,14 @@
* @version 1.0.0
*/
import './css/style.scss';
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';
import { createRoot } from "react-dom/client";
import App from "./App";
import { StrictMode } from "react";
createRoot(document.getElementById("root")!).render(
<StrictMode>
+2 -2
View File
@@ -24,8 +24,8 @@ export interface AuthHeaderProps {
}
export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) {
const iconType = typeof icon == "string" ? "filled" : icon.type;
const iconName = typeof icon == "string" ? icon : icon.name;
const iconType = typeof icon === "string" ? "filled" : icon.type;
const iconName = typeof icon === "string" ? icon : icon.name;
return (
<div className="auth-header">
+10 -8
View File
@@ -19,15 +19,17 @@ export default function LoginPage() {
const setUser = useAppState(state => state.setUser);
const navigate = useNavigate();
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
if (navigateDownloadApp) return navigateDownloadApp;
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
updateAlerts((alerts) => {
alerts.push({ type: type, message: message });
});
}
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
return (
<AuthContainer>
<AuthHeader icon="login" title="Добро пожаловать!" subtitle="Войдите в свой аккаунт" />
@@ -50,12 +52,12 @@ export default function LoginPage() {
const request: LoginRequest = {
username: username,
password: password
}
};
const response = await fetch(`${API_BASE_URL}/login`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
@@ -100,7 +102,7 @@ export default function LoginPage() {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Неверное имя пользователя или пароль");
}
} catch (error) {
} catch {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
+12 -10
View File
@@ -17,16 +17,18 @@ export default function RegisterPage() {
const setUser = useAppState(state => state.setUser);
const navigate = useNavigate();
const { navigate: navigateDownloadApp } = useDownloadAppScreen();
if (navigateDownloadApp) return navigateDownloadApp;
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => { alerts.push({type: type, message: message}) });
}
const usernameElement = useRef<TextField>(null);
const passwordElement = useRef<TextField>(null);
const confirmPasswordElement = useRef<TextField>(null);
if (navigateDownloadApp) return navigateDownloadApp;
function showAlert(type: AlertType, message: string) {
updateAlerts((alerts) => {
alerts.push({ type: type, message: message });
});
}
return (
<AuthContainer>
<AuthHeader icon="person_add" title="Регистрация" subtitle="Создайте новый аккаунт" />
@@ -68,9 +70,9 @@ export default function RegisterPage() {
}
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json"
},
body: JSON.stringify(request)
});
@@ -83,7 +85,7 @@ export default function RegisterPage() {
// Setup keys with the token we just received
try {
await ensureKeysOnLogin(password, data.token);
} catch (e) {
} catch {
console.error("Key setup failed:", e);
}
@@ -92,7 +94,7 @@ export default function RegisterPage() {
const data: ErrorResponse = await response.json();
showAlert("danger", data.message || "Ошибка при регистрации");
}
} catch (error) {
} catch {
showAlert("danger", "Ошибка соединения с сервером");
}
}}>
+2 -2
View File
@@ -101,7 +101,7 @@ export function useDM() {
} finally {
setIsLoadingUsers(false);
}
}, [user.authToken, isLoadingUsers]);
}, [user.authToken, isLoadingUsers, loadUserLastMessage, setDmUsers]);
// Reset users loaded flag when user changes
useEffect(() => {
@@ -263,7 +263,7 @@ export function useDM() {
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [chat.activeDm, user.currentUser, addMessage]);
}, [chat.activeDm, user.currentUser, addMessage, user.authToken]);
// Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => {
+10 -10
View File
@@ -20,8 +20,8 @@ export default function useProfile() {
setProfileData(data);
}
} catch (error) {
console.error('Error loading profile:', error);
showError('Ошибка при загрузке профиля');
console.error("Error loading profile:", error);
showError("Ошибка при загрузке профиля");
} finally {
setIsLoading(false);
}
@@ -37,15 +37,15 @@ export default function useProfile() {
if (success) {
// Reload profile data to get updated information
await loadProfileData();
showSuccess('Профиль обновлен!');
showSuccess("Профиль обновлен!");
return true;
} else {
showError('Ошибка при обновлении профиля');
showError("Ошибка при обновлении профиля");
return false;
}
} catch (error) {
console.error('Error updating profile:', error);
showError('Ошибка при обновлении профиля');
console.error("Error updating profile:", error);
showError("Ошибка при обновлении профиля");
return false;
} finally {
setIsUpdating(false);
@@ -65,15 +65,15 @@ export default function useProfile() {
...prev,
profile_picture: result.profile_picture_url
} : null);
showSuccess('Фото профиля обновлено!');
showSuccess("Фото профиля обновлено!");
return true;
} else {
showError('Ошибка при загрузке фото');
showError("Ошибка при загрузке фото");
return false;
}
} catch (error) {
console.error('Error uploading profile picture:', error);
showError('Ошибка при загрузке фото');
console.error("Error uploading profile picture:", error);
showError("Ошибка при загрузке фото");
return false;
} finally {
setIsUpdating(false);
+10 -10
View File
@@ -155,10 +155,10 @@ export const useAppState = create<AppState>((set, get) => ({
// Store credentials in localStorage
try {
localStorage.setItem('authToken', token);
localStorage.setItem('currentUser', JSON.stringify(user));
localStorage.setItem("authToken", token);
localStorage.setItem("currentUser", JSON.stringify(user));
} catch (error) {
console.error('Failed to store credentials in localStorage:', error);
console.error("Failed to store credentials in localStorage:", error);
}
try {
@@ -177,10 +177,10 @@ export const useAppState = create<AppState>((set, get) => ({
logout: () => {
// Clear localStorage
try {
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
localStorage.removeItem("authToken");
localStorage.removeItem("currentUser");
} catch (error) {
console.error('Failed to clear localStorage:', error);
console.error("Failed to clear localStorage:", error);
}
set(() => ({
@@ -192,7 +192,7 @@ export const useAppState = create<AppState>((set, get) => ({
},
restoreUserFromStorage: async () => {
try {
const token = localStorage.getItem('authToken');
const token = localStorage.getItem("authToken");
if (token) {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
@@ -244,10 +244,10 @@ export const useAppState = create<AppState>((set, get) => ({
}
}
} catch (error) {
console.error('Failed to restore user from localStorage:', error);
console.error("Failed to restore user from localStorage:", error);
// Clear invalid data
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
localStorage.removeItem("authToken");
localStorage.removeItem("currentUser");
}
},
@@ -1,52 +0,0 @@
import { useAppState } from "@/pages/chat/state";
export function ChatTabs() {
const { chat, setActiveTab, switchToPublicChat } = useAppState();
return (
<div className="chat-tabs">
<mdui-tabs value={chat.activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
<mdui-tab value="chats">
Чаты
</mdui-tab>
<mdui-tab value="channels">
Каналы
</mdui-tab>
<mdui-tab value="contacts">
Контакты
</mdui-tab>
<mdui-tab value="dms">
ЛС
</mdui-tab>
<mdui-tab-panel slot="panel" value="chats">
<mdui-list>
<mdui-list-item
headline="Общий чат"
description="Вы: Последнее сообщение"
id="chat-list-chat-1"
onClick={async () => await switchToPublicChat("Общий чат")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
<mdui-list-item
headline="Общий чат 2"
description="Вы: Последнее сообщение"
id="chat-list-chat-2"
onClick={async () => await switchToPublicChat("Общий чат 2")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
</mdui-list-item>
</mdui-list>
</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="channels">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="contacts">Скоро будет...</mdui-tab-panel>
<mdui-tab-panel slot="panel" value="dms">
<mdui-list id="dm-users"></mdui-list>
</mdui-tab-panel>
</mdui-tabs>
</div>
);
}
@@ -6,7 +6,7 @@ import { ProfileDialog } from "./profile/ProfileDialog";
import { SettingsDialog } from "./settings/SettingsDialog";
import { DMUsersList } from "./DMUsersList";
import type { Tabs } from "mdui";
import type { ChatTabs } from "@/pages/chat/state";
import type { ChatTabs as ChatTabsType } from "@/pages/chat/state";
function BottomAppBar() {
const [settingsOpen, onSettingsOpenChange] = useState(false);
@@ -19,16 +19,15 @@ function BottomAppBar() {
return (
<>
<mdui-bottom-app-bar>
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)}></mdui-button-icon>
<mdui-button-icon icon="group_add--filled"></mdui-button-icon>
<div style={{ flexGrow: 1 }}></div>
<mdui-button-icon icon="settings--filled" id="settings-open" onClick={() => onSettingsOpenChange(true)} />
<mdui-button-icon icon="group_add--filled" />
<div style={{ flexGrow: 1 }} />
<mdui-button-icon
icon="logout--filled"
id="logout-btn"
onClick={handleLogout}
title="Выйти"
></mdui-button-icon>
<mdui-fab icon="edit--filled"></mdui-fab>
title="Выйти" />
<mdui-fab icon="edit--filled" />
</mdui-bottom-app-bar>
<SettingsDialog isOpen={settingsOpen} onOpenChange={onSettingsOpenChange} />
</>
@@ -45,7 +44,7 @@ function ChatTabs() {
}
function handleTabChange(e: FormEvent<Tabs>) {
setActiveTab((e.target as Tabs).value as ChatTabs);
setActiveTab((e.target as Tabs).value as ChatTabsType);
}
return (
@@ -4,10 +4,10 @@ export function CropperDialog() {
<div className="cropper-dialog-content">
<div className="cropper-header">
<h3>Обрезать фото профиля</h3>
<mdui-button-icon icon="close" id="cropper-close"></mdui-button-icon>
<mdui-button-icon icon="close" id="cropper-close" />
</div>
<div className="cropper-container">
<div id="cropper-area"></div>
<div id="cropper-area" />
</div>
<div className="cropper-actions">
<mdui-button id="crop-cancel" variant="outlined">Отмена</mdui-button>
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Size2D, Rect } from "@/core/types";
interface ImageCropperProps {
@@ -17,13 +17,13 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
const [dragStart, setDragStart] = useState<Size2D>({ x: 0, y: 0 });
useEffect(() => {
const img = imageRef.current;
if (imageFile) {
const reader = new FileReader();
function handleImageLoad() {
setIsLoaded(true);
// Initialize crop area to center of image
const img = imageRef.current;
if (img) {
const size = Math.min(img.naturalWidth, img.naturalHeight) * 0.8;
setCropArea({
@@ -36,9 +36,9 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
}
function handleReaderLoad() {
if (imageRef.current) {
if (img) {
setSrc(reader.result as string);
imageRef.current.addEventListener("load", handleImageLoad);
img.addEventListener("load", handleImageLoad);
}
}
@@ -48,10 +48,10 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
return () => {
reader.abort();
reader.removeEventListener("load", handleReaderLoad);
imageRef.current?.removeEventListener("load", handleImageLoad);
img?.removeEventListener("load", handleImageLoad);
}
}
}, [imageFile]);
}, [imageFile, imageRef]);
function handleMouseDown(e: React.MouseEvent) {
if (!isLoaded) return;
@@ -100,10 +100,11 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
};
function handleCrop() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const img = imageRef.current;
if (!canvasRef.current || !img || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Set canvas size to crop area
@@ -112,47 +113,48 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
// Draw cropped portion
ctx.drawImage(
imageRef.current,
img,
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
0, 0, cropArea.width, cropArea.height
);
// Convert to data URL
const croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
const croppedImageData = canvas.toDataURL("image/jpeg", 0.9);
onCrop(croppedImageData);
};
function drawCropArea() {
if (!canvasRef.current || !imageRef.current || !isLoaded) return;
const drawCropArea = useCallback(() => {
const img = imageRef.current;
if (!canvasRef.current || !img || !isLoaded) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw image
ctx.drawImage(imageRef.current, 0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Draw crop overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Clear crop area
ctx.globalCompositeOperation = 'destination-out';
ctx.globalCompositeOperation = "destination-out";
ctx.fillRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
// Draw crop border
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = '#fff';
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#fff";
ctx.lineWidth = 2;
ctx.strokeRect(cropArea.x, cropArea.y, cropArea.width, cropArea.height);
};
}, [cropArea, isLoaded, imageRef]);
useEffect(() => {
drawCropArea();
}, [cropArea, isLoaded]);
}, [cropArea, isLoaded, drawCropArea]);
if (!imageFile) return null;
@@ -163,10 +165,10 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
width={400}
height={400}
style={{
cursor: isDragging ? 'grabbing' : 'grab',
border: '1px solid #ccc',
maxWidth: '100%',
height: 'auto'
cursor: isDragging ? "grabbing" : "grab",
border: "1px solid #ccc",
maxWidth: "100%",
height: "auto"
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -176,7 +178,7 @@ export function ImageCropper({ onCrop, onCancel, imageFile }: ImageCropperProps)
<img
ref={imageRef}
src={src}
style={{ display: 'none' }}
style={{ display: "none" }}
alt="Crop source"
/>
<div className="cropper-actions">
@@ -20,6 +20,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
// Update form fields when profile data changes
useEffect(() => {
if (profileData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setUsername(profileData.nickname || "");
setDescription(profileData.description || "");
}
@@ -40,7 +41,7 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
if (file && file.type.startsWith("image/")) {
setSelectedImage(file);
setShowCropper(true);
}
@@ -57,25 +58,25 @@ export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.value = "";
}
}
} catch (error) {
console.error('Error processing cropped image:', error);
console.error("Error processing cropped image:", error);
}
};
const handleCropCancel = () => {
function handleCropCancel() {
setShowCropper(false);
setSelectedImage(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.value = "";
}
}
};
const handleUploadClick = () => {
function handleUploadClick() {
fileInputRef.current?.click();
};
}
const profilePictureUrl = profileData?.profile_picture || defaultAvatar;
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useMemo } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "@/core/config";
import type { DialogProps } from "@/core/types";
import { MaterialDialog } from "@/core/components/Dialog";
@@ -10,22 +10,15 @@ import { getAuthHeaders } from "@/core/api/authApi";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings");
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false);
const pushSupported = useMemo(() => isSupported(), []);
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(pushSupported);
const user = useAppState(state => state.user);
useEffect(() => {
setPushSupported(isSupported());
// For Electron, we assume notifications are enabled if supported
// For web browsers, we check if there's a subscription
setPushNotificationsEnabled(isSupported());
}, []);
const handlePanelChange = (panelId: string) => {
function handlePanelChange(panelId: string) {
setActivePanel(panelId);
};
}
const handlePushNotificationToggle = async (enabled: boolean) => {
async function handlePushNotificationToggle(enabled: boolean) {
if (!user.authToken) return;
try {
@@ -66,7 +59,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div className="fullscreen-wrapper">
<div id="settings-dialog-inner">
<div className="header">
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)}></mdui-button-icon>
<mdui-button-icon icon="close" id="settings-close" onClick={() => onOpenChange(false)} />
<mdui-top-app-bar-title>Настройки</mdui-top-app-bar-title>
</div>
<div id="settings-menu">
@@ -185,7 +178,7 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div id="storage-settings" className={`settings-panel ${activePanel === "storage-settings" ? "active" : ""}`}>
<h3>Хранилище</h3>
<p>Использовано: 2.5 ГБ из 10 ГБ</p>
<mdui-linear-progress value={25}></mdui-linear-progress>
<mdui-linear-progress value={25} />
<mdui-button variant="outlined">Очистить кэш</mdui-button>
</div>
@@ -51,11 +51,13 @@ export function ChatInputWrapper(
if (onProvideFileAdder) {
const addFiles = (files: File[]) => {
if (!files || files.length === 0) return;
setSelectedFiles(draft => { draft.push(...files) });
setSelectedFiles(draft => {
draft.push(...files)
});
};
onProvideFileAdder(addFiles);
}
}, [onProvideFileAdder]);
}, [onProvideFileAdder, setSelectedFiles]);
// When entering edit mode, preload the message content
useEffect(() => {
@@ -85,11 +87,11 @@ export function ChatInputWrapper(
} else {
setEmojiMenuOpen(false);
}
};
}
function handleEmojiSelect(emoji: string) {
setMessage(prev => prev + emoji);
};
}
async function handleSubmit(e: React.FormEvent | Event) {
e.preventDefault();
@@ -113,14 +115,16 @@ export function ChatInputWrapper(
if (onClearReply) onClearReply();
}
}
};
}
function handleAttachClick() {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.addEventListener("change", () => {
setSelectedFiles(draft => { draft.push(...Array.from(input.files || [])) });
setSelectedFiles(draft => {
draft.push(...Array.from(input.files || []))
});
});
input.click();
}
@@ -136,7 +140,7 @@ export function ChatInputWrapper(
<span className="reply-username">{editingMessage!.username}</span>
<span className="reply-text">{editingMessage!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit}></mdui-button-icon>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearEdit} />
</div>
)}
</AnimatedHeight>
@@ -148,7 +152,7 @@ export function ChatInputWrapper(
<span className="reply-username">{replyTo!.username}</span>
<span className="reply-text">{replyTo!.content}</span>
</Quote>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply}></mdui-button-icon>
<mdui-button-icon icon="close" className="reply-cancel" onClick={onClearReply} />
</div>
)}
</AnimatedHeight>
@@ -164,19 +168,21 @@ export function ChatInputWrapper(
end-icon="close"
title={`${file.name} (${Math.round(file.size / 1024 / 1024)} MB)`}
onClick={() => {
if (selectedFiles.length == 1) {
if (selectedFiles.length === 1) {
setAttachmentsVisible(false);
} else {
setSelectedFiles(draft => { draft.splice(i) })
setSelectedFiles(draft => {
draft.splice(i);
});
}
}}
>
<mdui-icon slot="icon" name="attach_file"></mdui-icon>
<mdui-icon slot="icon" name="attach_file" />
<span className="name">{file.name}</span>
</mdui-chip>
))}
</div>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)}></mdui-button-icon>
<mdui-button-icon icon="close" className="reply-cancel" onClick={() => setAttachmentsVisible(false)} />
</div>
)}
</AnimatedHeight>
@@ -199,7 +205,7 @@ export function ChatInputWrapper(
onTextChange={(value) => setMessage(value)}
onEnter={handleSubmit} />
<div className="buttons">
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn"></mdui-button-icon>
<mdui-button-icon icon="attach_file" onClick={handleAttachClick} className="attach-btn" />
<button type="submit" className="send-btn">
<span className="material-symbols filled">{editingMessage ? "check" : "send"}</span>
</button>
@@ -1,21 +0,0 @@
import { useAppState } from "@/pages/chat/state";
import defaultAvatar from "@/images/default-avatar.png";
export function ChatMainHeader() {
const { currentChat } = useAppState().chat;
return (
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{currentChat}</h4>
<p>
<span className="online-status"></span>
Онлайн
</p>
</div>
</div>
</div>
);
}
@@ -22,7 +22,16 @@ interface ChatMessagesProps {
dmRecipientPublicKey?: string;
}
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
export function ChatMessages({
messages = [],
children,
isDm = false,
onReplySelect,
onEditSelect,
onDelete,
onRetryMessage,
dmRecipientPublicKey }: ChatMessagesProps
) {
const { user } = useAppState();
// Use prop messages (panels provide their own messages)
+31 -30
View File
@@ -30,11 +30,32 @@ export function EmojiMenu(props: EmojiMenuProps) {
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
useEffect(() => {
if (isOpen) {
setRecentEmojis(getRecentEmojis());
const scrollToCategory = useCallback((categoryName: string) => {
const element = categoryRefs.current.get(categoryName);
if (element && scrollRef.current) {
element.scrollIntoView({
behavior: "smooth",
block: "start"
});
}
}, [isOpen]);
}, [categoryRefs, scrollRef]);
const scrollTabIntoView = useCallback((categoryName: string) => {
const tabElement = tabRefs.current.get(categoryName);
if (tabElement && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is outside the visible area
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
tabElement.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center"
});
}
}
}, [tabRefs, tabsRef]);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
@@ -55,34 +76,14 @@ export function EmojiMenu(props: EmojiMenuProps) {
}
}
}
}, [activeCategory]);
}, [activeCategory, scrollTabIntoView]);
function scrollToCategory(categoryName: string) {
const element = categoryRefs.current.get(categoryName);
if (element && scrollRef.current) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
function scrollTabIntoView(categoryName: string) {
const tabElement = tabRefs.current.get(categoryName);
if (tabElement && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is outside the visible area
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
tabElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
}
useEffect(() => {
if (isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setRecentEmojis(getRecentEmojis());
}
}, [isOpen]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
+63 -46
View File
@@ -4,7 +4,7 @@ import defaultAvatar from "@/images/default-avatar.png";
import Quote from "@/core/components/Quote";
import { parse } from "marked";
import DOMPurify from "dompurify";
import { useEffect, useState, useRef } from "react";
import { useEffect, useState, useRef, useCallback } from "react";
import { getCurrentKeys } from "@/core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
@@ -42,6 +42,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
}, 200);
} else {
// No visible reactions, hide immediately
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsVisible(false);
}
return;
@@ -99,7 +100,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
return updated;
});
}, [reactions]);
}, [reactions, visibleReactions]);
// Don't render if not visible
if (!isVisible) {
@@ -114,7 +115,7 @@ function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsPr
return (
<button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
key={`${messageId || "unknown"}-${reaction.emoji}-${reaction.count}-${index}`}
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
@@ -147,7 +148,16 @@ interface Rect {
height: number
}
export function Message({ message, isAuthor, onProfileClick, onContextMenu, onReactionClick, isLoadingProfile = false, isDm = false, dmRecipientPublicKey }: MessageProps) {
export function Message({
message,
isAuthor,
onProfileClick,
onContextMenu,
onReactionClick,
isLoadingProfile = false,
isDm = false,
dmRecipientPublicKey
}: MessageProps) {
const [formattedMessage, setFormattedMessage] = useState({ __html: "" });
const [decryptedFiles, updateDecryptedFiles] = useImmer<Map<string, string>>(new Map());
const [loadedImages, updateLoadedImages] = useImmer<Set<string>>(new Set());
@@ -165,39 +175,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
const imageRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dmEnvelope = message.runtimeData?.dmEnvelope;
useEffect(() => {
(async () => {
setFormattedMessage({
__html: DOMPurify.sanitize(
await parse(message.content)
).trim()
});
})();
}, [message]);
// Auto-decrypt images in DMs
useEffect(() => {
if (isDm && message.files) {
message.files.forEach(async (file) => {
console.log(file);
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
console.log("Decrypting...");
const decryptedUrl = await decryptFile(file);
console.log(decryptedUrl);
if (decryptedUrl) {
updateDecryptedFiles(draft => {
draft.set(file.path, decryptedUrl);
});
}
}
});
}
}, [message.files, isDm, decryptedFiles]);
async function decryptFile(file: Attachment): Promise<string | null> {
const decryptFile = useCallback(async (file: Attachment): Promise<string | null> => {
if (!file.encrypted || !isDm || !user.authToken || !dmRecipientPublicKey || !dmEnvelope) {
debugger;
console.warn("Conditions not met")
return null;
}
@@ -250,7 +229,37 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
} finally {
// no-op decrypt indicator removed from UI
}
};
}, [decryptedFiles, isDm, user.authToken, dmRecipientPublicKey, dmEnvelope, updateDecryptedFiles]);
useEffect(() => {
(async () => {
setFormattedMessage({
__html: DOMPurify.sanitize(
await parse(message.content)
).trim()
});
})();
}, [message]);
// Auto-decrypt images in DMs
useEffect(() => {
if (isDm && message.files) {
message.files.forEach(async (file) => {
console.log(file);
const isImage = /\.(png|jpg|jpeg|gif|webp)$/i.test(file.name || "");
if (isImage && file.encrypted && !decryptedFiles.has(file.path)) {
console.log("Decrypting...");
const decryptedUrl = await decryptFile(file);
console.log(decryptedUrl);
if (decryptedUrl) {
updateDecryptedFiles(draft => {
draft.set(file.path, decryptedUrl);
});
}
}
});
}
}, [message.files, isDm, decryptedFiles, decryptFile, updateDecryptedFiles]);
async function handleImageClick(file: Attachment, imageElement: HTMLImageElement) {
// Use decrypted URL if available, otherwise decrypt first
@@ -423,6 +432,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
<div
className={`message-username ${isLoadingProfile ? "loading" : ""}`}
onClick={() => !isLoadingProfile && onProfileClick(message.username)}
role="link"
tabIndex={0}
style={{ cursor: isLoadingProfile ? "default" : "pointer" }}>
{message.username}
</div>
@@ -445,7 +456,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
const decryptedUrl = decryptedFiles.get(file.path);
const imageSrc = isImage ? (isEncryptedDm ? decryptedUrl : file.path) : undefined;
const isDownloading = downloadingPaths.has(file.path);
const isSending = message.runtimeData?.sendingState?.status === 'sending';
const isSending = message.runtimeData?.sendingState?.status === "sending";
return (
<div className="attachment" key={idx}>
@@ -458,7 +469,9 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
src={imageSrc}
alt={file.name || "image"}
onClick={(e) => handleImageClick(file, e.currentTarget)}
onLoad={() => updateLoadedImages(draft => { draft.add(file.path); })}
onLoad={() => updateLoadedImages(draft => {
draft.add(file.path);
})}
className={`attachement-image ${loadedImages.has(file.path) ? "" : "loading"}`}
/>
{(!loadedImages.has(file.path) || isSending) && (
@@ -500,18 +513,18 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
{message.is_edited ? " (edited)" : undefined}
{isAuthor && message.is_read && (
<span className="material-symbols outlined"></span>
<span className="material-symbols outlined" />
)}
{isAuthor && message.runtimeData?.sendingState && (
<span className="message-status-indicator">
{message.runtimeData.sendingState.status === 'sending' && (
<mdui-circular-progress style={{ width: '16px', height: '16px' }} />
{message.runtimeData.sendingState.status === "sending" && (
<mdui-circular-progress style={{ width: "16px", height: "16px" }} />
)}
{message.runtimeData.sendingState.status === 'failed' && (
{message.runtimeData.sendingState.status === "failed" && (
<span className="material-symbols error-icon">error</span>
)}
{message.runtimeData.sendingState.status === 'sent' && (
{message.runtimeData.sendingState.status === "sent" && (
<span className="material-symbols success-icon">check</span>
)}
</span>
@@ -524,7 +537,8 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
{fullscreenImage && createPortal(
<div
className={`fullscreen-image-overlay ${isAnimatingOpen ? "open" : "closing"}`}
onClick={closeFullscreen}>
onClick={closeFullscreen}
role="dialog">
<img
src={fullscreenImage.src}
alt={fullscreenImage.name}
@@ -537,7 +551,10 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
}}
onClick={e => e.stopPropagation()}
/>
<div className="fullscreen-controls top-right" onClick={e => e.stopPropagation()}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="fullscreen-controls top-right"
onClick={e => e.stopPropagation()}>
<mdui-button-icon icon="close" onClick={closeFullscreen} />
{isDownloadingFullscreen ? (
<div className="progress-wrapper">
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import type { Message, Size2D } from "@/core/types";
import { EmojiMenu } from "./EmojiMenu";
@@ -36,8 +36,8 @@ export function MessageContextMenu({
// Internal state for closing animation
const [isClosing, setIsClosing] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering');
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left');
const [animationClass, setAnimationClass] = useState("entering");
const [reactionBarPosition, setReactionBarPosition] = useState<"left" | "right">("left");
const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false);
const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null);
const [expandUpward, setExpandUpward] = useState(false);
@@ -49,6 +49,25 @@ export function MessageContextMenu({
const contextMenuRef = useRef<HTMLDivElement>(null);
const emojiMenuRef = useRef<HTMLDivElement>(null);
const handleClose = useCallback(() => {
setIsClosing(true);
// Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace("entering", "closing");
setAnimationClass(closingAnimation);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
setIsClosing(false);
setAnimationClass("entering"); // Reset for next opening
// Reset emoji menu state after context menu animation completes
setIsEmojiMenuExpanded(false);
setInitialDimensions(null);
setExpandUpward(false);
setContextMenuHeight(null);
}, 200); // Match the animation duration from _animations.scss
}, [animationClass, onOpenChange]);
// Calculate smart positioning when component opens
useEffect(() => {
if (isOpen) {
@@ -70,16 +89,16 @@ export function MessageContextMenu({
let x = position.x;
let y = position.y;
let animation = 'entering';
let reactionPosition: 'left' | 'right' = 'left';
let animation = "entering";
let reactionPosition: "left" | "right" = "left";
// Check if shared rect would overflow and adjust position
if (x + sharedRect.width > viewportWidth) {
x = position.x - contextMenuRect.width - 25;
animation = 'entering-left';
reactionPosition = 'right';
animation = "entering-left";
reactionPosition = "right";
} else {
reactionPosition = 'left';
reactionPosition = "left";
}
// Ensure menu doesn't go off the left edge
@@ -90,7 +109,7 @@ export function MessageContextMenu({
// Check if shared rect would overflow bottom edge
if (y + sharedRect.height > viewportHeight) {
y = viewportHeight - sharedRect.height;
animation = 'entering-up';
animation = "entering-up";
}
setCalculatedPosition({ x, y });
@@ -109,14 +128,14 @@ export function MessageContextMenu({
if (isOpen && !isClosing) {
// Check if the click is on a context menu element or reaction bar
const target = event.target as Element;
if (!target.closest('.context-menu') && !target.closest('.context-menu-reaction-bar')) {
if (!target.closest(".context-menu") && !target.closest(".context-menu-reaction-bar")) {
handleClose();
}
}
};
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen && !isClosing) {
if (event.key === "Escape" && isOpen && !isClosing) {
handleClose();
}
};
@@ -129,36 +148,17 @@ export function MessageContextMenu({
};
// Add event listeners
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('blur', handleWindowBlur);
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleWindowBlur);
// Cleanup
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleWindowBlur);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleWindowBlur);
};
}, [isOpen, isClosing]);
function handleClose() {
setIsClosing(true);
// Set appropriate closing animation based on opening animation
const closingAnimation = animationClass.replace('entering', 'closing');
setAnimationClass(closingAnimation);
// Wait for animation to complete before calling onOpenChange
setTimeout(() => {
onOpenChange(false);
setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening
// Reset emoji menu state after context menu animation completes
setIsEmojiMenuExpanded(false);
setInitialDimensions(null);
setExpandUpward(false);
setContextMenuHeight(null);
}, 200); // Match the animation duration from _animations.scss
}
}, [isOpen, isClosing, handleClose]);
interface Action {
label: string;
@@ -168,8 +168,8 @@ export function MessageContextMenu({
}
// Check if message is sending or failed
const isSending = message.runtimeData?.sendingState?.status === 'sending';
const isFailed = message.runtimeData?.sendingState?.status === 'failed';
const isSending = message.runtimeData?.sendingState?.status === "sending";
const isFailed = message.runtimeData?.sendingState?.status === "failed";
const isSendingOrFailed = isSending || isFailed;
const actions: Action[] = [
@@ -210,7 +210,7 @@ export function MessageContextMenu({
handleClose();
},
show: isAuthor
},
}
];
// Quick reactions for the reaction bar
@@ -266,17 +266,19 @@ export function MessageContextMenu({
left: calculatedPosition.x,
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}>
onClick={(e) => e.stopPropagation()}
role="menu"
tabIndex={0}>
{/* Reaction Bar */}
<div
ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
style={isEmojiMenuExpanded && !expandUpward ? {
position: 'fixed',
position: "fixed",
top: `${(-(contextMenuHeight || 0) + 95)}px`,
width: '320px',
height: '400px',
width: "320px",
height: "400px",
zIndex: 1001
} : initialDimensions && !isEmojiMenuExpanded ? {
width: `${initialDimensions.width}px`,
@@ -326,6 +328,8 @@ export function MessageContextMenu({
className="context-menu-item"
onClick={action.onClick}
key={i}
role="menuitem"
tabIndex={0}
>
<span className="material-symbols">{action.icon}</span>
{action.label}
@@ -66,7 +66,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler((message: WebSocketMessage<any>) => panel.handleWebSocketMessage(message));
setGlobalMessageHandler((message: WebSocketMessage<object>) => panel.handleWebSocketMessage(message));
}
} else {
setPanelState(null);
@@ -79,7 +79,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
panel.onStateChange = null;
}
if (typeof panel.destroy === 'function') {
if (typeof panel.destroy === "function") {
panel.destroy();
}
}
@@ -95,27 +95,28 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
function handleAnimationEnd(event: Event) {
const animationEvent = event as AnimationEvent;
if (animationEvent.animationName === 'fadeOutUp') {
if (animationEvent.animationName === "fadeOutUp") {
// Apply pending panel exactly at the boundary between animations
applyPendingPanel();
setSwitchOut(false);
setSwitchIn(true);
} else if (animationEvent.animationName === 'fadeInDown') {
} else if (animationEvent.animationName === "fadeInDown") {
setSwitchIn(false);
// End the chat switching state
chat.setIsSwitching(false);
}
};
}
// Add event listener to document to catch all animation events
document.addEventListener('animationend', handleAnimationEnd);
document.addEventListener("animationend", handleAnimationEnd);
// Cleanup function
return () => {
document.removeEventListener('animationend', handleAnimationEnd);
document.removeEventListener("animationend", handleAnimationEnd);
};
}
}, [chat.isSwitching]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat.isSwitching, chat.setIsSwitching, applyPendingPanel]);
// Load messages when panel changes and animation is not running
useEffect(() => {
@@ -138,13 +139,9 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const el = messagesEndRef.current;
if (!el) return;
// Scroll without animation when messages are initially loaded
if (previousMessageCount === 0 && currentMessageCount > 0 && !panelState.isLoading) {
el.scrollIntoView({ behavior: "instant", block: "end" });
}
// Scroll with animation when a new message is added
else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
// Defer to next frame to ensure layout is stable
} else if (currentMessageCount > previousMessageCount && previousMessageCount > 0) {
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
@@ -154,6 +151,8 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
// Update the previous message count
previousMessageCountRef.current = currentMessageCount;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
return (
@@ -200,18 +199,14 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
alt="Avatar"
className="chat-header-avatar"
onClick={panel?.handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
/>
style={{ cursor: panel ? "pointer" : "default" }} />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<p>
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
<span className={`online-status ${panelState?.online ? "online" : ""}`} />
{panelState ? (
<>
{panelState.online ? "Online" : "Offline"}
{panelState.isTyping && " • Typing..."}
</>
panelState.online ? "Online" : "Offline"
) : (
"Выберите чат, чтобы начать переписку"
)}
@@ -14,7 +14,6 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
<div className="profile-picture-section">
<img
className="profile-picture"
alt="Profile Picture"
src={userProfile.profile_picture || defaultAvatar}
onError={(e) => {
const target = e.target as HTMLImageElement;
@@ -28,17 +27,17 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
<div className={`online-status ${userProfile.online ? "online" : "offline"}`}>
{userProfile.online ? (
<>
<span className="online-indicator"></span> Онлайн
<span className="online-indicator" /> Онлайн
</>
) : (
<>
<span className="offline-indicator"></span> Последний заход {formatTime(userProfile.last_seen)}
<span className="offline-indicator" /> Последний заход {formatTime(userProfile.last_seen)}
</>
)}
</div>
</div>
<div className="bio-section">
<label>О себе:</label>
<span>О себе:</span>
<div className="bio-display">
{userProfile.bio || "No bio available."}
</div>
@@ -55,7 +54,7 @@ export function UserProfileDialog({ isOpen, onOpenChange, userProfile }: UserPro
</div>
<div className="profile-actions">
<mdui-button id="dm-button" variant="filled">
<mdui-icon slot="icon" name="chat--filled"></mdui-icon>
<mdui-icon slot="icon" name="chat--filled" />
Send Message
</mdui-button>
</div>
@@ -7,7 +7,7 @@ import {
editDmEnvelope,
deleteDmEnvelope
} from "../../../../../core/api/dmApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message, Reaction } from "@/core/types";
import type { UserState } from "@/pages/chat/state";
export interface DMPanelData {
@@ -69,7 +69,13 @@ export class DMPanel extends MessagePanel {
timestamp: env.timestamp,
is_read: false,
is_edited: false,
files: env.files?.map(file => { return {"name": file.name, "encrypted": true, "path": file.path} }) || [],
files: env.files?.map(file => {
return {
"name": file.name,
"encrypted": true,
"path": file.path
}
}) || [],
reactions: env.reactions || [],
runtimeData: {
@@ -231,7 +237,7 @@ export class DMPanel extends MessagePanel {
} catch {}
const updates: Partial<Message> = { content, is_edited: true, files };
this.updateMessage(id, updates);
} catch (e) {
} catch {
this.updateMessage(id, { is_edited: true });
}
}
@@ -308,7 +314,7 @@ export class DMPanel extends MessagePanel {
handleProfileClick(): void {}
updateMessageReactions(dmEnvelopeId: number, reactions: any[]): void {
updateMessageReactions(dmEnvelopeId: number, reactions: Reaction[]): void {
const messages = this.getMessages();
const messageIndex = messages.findIndex(msg =>
msg.runtimeData?.dmEnvelope?.id === dmEnvelopeId
@@ -1,4 +1,4 @@
import type { Message, WebSocketMessage } from "@/core/types";
import type { Message, Reaction, WebSocketMessage } from "@/core/types";
import type { UserState } from "@/pages/chat/state";
export interface MessagePanelState {
@@ -27,7 +27,7 @@ export abstract class MessagePanel {
constructor(
id: string,
currentUser: UserState,
currentUser: UserState
) {
this.state = {
id,
@@ -46,7 +46,7 @@ export abstract class MessagePanel {
abstract loadMessages(): Promise<void>;
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean;
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
abstract handleWebSocketMessage(response: WebSocketMessage<object>): Promise<void>;
// Common methods
protected updateState(updates: Partial<MessagePanelState>): void {
@@ -86,7 +86,7 @@ export abstract class MessagePanel {
});
}
protected updateMessageReactions(messageId: number, reactions: any[]): void {
protected updateMessageReactions(messageId: number, reactions: Reaction[]): void {
this.updateState({
messages: this.state.messages.map(msg =>
msg.id === messageId ? { ...msg, reactions } : msg
@@ -148,7 +148,7 @@ export abstract class MessagePanel {
runtimeData: {
...message.runtimeData,
sendingState: {
status: 'sending',
status: "sending",
tempId,
retryData: {
content,
@@ -194,7 +194,7 @@ export abstract class MessagePanel {
...msg.runtimeData,
sendingState: {
...msg.runtimeData.sendingState,
status: 'failed'
status: "failed"
}
}
};
@@ -222,7 +222,7 @@ export abstract class MessagePanel {
runtimeData: {
...confirmedMessage.runtimeData,
sendingState: {
status: 'sent'
status: "sent"
}
}
};
@@ -254,7 +254,7 @@ export abstract class MessagePanel {
if (!content.trim() && files.length === 0) return;
// Create temporary message for immediate display
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const tempId = `temp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const tempMessage: Message = {
id: -1, // Temporary negative ID
username: this.currentUser.currentUser?.username ?? "You",
@@ -269,7 +269,7 @@ export abstract class MessagePanel {
})),
runtimeData: {
sendingState: {
status: 'sending',
status: "sending",
tempId,
retryData: {
content: content.trim(),
@@ -336,7 +336,7 @@ export abstract class MessagePanel {
...msg.runtimeData,
sendingState: {
...msg.runtimeData.sendingState,
status: 'failed'
status: "failed"
}
}
};
@@ -84,16 +84,22 @@ export class PublicChatPanel extends MessagePanel {
}
} else {
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);
for (const f of files) {
form.append("files", f, f.name);
}
const res = await fetch(`${API_BASE_URL}/send_message`, {
method: "POST",
headers: getAuthHeaders(this.currentUser.authToken, false),
body: form
});
if (!res.ok) {
console.error("Error sending message with files", await res.text());
}
@@ -106,17 +112,17 @@ export class PublicChatPanel extends MessagePanel {
// Handle incoming WebSocket messages
async handleWebSocketMessage(response: ChatWebSocketMessage | ReactionUpdateWebSocketMessage): Promise<void> {
switch (response.type) {
case 'messageEdited':
case "messageEdited":
if (response.data) {
this.updateMessage(response.data.id, response.data);
}
break;
case 'messageDeleted':
case "messageDeleted":
if (response.data && response.data.message_id) {
this.removeMessage(response.data.message_id);
}
break;
case 'newMessage':
case "newMessage":
if (response.data) {
const newMsg = response.data;
@@ -136,7 +142,7 @@ export class PublicChatPanel extends MessagePanel {
this.addMessage(newMsg);
}
break;
case 'reactionUpdate':
case "reactionUpdate":
if (response.data) {
this.updateMessageReactions(response.data.message_id, response.data.reactions);
}
+7 -6
View File
@@ -5,13 +5,13 @@ import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
function GitHubLink({ children }: { children: React.ReactNode }) {
return (
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
<a href="https://github.com/denis0001-dev/FromChat" target="_blank" rel="noopener noreferrer">{children}</a>
);
}
function SupportLink({ children }: { children: React.ReactNode }) {
return (
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
<a href="https://t.me/denis0001-dev" target="_blank" rel="noopener noreferrer">{children}</a>
);
}
@@ -73,12 +73,13 @@ export default function HomePage() {
</p>
<div className="hero-actions">
{openBtn}
{!isMobile && <mdui-button
{!isMobile && (
<mdui-button
variant="outlined"
onClick={() => navigate("/register")}
>
onClick={() => navigate("/register")}>
Зарегистрироваться
</mdui-button>}
</mdui-button>
)}
</div>
</div>
<div className="hero-visual">
@@ -20,16 +20,10 @@ export default function NotFoundPage() {
>
На главную
</mdui-button>
<mdui-button
variant="outlined"
onClick={() => navigate(-1)}
>
Назад
</mdui-button>
</div>
</div>
<div className="not-found-illustration">
<mdui-icon name="search_off"></mdui-icon>
<mdui-icon name="search_off" />
</div>
</div>
</div>
+1 -9
View File
@@ -1,5 +1,6 @@
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
import { importPassword, deriveKEK, randomBytes } from "./kdf";
import { b64, ub64 } from "@/utils/utils";
export interface PrivateKeyBundle {
version: 1;
@@ -46,7 +47,6 @@ export async function decryptBackupWithPassword(password: string, blob: Encrypte
}
export function encodeBlob(blob: EncryptedBackupBlob): string {
function b64(a: Uint8Array) { return btoa(String.fromCharCode(...a)); }
return JSON.stringify({
salt: b64(blob.salt),
iv: b64(blob.iv),
@@ -55,14 +55,6 @@ export function encodeBlob(blob: EncryptedBackupBlob): string {
}
export function decodeBlob(json: string): EncryptedBackupBlob {
function ub64(s: string) {
const bin = atob(s);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
const obj = JSON.parse(json);
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
}
+10 -2
View File
@@ -3,7 +3,11 @@ export async function importPassword(password: string): Promise<CryptoKey> {
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
}
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
export async function deriveKEK(
passwordKey: CryptoKey,
salt: Uint8Array | ArrayBuffer,
iterations = 210_000
): Promise<CryptoKey> {
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
@@ -14,7 +18,11 @@ export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | Array
);
}
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer,
salt: Uint8Array | ArrayBuffer,
info: Uint8Array | ArrayBuffer,
length = 32
): Promise<Uint8Array> {
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
+5 -1
View File
@@ -10,7 +10,11 @@ export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | Arra
return { iv, ciphertext: new Uint8Array(ct) };
}
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
export async function aesGcmDecrypt(
key: CryptoKey,
iv: Uint8Array | ArrayBuffer,
ciphertext: Uint8Array | ArrayBuffer
): Promise<Uint8Array> {
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
const ivBuf: ArrayBuffer = iv instanceof Uint8Array
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
+17 -17
View File
@@ -5,24 +5,24 @@
* @version 1.0.0
*/
import 'mdui/components/tabs';
import 'mdui/components/tab';
import 'mdui/components/tab-panel';
import 'mdui/components/list';
import 'mdui/components/list-item';
import 'mdui/components/bottom-app-bar';
import 'mdui/components/button-icon';
import 'mdui/components/fab';
import 'mdui/components/dialog';
import 'mdui/components/button';
import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import 'mdui/components/chip';
import "mdui/components/tabs";
import "mdui/components/tab";
import "mdui/components/tab-panel";
import "mdui/components/list";
import "mdui/components/list-item";
import "mdui/components/bottom-app-bar";
import "mdui/components/button-icon";
import "mdui/components/fab";
import "mdui/components/dialog";
import "mdui/components/button";
import "mdui/components/text-field";
import "mdui/components/button-icon";
import "mdui/components/top-app-bar";
import "mdui/components/top-app-bar-title";
import "mdui/components/switch";
import "mdui/components/chip";
import "mdui/mdui.css";
import { setColorScheme } from 'mdui/functions/setColorScheme.js';
import { setColorScheme } from "mdui/functions/setColorScheme.js";
setColorScheme("#91cef4");
+6 -6
View File
@@ -9,7 +9,7 @@
* Notification type enumeration
* @typedef {'success' | 'error'} NotificationType
*/
export type NotificationType = 'success' | 'error';
export type NotificationType = "success" | "error";
/**
* Shows a notification with the specified message and type
@@ -18,7 +18,7 @@ export type NotificationType = 'success' | 'error';
* @private
*/
function showNotification(message: string, type: NotificationType): void {
const notification = document.createElement('div');
const notification = document.createElement("div");
notification.textContent = message;
notification.style.cssText = `
position: fixed;
@@ -27,7 +27,7 @@ function showNotification(message: string, type: NotificationType): void {
padding: 12px 16px;
border-radius: 4px;
color: white;
background: ${type === 'success' ? '#4caf50' : '#f44336'};
background: ${type === "success" ? "#4caf50" : "#f44336"};
z-index: 10000;
font-family: inherit;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
@@ -38,7 +38,7 @@ function showNotification(message: string, type: NotificationType): void {
// Fade out and remove
setTimeout(() => {
notification.style.opacity = '0';
notification.style.opacity = "0";
setTimeout(() => {
notification.remove();
}, 300);
@@ -50,7 +50,7 @@ function showNotification(message: string, type: NotificationType): void {
* @param {string} message - The success message to display
*/
export function showSuccess(message: string): void {
showNotification(message, 'success');
showNotification(message, "success");
}
/**
@@ -58,5 +58,5 @@ export function showSuccess(message: string): void {
* @param {string} message - The error message to display
*/
export function showError(message: string): void {
showNotification(message, 'error');
showNotification(message, "error");
}
+11 -7
View File
@@ -14,11 +14,11 @@
*/
export function formatTime(dateString: string): string {
const date = new Date(dateString);
let hours = date.getHours();
let minutes = date.getMinutes();
const hoursString = hours < 10 ? '0' + hours : hours;
const minutesString = minutes < 10 ? '0' + minutes : minutes;
return hoursString + ':' + minutesString;
const hours = date.getHours();
const minutes = date.getMinutes();
const hoursString = hours < 10 ? "0" + hours : hours;
const minutesString = minutes < 10 ? "0" + minutes : minutes;
return hoursString + ":" + minutesString;
}
/**
@@ -32,12 +32,16 @@ export function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function b64(a: Uint8Array): string {
return btoa(String.fromCharCode(...a));
}
export function b64(a: Uint8Array): string { return btoa(String.fromCharCode(...a)); }
export function ub64(s: string): Uint8Array {
const bin = atob(s);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
for (let i = 0; i < bin.length; i++) {
arr[i] = bin.charCodeAt(i);
}
return arr;
}
+12 -1
View File
@@ -14,7 +14,9 @@
"backend:clean": "rm -rf backend/data",
"frontend:dev": "vite frontend",
"frontend:typecheck": "tsc --project frontend",
"frontend:build": "npm run frontend:typecheck && vite build frontend",
"frontend:lint": "eslint --config frontend/eslint.config.ts frontend/src --ext .js,.jsx,.ts,.tsx",
"frontend:check": "npm run frontend:lint && npm run frontend:typecheck",
"frontend:build": "npm run frontend:check && vite build frontend",
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64",
"frontend:preview": "vite preview frontend",
@@ -44,13 +46,22 @@
"@electron-forge/plugin-auto-unpack-natives": "^7.9.0",
"@electron-forge/plugin-fuses": "^7.9.0",
"@electron/fuses": "^1.0.0",
"@eslint/js": "^9.37.0",
"@types/eslint-plugin-jsx-a11y": "^6.10.1",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0",
"@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.1",
"dotenv-cli": "^10.0.0",
"electron": "^38.1.2",
"eslint": "^9.37.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-refresh": "^0.4.23",
"husky": "^9.1.7",
"postcss": "^8.5.6",
"rollup-plugin-visualizer": "^6.0.4",