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