Restructure

This commit is contained in:
2025-10-09 22:35:12 +03:00
Unverified
parent b638326653
commit 32e08cdfdb
43 changed files with 205 additions and 268 deletions
+14
View File
@@ -0,0 +1,14 @@
import { LeftPanel } from "./left/LeftPanel";
import { RightPanel } from "./right/RightPanel";
import "../css/chat.scss";
export default function ChatPage() {
return (
<div id="chat-interface">
<div className="all-container">
<LeftPanel />
<RightPanel />
</div>
</div>
);
}
@@ -1,11 +0,0 @@
import { PRODUCT_NAME } from "../../../../core/config";
import { isElectron } from "../../../../core/electron/electron";
export function ElectronTitleBar() {
return isElectron && (
<div id="electron-title-bar">
{window.electronInterface.platform == "darwin" && <div className="macos-padding"></div>}
<div id="window-title">{PRODUCT_NAME}</div>
</div>
)
}
@@ -1,117 +0,0 @@
import { useAppState } from "../../state";
import { useState, useEffect } from "react";
import type { Reaction } from "../../../../../core/types";
interface MessageReactionsProps {
reactions?: Reaction[];
onReactionClick: (emoji: string) => void;
messageId?: number; // Add messageId to ensure unique keys
}
export function MessageReactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
const { user } = useAppState();
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
const [isVisible, setIsVisible] = useState(false);
// Handle reactions with animation
useEffect(() => {
if (!reactions || reactions.length === 0) {
// If we have visible reactions, animate them out
if (visibleReactions.length > 0) {
visibleReactions.forEach(reaction => {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
});
// After animation completes, hide the component
setTimeout(() => {
setVisibleReactions([]);
setAnimatingReactions(new Set());
setIsVisible(false);
}, 200);
} else {
// No visible reactions, hide immediately
setIsVisible(false);
}
return;
}
// Show the component when we have reactions
setIsVisible(true);
// Deduplicate reactions by emoji (safety measure)
const uniqueReactions = reactions.reduce((acc, reaction) => {
const existing = acc.find(r => r.emoji === reaction.emoji);
if (existing) {
// Keep the one with the higher count
if (reaction.count > existing.count) {
acc[acc.indexOf(existing)] = reaction;
}
} else {
acc.push(reaction);
}
return acc;
}, [] as Reaction[]);
// Animate out removed reactions
visibleReactions.forEach(reaction => {
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
setTimeout(() => {
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
setAnimatingReactions(prev => {
const newSet = new Set(prev);
newSet.delete(reaction.emoji);
return newSet;
});
}, 200);
}
});
// Update existing reactions and add new ones
setVisibleReactions(prev => {
const updated = [...prev];
// Update existing reactions
uniqueReactions.forEach(reaction => {
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
if (existingIndex !== -1) {
updated[existingIndex] = reaction;
} else {
// Add new reaction only if it doesn't already exist
if (!updated.some(r => r.emoji === reaction.emoji)) {
updated.push(reaction);
}
}
});
return updated;
});
}, [reactions]);
// Don't render if not visible
if (!isVisible) {
return null;
}
return (
<div className="message-reactions">
{visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji);
return (
<button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
>
<span className="reaction-emoji">{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span>
</button>
);
})}
</div>
);
}
@@ -1,60 +0,0 @@
import { useState, useEffect } from "react";
import type { Message } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { MaterialTextField } from "../core/TextField";
interface ReplyMessageDialogProps {
isOpen: boolean;
onOpenChange: (value: boolean) => void;
replyToMessage: Message | null;
onSendReply: (content: string, replyToId: number) => void;
}
export function ReplyMessageDialog({ isOpen, onOpenChange, replyToMessage, onSendReply }: ReplyMessageDialogProps) {
const [replyContent, setReplyContent] = useState("");
useEffect(() => {
if (replyToMessage) {
setReplyContent("");
}
}, [replyToMessage]);
const handleSendReply = () => {
if (replyToMessage && replyContent.trim()) {
onSendReply(replyContent.trim(), replyToMessage.id);
onOpenChange(false);
}
};
const handleCancel = () => {
onOpenChange(false);
setReplyContent("");
};
if (!replyToMessage) return null;
return (
<MaterialDialog open={isOpen} onOpenChange={onOpenChange} close-on-overlay-click close-on-esc className="reply-dialog">
<div className="dialog-content">
<h3>Ответить на сообщение</h3>
<div className="reply-preview-dialog">
<div className="reply-content">
<span className="reply-username">{replyToMessage.username}</span>
<span className="reply-text">{replyToMessage.content}</span>
</div>
</div>
<MaterialTextField
value={replyContent}
onInput={(e) => setReplyContent((e.target as HTMLInputElement).value)}
label="Reply"
variant="outlined"
placeholder="Type your reply..."
maxlength={1000} />
<div className="dialog-actions">
<mdui-button onClick={handleCancel} variant="outlined">Cancel</mdui-button>
<mdui-button onClick={handleSendReply}>Send Reply</mdui-button>
</div>
</div>
</MaterialDialog>
);
}
@@ -1,45 +0,0 @@
import type { Dialog as MduiDialog } from "mdui/components/dialog";
import { useEffect, type Ref } from "react"
import { createPortal } from "react-dom";
import { id } from "../../../../../utils/utils";
import useCombinedRefs from "../../hooks/useCombinedRefs";
export interface BaseDialogProps {
onOpenChange: (value: boolean) => void;
ref?: Ref<MduiDialog & HTMLElement>
}
export type FullDialogProps = React.ComponentPropsWithoutRef<"mdui-dialog"> & BaseDialogProps;
export function MaterialDialog(props: FullDialogProps) {
const [setDialogRef, dialogRef] = useCombinedRefs(props.ref);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes" && mutation.attributeName === "open") {
const isOpen = dialog.hasAttribute("open");
if (isOpen !== props.open) {
props.onOpenChange(isOpen);
}
}
});
});
// Start observing the dialog element for attribute changes
observer.observe(dialog, {
attributes: true,
attributeFilter: ["open"]
});
// Cleanup observer
return () => {
observer.disconnect();
};
}, [dialogRef.current, props.open, props.onOpenChange]);
return createPortal(<mdui-dialog {...props} ref={setDialogRef} />, id("root"));
}
@@ -1,17 +0,0 @@
import type { ReactNode } from "react";
export interface QuoteProps {
className?: string;
children?: ReactNode;
background?: "surfaceContainer" | "primaryContainer"
}
export default function Quote({ className, children, background = "primaryContainer" }: QuoteProps) {
return (
<div className={`quote bg-${background} ${className}`}>
<div className="quote-inner">
{children}
</div>
</div>
)
}
@@ -1,211 +0,0 @@
import { useEffect, useRef, useCallback, useLayoutEffect } from "react";
interface RichTextAreaProps {
text: string;
onTextChange: (value: string) => void;
onEnter?: "newLine" | null | ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void);
onCtrlEnter?: ((e: React.KeyboardEvent<HTMLTextAreaElement>) => void) | null;
placeholder?: string;
id?: string;
className?: string;
rows?: number;
autoComplete?: string;
}
export function RichTextArea({
text,
onTextChange,
onEnter = "newLine",
onCtrlEnter = null,
placeholder,
className,
rows = 1,
autoComplete = "off",
}: RichTextAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const hiddenTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const heightRef = useRef<number | null>(null);
function getStyleValue(computedStyle: CSSStyleDeclaration, prop: keyof CSSStyleDeclaration): number {
const raw = computedStyle[prop] as string | number | undefined;
if (raw == null) return 0;
const str = String(raw);
return str.endsWith("px") ? parseFloat(str) : parseFloat(str) || 0;
}
const calculateTextareaStyles = useCallback(() => {
const textarea = textareaRef.current;
const hidden = hiddenTextareaRef.current;
if (!textarea || !hidden) return undefined;
const computedStyle = window.getComputedStyle(textarea);
if (computedStyle.width === "0px") {
return { outerHeightStyle: 0, overflowing: false };
}
// Ensure hidden textarea copies width but not percentage-based anomalies from parents
// Normalize hidden textarea to avoid inherited constraints and copy critical metrics
hidden.style.position = "fixed";
hidden.style.top = "-9999px";
hidden.style.left = "-9999px";
hidden.style.visibility = "hidden";
hidden.style.height = "auto";
hidden.style.minHeight = "0";
hidden.style.maxHeight = "none";
hidden.style.overflow = "hidden";
hidden.style.boxSizing = computedStyle.boxSizing;
// Avoid counting vertical padding twice: keep 0 for measurement
hidden.style.paddingTop = "0";
hidden.style.paddingBottom = "0";
hidden.style.paddingLeft = computedStyle.paddingLeft;
hidden.style.paddingRight = computedStyle.paddingRight;
// Do not include borders in the inner scrollHeight measurement
hidden.style.borderTopWidth = "0";
hidden.style.borderBottomWidth = "0";
hidden.style.borderLeftWidth = computedStyle.borderLeftWidth;
hidden.style.borderRightWidth = computedStyle.borderRightWidth;
hidden.style.fontFamily = computedStyle.fontFamily;
hidden.style.fontSize = computedStyle.fontSize;
hidden.style.fontWeight = computedStyle.fontWeight;
hidden.style.lineHeight = computedStyle.lineHeight;
hidden.style.letterSpacing = computedStyle.letterSpacing;
hidden.style.whiteSpace = computedStyle.whiteSpace;
hidden.style.wordSpacing = computedStyle.wordSpacing;
hidden.style.textIndent = computedStyle.textIndent;
hidden.style.textTransform = computedStyle.textTransform;
hidden.style.textDecoration = computedStyle.textDecoration;
hidden.style.width = computedStyle.width;
hidden.style.maxWidth = computedStyle.width;
hidden.value = textarea.value || placeholder || "x";
if (hidden.value.slice(-1) === "\n") {
hidden.value += " ";
}
const boxSizing = computedStyle.boxSizing;
const padding = getStyleValue(computedStyle, "paddingBottom") + getStyleValue(computedStyle, "paddingTop");
const border = getStyleValue(computedStyle, "borderBottomWidth") + getStyleValue(computedStyle, "borderTopWidth");
const innerHeight = hidden.scrollHeight;
hidden.value = "x";
const singleRowHeight = hidden.scrollHeight;
let outerHeight = innerHeight;
const minRows = Number(rows || 1);
if (minRows) {
outerHeight = Math.max(minRows * singleRowHeight, outerHeight);
}
outerHeight = Math.max(outerHeight, singleRowHeight);
// Use ceil to avoid sub-pixel gaps and subtract a tiny epsilon to reduce visual gap
let outerHeightStyle = outerHeight + (boxSizing === "border-box" ? padding + border : 0);
outerHeightStyle = Math.round(outerHeightStyle); // snap to pixel to avoid half-line gaps
const overflowing = Math.abs(outerHeight - innerHeight) <= 1;
return { outerHeightStyle, overflowing };
}, [rows, placeholder]);
const syncHeight = useCallback(() => {
const textarea = textareaRef.current;
const styles = calculateTextareaStyles();
if (!textarea || !styles) return;
const { outerHeightStyle, overflowing } = styles;
if (heightRef.current !== outerHeightStyle) {
heightRef.current = outerHeightStyle;
textarea.style.height = `${outerHeightStyle}px`;
}
textarea.style.overflowY = overflowing ? "hidden" : "";
}, [calculateTextareaStyles]);
useLayoutEffect(() => {
syncHeight();
}, [syncHeight, text]);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const onResize = () => syncHeight();
window.addEventListener("resize", onResize);
let ro: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined") {
ro = new ResizeObserver(() => {
ro!.unobserve(textarea);
syncHeight();
requestAnimationFrame(() => ro && textarea && ro.observe(textarea));
});
ro.observe(textarea);
}
return () => {
window.removeEventListener("resize", onResize);
if (ro) ro.disconnect();
};
}, [syncHeight]);
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
// Keep height responsive during rapid uncontrolled input bursts
syncHeight();
onTextChange(e.target.value);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
const isCtrlEnter = e.key === "Enter" && (e.ctrlKey || e.metaKey);
const isPlainEnter = e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
if (isCtrlEnter) {
e.preventDefault();
if (typeof onCtrlEnter === "function") {
onCtrlEnter(e);
}
return;
}
if (isPlainEnter) {
if (onEnter === "newLine") {
// allow default
return;
}
if (onEnter === null) {
e.preventDefault();
return;
}
if (typeof onEnter === "function") {
e.preventDefault();
onEnter(e);
return;
}
}
}
return (
<>
<textarea
className={`rich-text-area ${className}`}
ref={textareaRef}
value={text}
placeholder={placeholder}
rows={rows}
autoComplete={autoComplete}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
<textarea
aria-hidden
readOnly
tabIndex={-1}
ref={hiddenTextareaRef}
style={{
position: "fixed",
top: "-9999px",
left: "-9999px",
visibility: "hidden",
paddingTop: 0,
paddingBottom: 0,
height: "auto",
minHeight: 0,
maxHeight: "none",
overflow: "hidden",
}}
rows={1}
/>
</>
);
}
@@ -1,9 +0,0 @@
import type { TextField } from "mdui/components/text-field";
type TextFieldProps = React.ComponentPropsWithoutRef<"mdui-text-field">
export function MaterialTextField(props: TextFieldProps & { ref?: React.Ref<TextField> }) {
return <mdui-text-field
autocomplete="off"
{...(props as TextFieldProps & { ref?: React.Ref<HTMLElement> })} />
}
@@ -1,71 +0,0 @@
import { useEffect, useState, useRef } from "react";
import type { AnimatedPropertyProps } from "./types";
export default function AnimatedHeight({ visible, duration = 0.25, onFinish, children, ...props }: AnimatedPropertyProps) {
const [height, setHeight] = useState("0px");
const [shouldRender, setShouldRender] = useState(!!visible);
const [isAnimating, setIsAnimating] = useState(false);
const measureRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (visible) {
setShouldRender(true);
setIsAnimating(true);
// Wait for content to render, then measure
setTimeout(() => {
if (measureRef.current) {
const contentHeight = measureRef.current.scrollHeight;
setHeight(`${contentHeight}px`);
}
// Animation complete
setTimeout(() => {
setHeight("auto");
setIsAnimating(false);
}, duration * 1000);
}, 0);
} else if (shouldRender) {
setIsAnimating(true);
if (measureRef.current) {
const contentHeight = measureRef.current.scrollHeight;
setHeight(`${contentHeight}px`);
// Force a reflow before animating to 0
requestAnimationFrame(() => {
// Read layout to ensure the previous height assignment is flushed
if (containerRef.current) {
containerRef.current.offsetHeight;
}
// Use a second frame to ensure the measured pixel height is applied before collapsing
requestAnimationFrame(() => {
setHeight("0px");
});
});
}
// Hide content after animation completes
setTimeout(() => {
setShouldRender(false);
setIsAnimating(false);
if (onFinish) {
onFinish();
}
}, duration * 1000);
}
}, [visible, shouldRender]);
return (visible || shouldRender || isAnimating) && (
<div
{...props}
ref={containerRef}
style={{
height,
transition: `height ${duration}s ease`,
overflow: "hidden",
...props.style
}}
>
<div ref={measureRef} style={{ height: "auto" }}>
{shouldRender && children}
</div>
</div>
);
}
@@ -1,41 +0,0 @@
import { useEffect, useState } from "react";
import type { AnimatedPropertyProps } from "./types";
export default function AnimatedOpacity({ visible, duration = 0.5, onFinish, children, ...props }: AnimatedPropertyProps) {
const [opacity, setOpacity] = useState(visible ? 1 : 0);
const [shouldRender, setShouldRender] = useState(visible);
useEffect(() => {
if (visible) {
setShouldRender(true);
setOpacity(0);
// Wait for content to render, then animate in
const id = setTimeout(() => {
setOpacity(1);
}, 10);
return () => clearTimeout(id);
} else {
setOpacity(0);
const id = setTimeout(() => {
setShouldRender(false);
if (onFinish) {
onFinish();
}
}, duration * 1000);
return () => clearTimeout(id);
}
}, [visible, duration, onFinish]);
return shouldRender && (
<div
{...props}
style={{
opacity,
transition: `opacity ${duration}s ease`,
...props.style
}}
>{children}</div>
);
}
@@ -1,10 +0,0 @@
import type { ReactNode } from "react";
export interface BaseAnimatedPropertyProps {
visible: any;
duration?: number;
onFinish?: () => void
children?: ReactNode;
}
export type AnimatedPropertyProps = BaseAnimatedPropertyProps & React.ComponentPropsWithRef<"div">
@@ -1,34 +0,0 @@
import { useRef, useCallback, type RefCallback, type Ref } from 'react';
// Определяем тип для ref, который может быть либо функцией, либо объектом
type PossibleRef<T> = Ref<T> | undefined;
export default function useCombinedRefs<T>(...refs: PossibleRef<T>[]): [RefCallback<T>, React.RefObject<T | null>] {
const targetRef = useRef<T | null>(null);
const setRefs = useCallback((node: T | null) => {
// Обновляем внутренний ref
targetRef.current = node;
// Обновляем все переданные refs
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
// Если ref - это функция, вызываем её
ref(node);
} else {
// Если ref - это объект, обновляем его свойство .current
// Используем проверку, чтобы убедиться, что это действительно MutableRefObject
// (хотя в реальном коде это почти всегда так)
ref.current = node;
}
});
},
// Убедитесь, что массив зависимостей всегда актуален
// eslint-disable-next-line react-hooks/exhaustive-deps
[...refs]
);
return [setRefs, targetRef];
}
-300
View File
@@ -1,300 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import {
fetchUsers,
fetchUserPublicKey,
fetchDMHistory,
decryptDm,
sendDMViaWebSocket
} from "../../../../core/api/dmApi";
import type { User, Message, DmEncryptedJSON } from "../../../../core/types";
import { websocket } from "../../../../core/websocket";
export interface DMUser extends User {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
}
export function useDM() {
const { user, chat, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState();
const [dmUsers, setDmUsersState] = useState<DMUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const usersLoadedRef = useRef(false);
// Load last message and unread count for a specific user
const loadUserLastMessage = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key
const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
// Get message history
const messages = await fetchDMHistory(dmUser.id, user.authToken, 50);
if (messages.length === 0) return;
// Find last message
const lastMessage = messages[messages.length - 1];
let lastPlaintext: string | null = null;
try {
lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content;
console.log(lastPlaintext);
} catch (error) {
console.error("Failed to decrypt last message:", error);
}
// Calculate unread count
const lastReadId = getLastReadId(dmUser.id);
let unreadCount = 0;
for (const msg of messages) {
if (msg.senderId === dmUser.id && msg.id > lastReadId) {
unreadCount++;
}
}
// Update user state
setDmUsersState(prev => prev.map(u =>
u.id === dmUser.id
? {
...u,
lastMessage: lastPlaintext ? lastPlaintext.split(/\r?\n/).slice(0, 2).join("\n") : undefined,
unreadCount,
publicKey
}
: u
));
} catch (error) {
console.error("Failed to load last message for user:", dmUser.id, error);
}
}, [user.authToken]);
// Load users when DM tab is active
const loadUsers = useCallback(async () => {
if (!user.authToken || isLoadingUsers || usersLoadedRef.current) return;
usersLoadedRef.current = true;
setIsLoadingUsers(true);
try {
const users = await fetchUsers(user.authToken);
console.log("Fetched users:", users);
const dmUsersWithState: DMUser[] = users.map(user => ({
...user,
unreadCount: 0,
lastMessage: undefined,
publicKey: null
}));
setDmUsersState(dmUsersWithState);
setDmUsers(users);
// Load last messages and unread counts for visible users
// Call loadUserLastMessage directly without dependency
for (const dmUser of dmUsersWithState) {
await loadUserLastMessage(dmUser);
}
} catch (error) {
console.error("Failed to load DM users:", error);
} finally {
setIsLoadingUsers(false);
}
}, [user.authToken, isLoadingUsers]);
// Reset users loaded flag when user changes
useEffect(() => {
usersLoadedRef.current = false;
}, [user.authToken]);
// Load DM history for active conversation
const loadDMHistory = useCallback(async (userId: number, publicKey: string) => {
if (!user.authToken || isLoadingHistory) return;
setIsLoadingHistory(true);
try {
const messages = await fetchDMHistory(userId, user.authToken, 50);
const decryptedMessages: Message[] = [];
let maxIncomingId = 0;
for (const env of messages) {
try {
const text = await decryptDm(env, publicKey);
const isAuthor = env.senderId !== userId;
const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User";
decryptedMessages.push({
id: env.id,
content: text,
username: username,
timestamp: env.timestamp,
is_read: false,
is_edited: false
});
if (env.senderId === userId && env.id > maxIncomingId) {
maxIncomingId = env.id;
}
} catch (error) {
console.error("Error decrypting message:", error);
}
}
clearMessages();
decryptedMessages.forEach(msg => addMessage(msg));
// Update last read ID
if (maxIncomingId > 0) {
setLastReadId(userId, maxIncomingId);
// Clear unread count
setDmUsersState(prev => prev.map(u =>
u.id === userId ? { ...u, unreadCount: 0 } : u
));
}
} catch (error) {
console.error("Failed to load DM history:", error);
} finally {
setIsLoadingHistory(false);
}
}, [user.authToken, user.currentUser, isLoadingHistory, clearMessages, addMessage]);
// Send DM message
const sendDMMessage = useCallback(async (recipientId: number, publicKey: string, content: string) => {
if (!user.authToken) return;
try {
await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken);
} catch (error) {
console.error("Failed to send DM:", error);
}
}, [user.authToken]);
// Start DM conversation
const startDMConversation = useCallback(async (dmUser: DMUser) => {
if (!user.authToken) return;
try {
// Get public key if not already loaded
let publicKey = dmUser.publicKey;
if (!publicKey) {
publicKey = await fetchUserPublicKey(dmUser.id, user.authToken);
if (!publicKey) return;
}
// Set active DM
setActiveDm({
userId: dmUser.id,
username: dmUser.username,
publicKey
});
// Load conversation history
await loadDMHistory(dmUser.id, publicKey);
} catch (error) {
console.error("Failed to start DM conversation:", error);
}
}, [user.authToken, setActiveDm, loadDMHistory]);
// WebSocket message handler
useEffect(() => {
async function handleWebSocketMessage(e: MessageEvent) {
try {
const msg = JSON.parse(e.data);
if (msg.type === "dmNew") {
const { senderId, recipientId, ...envelope } = msg.data;
// If this is for the active DM conversation
if (chat.activeDm && (senderId === chat.activeDm.userId || recipientId === chat.activeDm.userId)) {
try {
const plaintext = await decryptDm(envelope, chat.activeDm.publicKey!);
const isAuthor = senderId !== chat.activeDm.userId;
addMessage({
id: envelope.id,
content: plaintext,
username: isAuthor ? (user.currentUser?.username || "Unknown") : (chat.activeDm.username || "Unknown"),
timestamp: envelope.timestamp,
is_read: false,
is_edited: false
});
// Update last read if it's from the other user
if (senderId === chat.activeDm.userId) {
setLastReadId(chat.activeDm.userId, Math.max(getLastReadId(chat.activeDm.userId), envelope.id));
}
} catch (error) {
console.error("Failed to decrypt incoming DM:", error);
}
} else {
// Update unread count for other users
const otherUserId = senderId;
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? { ...u, unreadCount: u.unreadCount + 1 }
: u
));
// Update last message preview
try {
const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!);
if (publicKey) {
const plaintext = await decryptDm(envelope, publicKey);
setDmUsersState(prev => prev.map(u =>
u.id === otherUserId
? {
...u,
lastMessage: plaintext.split(/\r?\n/).slice(0, 2).join("\n"),
publicKey
}
: u
));
}
} catch (error) {
console.error("Failed to update last message preview:", error);
}
}
}
} catch (error) {
console.error("Failed to handle WebSocket message:", error);
}
}
websocket.addEventListener("message", handleWebSocketMessage);
return () => websocket.removeEventListener("message", handleWebSocketMessage);
}, [chat.activeDm, user.currentUser, addMessage]);
// Force reload users (useful for refreshing the list)
const reloadUsers = useCallback(() => {
usersLoadedRef.current = false;
loadUsers();
}, [loadUsers]);
return {
dmUsers,
isLoadingUsers,
isLoadingHistory,
loadUsers,
reloadUsers,
startDMConversation,
sendDMMessage,
loadUserLastMessage
};
}
// Helper functions for localStorage
function getLastReadId(userId: number): number {
try {
const v = localStorage.getItem(`dmLastRead:${userId}`);
return v ? Number(v) : 0;
} catch {
return 0;
}
}
function setLastReadId(userId: number, id: number): void {
try {
localStorage.setItem(`dmLastRead:${userId}`, String(id));
} catch {}
}
@@ -1,98 +0,0 @@
import { useState, useCallback, useEffect } from "react";
import { useAppState } from "../state";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../../../core/api/profileApi";
import { showSuccess, showError } from "../../../../utils/notification";
export default function useProfile() {
const { user } = useAppState();
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
// Load profile data
const loadProfileData = useCallback(async () => {
if (!user.authToken) return;
setIsLoading(true);
try {
const data = await loadProfile(user.authToken);
if (data) {
setProfileData(data);
}
} catch (error) {
console.error('Error loading profile:', error);
showError('Ошибка при загрузке профиля');
} finally {
setIsLoading(false);
}
}, [user.authToken]);
// Update profile
const updateProfileData = useCallback(async (data: Partial<ProfileData>) => {
if (!user.authToken) return false;
setIsUpdating(true);
try {
const success = await updateProfile(user.authToken, data);
if (success) {
// Reload profile data to get updated information
await loadProfileData();
showSuccess('Профиль обновлен!');
return true;
} else {
showError('Ошибка при обновлении профиля');
return false;
}
} catch (error) {
console.error('Error updating profile:', error);
showError('Ошибка при обновлении профиля');
return false;
} finally {
setIsUpdating(false);
}
}, [user.authToken, loadProfileData]);
// Upload profile picture
const uploadProfilePictureData = useCallback(async (file: Blob) => {
if (!user.authToken) return false;
setIsUpdating(true);
try {
const result = await uploadProfilePicture(user.authToken, file);
if (result) {
// Update profile data with new picture URL
setProfileData(prev => prev ? {
...prev,
profile_picture: result.profile_picture_url
} : null);
showSuccess('Фото профиля обновлено!');
return true;
} else {
showError('Ошибка при загрузке фото');
return false;
}
} catch (error) {
console.error('Error uploading profile picture:', error);
showError('Ошибка при загрузке фото');
return false;
} finally {
setIsUpdating(false);
}
}, [user.authToken]);
// Load profile data when user is authenticated
useEffect(() => {
if (user.authToken) {
loadProfileData();
}
}, [user.authToken, loadProfileData]);
return {
profileData,
isLoading,
isUpdating,
loadProfileData,
updateProfileData,
uploadProfilePictureData
};
}
@@ -1,29 +0,0 @@
import { useEffect, useState } from "react";
export interface WindowSize {
width: number;
height: number;
}
export default function useWindowSize(): WindowSize {
const [width, setWidth] = useState(innerWidth);
const [height, setHeight] = useState(innerHeight);
useEffect(() => {
function listener() {
setWidth(innerWidth);
setHeight(innerHeight);
}
addEventListener("resize", listener);
return () => {
removeEventListener("resize", listener);
}
});
return {
width: width,
height: height
}
}
@@ -1,8 +1,8 @@
import { PRODUCT_NAME } from "../../../../../core/config";
import { PRODUCT_NAME } from "../../../../core/config";
import useProfile from "../../hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { useState } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
import { ProfileDialog } from "./profile/ProfileDialog";
export function ChatHeader() {
const { profileData } = useProfile();
@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { useDM, type DMUser } from "../../hooks/useDM";
import { useAppState } from "../../state";
import { fetchUserPublicKey } from "../../../../../core/api/dmApi";
import defaultAvatar from "../../../../../images/default-avatar.png";
import { fetchUserPublicKey } from "../../../../core/api/dmApi";
import defaultAvatar from "../../../../images/default-avatar.png";
export function DMUsersList() {
const { dmUsers, isLoadingUsers, loadUsers } = useDM();
@@ -1,9 +1,9 @@
import { PRODUCT_NAME } from "../../../../../core/config";
import { PRODUCT_NAME } from "../../../../core/config";
import { useAppState } from "../../state";
import defaultAvatar from "../../../../../images/default-avatar.png";
import defaultAvatar from "../../../../images/default-avatar.png";
import { useState, type FormEvent } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
import { SettingsDialog } from "../settings/SettingsDialog";
import { ProfileDialog } from "./profile/ProfileDialog";
import { SettingsDialog } from "./settings/SettingsDialog";
import { DMUsersList } from "./DMUsersList";
import type { Tabs } from "mdui";
import type { ChatTabs } from "../../state";
@@ -2,10 +2,10 @@ import { useState, useEffect, useRef, type FormEvent } from "react";
import defaultAvatar from "../../../../../images/default-avatar.png";
import type { TextField } from "mdui/components/text-field";
import type { DialogProps } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import useProfile from "../../hooks/useProfile";
import { MaterialDialog } from "../../../../../core/components/Dialog";
import useProfile from "../../../hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
import { MaterialTextField } from "../core/TextField";
import { MaterialTextField } from "../../../../../core/components/TextField";
export function ProfileDialog({ isOpen, onOpenChange }: DialogProps) {
const { profileData, isLoading, isUpdating, updateProfileData, uploadProfilePictureData } = useProfile();
@@ -1,10 +1,10 @@
import { useState, useEffect } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "../../../../../core/config";
import type { DialogProps } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { MaterialDialog } from "../../../../../core/components/Dialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "../../../../../core/push-notifications/push-notifications";
import { isElectron } from "../../../../../core/electron/electron";
import { useAppState } from "../../state";
import { useAppState } from "../../../state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../../../core/api/authApi";
@@ -1,9 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { MaterialDialog } from "../core/Dialog";
import { RichTextArea } from "../core/RichTextArea";
import type { Message } from "../../../../../core/types";
import Quote from "../core/Quote";
import AnimatedHeight from "../core/animations/AnimatedHeight";
import { MaterialDialog } from "../../../../core/components/Dialog";
import { RichTextArea } from "../../../../core/components/RichTextArea";
import type { Message } from "../../../../core/types";
import Quote from "../../../../core/components/Quote";
import AnimatedHeight from "../../../../core/components/animations/AnimatedHeight";
import { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
@@ -1,15 +1,15 @@
import { Message } from "./Message";
import { useAppState } from "../../state";
import type { Message as MessageType } from "../../../../../core/types";
import type { UserProfile } from "../../../../../core/types";
import type { Message as MessageType } from "../../../../core/types";
import type { UserProfile } from "../../../../core/types";
import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { fetchUserProfile } from "../../../../../core/api/profileApi";
import { fetchUserProfile } from "../../../../core/api/profileApi";
import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../../../utils/utils";
import { MaterialDialog } from "../core/Dialog";
import { request } from "../../../../../core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "../../../../../core/types";
import { delay } from "../../../../utils/utils";
import { MaterialDialog } from "../../../../core/components/Dialog";
import { request } from "../../../../core/websocket";
import type { AddReactionRequest, AddDmReactionRequest } from "../../../../core/types";
interface ChatMessagesProps {
messages?: MessageType[];
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "../../../../../core/types";
import type { Size2D } from "../../../../core/types";
interface BaseEmojiMenuProps {
isOpen: boolean;
@@ -1,19 +1,133 @@
import { formatTime, id } from "../../../../../utils/utils";
import type { Attachment, Message as MessageType } from "../../../../../core/types";
import defaultAvatar from "../../../../../images/default-avatar.png";
import Quote from "../core/Quote";
import { formatTime, id } from "../../../../utils/utils";
import type { Attachment, Message as MessageType, Reaction } from "../../../../core/types";
import defaultAvatar from "../../../../images/default-avatar.png";
import Quote from "../../../../core/components/Quote";
import { parse } from "marked";
import DOMPurify from "dompurify";
import { useEffect, useState, useRef } from "react";
import { getCurrentKeys } from "../../../../../core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "../../../../../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "../../../../../utils/crypto/symmetric";
import { getAuthHeaders } from "../../../../../core/api/authApi";
import { getCurrentKeys } from "../../../../core/api/authApi";
import { ecdhSharedSecret, deriveWrappingKey } from "../../../../utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmDecrypt } from "../../../../utils/crypto/symmetric";
import { getAuthHeaders } from "../../../../core/api/authApi";
import { useAppState } from "../../state";
import { ub64 } from "../../../../../utils/utils";
import { ub64 } from "../../../../utils/utils";
import { useImmer } from "use-immer";
import { createPortal } from "react-dom";
import { MessageReactions } from "./MessageReactions";
interface MessageReactionsProps {
reactions?: Reaction[];
onReactionClick: (emoji: string) => void;
messageId?: number; // Add messageId to ensure unique keys
}
function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) {
const { user } = useAppState();
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
const [animatingReactions, setAnimatingReactions] = useState<Set<string>>(new Set());
const [isVisible, setIsVisible] = useState(false);
// Handle reactions with animation
useEffect(() => {
if (!reactions || reactions.length === 0) {
// If we have visible reactions, animate them out
if (visibleReactions.length > 0) {
visibleReactions.forEach(reaction => {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
});
// After animation completes, hide the component
setTimeout(() => {
setVisibleReactions([]);
setAnimatingReactions(new Set());
setIsVisible(false);
}, 200);
} else {
// No visible reactions, hide immediately
setIsVisible(false);
}
return;
}
// Show the component when we have reactions
setIsVisible(true);
// Deduplicate reactions by emoji (safety measure)
const uniqueReactions = reactions.reduce((acc, reaction) => {
const existing = acc.find(r => r.emoji === reaction.emoji);
if (existing) {
// Keep the one with the higher count
if (reaction.count > existing.count) {
acc[acc.indexOf(existing)] = reaction;
}
} else {
acc.push(reaction);
}
return acc;
}, [] as Reaction[]);
// Animate out removed reactions
visibleReactions.forEach(reaction => {
if (!uniqueReactions.some(r => r.emoji === reaction.emoji)) {
setAnimatingReactions(prev => new Set(prev).add(reaction.emoji));
setTimeout(() => {
setVisibleReactions(prev => prev.filter(r => r.emoji !== reaction.emoji));
setAnimatingReactions(prev => {
const newSet = new Set(prev);
newSet.delete(reaction.emoji);
return newSet;
});
}, 200);
}
});
// Update existing reactions and add new ones
setVisibleReactions(prev => {
const updated = [...prev];
// Update existing reactions
uniqueReactions.forEach(reaction => {
const existingIndex = updated.findIndex(r => r.emoji === reaction.emoji);
if (existingIndex !== -1) {
updated[existingIndex] = reaction;
} else {
// Add new reaction only if it doesn't already exist
if (!updated.some(r => r.emoji === reaction.emoji)) {
updated.push(reaction);
}
}
});
return updated;
});
}, [reactions]);
// Don't render if not visible
if (!isVisible) {
return null;
}
return (
<div className="message-reactions">
{visibleReactions.map((reaction, index) => {
const hasUserReacted = reaction.users.some(u => u.id === user.currentUser?.id);
const isAnimating = animatingReactions.has(reaction.emoji);
return (
<button
key={`${messageId || 'unknown'}-${reaction.emoji}-${reaction.count}-${index}`}
className={`reaction-button ${hasUserReacted ? "reacted" : ""} ${isAnimating ? "removing" : ""}`}
onClick={() => onReactionClick(reaction.emoji)}
title={reaction.users.map(u => u.username).join(", ")}
>
<span className="reaction-emoji">{reaction.emoji}</span>
<span className="reaction-count">{reaction.count}</span>
</button>
);
})}
</div>
);
}
interface MessageProps {
message: MessageType;
@@ -375,7 +489,7 @@ export function Message({ message, isAuthor, onProfileClick, onContextMenu, onRe
</mdui-list>
)}
<MessageReactions
<Reactions
reactions={message.reactions}
onReactionClick={(emoji) => onReactionClick?.(message.id, emoji)}
messageId={message.id}
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "../../../../../core/types";
import type { Message, Size2D } from "../../../../core/types";
import { EmojiMenu } from "./EmojiMenu";
interface MessageContextMenuProps {
@@ -1,13 +1,13 @@
import { useState, useEffect, useRef } from "react";
import { useAppState } from "../../state";
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
import { setGlobalMessageHandler } from "../../../../../core/websocket";
import type { Message, WebSocketMessage } from "../../../../../core/types";
import defaultAvatar from "../../../../../images/default-avatar.png";
import AnimatedOpacity from "../core/animations/AnimatedOpacity";
import type { DMPanel } from "../../panels/DMPanel";
import { setGlobalMessageHandler } from "../../../../core/websocket";
import type { Message, WebSocketMessage } from "../../../../core/types";
import defaultAvatar from "../../../../images/default-avatar.png";
import AnimatedOpacity from "../../../../core/components/animations/AnimatedOpacity";
import type { DMPanel } from "./panels/DMPanel";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
@@ -1,8 +1,8 @@
import type { DialogProps } from "../../../../../core/types";
import type { UserProfile } from "../../../../../core/types";
import { MaterialDialog } from "../core/Dialog";
import { formatTime } from "../../../../../utils/utils";
import defaultAvatar from "../../../../../images/default-avatar.png";
import type { DialogProps } from "../../../../core/types";
import type { UserProfile } from "../../../../core/types";
import { MaterialDialog } from "../../../../core/components/Dialog";
import { formatTime } from "../../../../utils/utils";
import defaultAvatar from "../../../../images/default-avatar.png";
interface UserProfileDialogProps extends DialogProps {
userProfile: UserProfile | null;
@@ -6,9 +6,9 @@ import {
sendDmWithFiles,
editDmEnvelope,
deleteDmEnvelope
} from "../../../../core/api/dmApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../core/types";
import type { UserState } from "../state";
} from "../../../../../core/api/dmApi";
import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "../../../../../core/types";
import type { UserState } from "../../../state";
export interface DMPanelData {
userId: number;
@@ -1,5 +1,5 @@
import type { Message, WebSocketMessage } from "../../../../core/types";
import type { UserState } from "../state";
import type { Message, WebSocketMessage } from "../../../../../core/types";
import type { UserState } from "../../../state";
export interface MessagePanelState {
id: string;
@@ -1,9 +1,9 @@
import { MessagePanel } from "./MessagePanel";
import { API_BASE_URL } from "../../../../core/config";
import { getAuthHeaders } from "../../../../core/api/authApi";
import { request } from "../../../../core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../core/types";
import type { UserState } from "../state";
import { API_BASE_URL } from "../../../../../core/config";
import { getAuthHeaders } from "../../../../../core/api/authApi";
import { request } from "../../../../../core/websocket";
import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "../../../../../core/types";
import type { UserState } from "../../../state";
export class PublicChatPanel extends MessagePanel {
private messagesLoaded: boolean = false;
-362
View File
@@ -1,362 +0,0 @@
import { create } from "zustand";
import type { Message, User } from "../../../core/types";
import { request } from "../../../core/websocket";
import { MessagePanel } from "./panels/MessagePanel";
import { PublicChatPanel } from "./panels/PublicChatPanel";
import { DMPanel, type DMPanelData } from "./panels/DMPanel";
import { getAuthHeaders } from "../../../core/api/authApi";
import { restoreKeys } from "../../../core/api/authApi";
import { API_BASE_URL } from "../../../core/config";
import { initialize, subscribe, startElectronReceiver, isSupported } from "../../../core/push-notifications/push-notifications";
import { isElectron } from "../../../core/electron/electron";
export type ChatTabs = "chats" | "channels" | "contacts" | "dms"
interface ActiveDM {
userId: number;
username: string;
publicKey: string | null
}
interface ChatState {
messages: Message[];
currentChat: string;
activeTab: ChatTabs;
dmUsers: User[];
activeDm: ActiveDM | null;
isSwitching: boolean;
setIsSwitching: (value: boolean) => void;
activePanel: MessagePanel | null;
publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null;
}
export interface UserState {
currentUser: User | null;
authToken: string | null;
}
interface AppState {
// Chat state
chat: ChatState;
addMessage: (message: Message) => void;
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => void;
removeMessage: (messageId: number) => void;
setCurrentChat: (chat: string) => void;
setActiveTab: (tab: ChatState["activeTab"]) => void;
setDmUsers: (users: User[]) => void;
setActiveDm: (dm: ChatState["activeDm"]) => void;
clearMessages: () => void;
setActivePanel: (panel: MessagePanel | null) => void;
setPendingPanel: (panel: MessagePanel | null) => void;
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
// User state
user: UserState;
setUser: (token: string, user: User) => void;
logout: () => void;
restoreUserFromStorage: () => Promise<void>;
}
export const useAppState = create<AppState>((set, get) => ({
// Chat state
chat: {
messages: [],
currentChat: "Общий чат",
activeTab: "chats",
dmUsers: [],
activeDm: null,
isSwitching: false,
setIsSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isSwitching: value
}
})),
activePanel: null,
publicChatPanel: null,
dmPanel: null,
pendingPanel: null
},
addMessage: (message: Message) => set((state) => {
// Check if message already exists to prevent duplicates
const messageExists = state.chat.messages.some(msg => msg.id === message.id);
if (messageExists) {
return state; // Return unchanged state if message already exists
}
return {
chat: {
...state.chat,
messages: [...state.chat.messages, message]
}
};
}),
updateMessage: (messageId: number, updatedMessage: Partial<Message>) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.map(msg =>
msg.id === messageId ? { ...msg, ...updatedMessage } : msg
)
}
})),
removeMessage: (messageId: number) => set((state) => ({
chat: {
...state.chat,
messages: state.chat.messages.filter(msg => msg.id !== messageId)
}
})),
clearMessages: () => set((state) => ({
chat: {
...state.chat,
messages: []
}
})),
setCurrentChat: (chat: string) => set((state) => ({
chat: {
...state.chat,
currentChat: chat
}
})),
setActiveTab: (tab: ChatState["activeTab"]) => set((state) => ({
chat: {
...state.chat,
activeTab: tab
}
})),
setDmUsers: (users: User[]) => set((state) => ({
chat: {
...state.chat,
dmUsers: users
}
})),
setActiveDm: (dm: ChatState["activeDm"]) => set((state) => ({
chat: {
...state.chat,
activeDm: dm
}
})),
// User state
user: {
currentUser: null,
authToken: null
},
setUser: (token: string, user: User) => {
set(() => ({
user: {
currentUser: user,
authToken: token
}
}));
// Store credentials in localStorage
try {
localStorage.setItem('authToken', token);
localStorage.setItem('currentUser', JSON.stringify(user));
} catch (error) {
console.error('Failed to store credentials in localStorage:', error);
}
try {
request({
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
}).then(() => {
console.log("Ping succeeded")
})
} catch {}
},
logout: () => {
// Clear localStorage
try {
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
} catch (error) {
console.error('Failed to clear localStorage:', error);
}
set(() => ({
user: {
currentUser: null,
authToken: null
}
}));
},
restoreUserFromStorage: async () => {
try {
const token = localStorage.getItem('authToken');
if (token) {
const response = await fetch(`${API_BASE_URL}/user/profile`, {
headers: getAuthHeaders(token)
});
if (response.ok) {
const user: User = await response.json();
restoreKeys();
set(() => ({
user: {
currentUser: user,
authToken: token
}
}));
try {
request({
type: "ping",
credentials: {
scheme: "Bearer",
credentials: token
},
data: {}
}).then(() => {
console.log("Ping succeeded")
})
} catch {}
// Initialize notifications after successful credential restoration
try {
if (isSupported()) {
const initialized = await initialize();
if (initialized) {
await subscribe(token);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
}
}
}
} catch (e) {
console.error("Notification setup failed (restored):", e);
}
} else {
throw new Error("Unable to authenticate");
}
}
} catch (error) {
console.error('Failed to restore user from localStorage:', error);
// Clear invalid data
localStorage.removeItem('authToken');
localStorage.removeItem('currentUser');
}
},
// Panel management
setActivePanel: (panel: MessagePanel | null) => set((state) => ({
chat: {
...state.chat,
activePanel: panel
}
})),
// Stash a panel to be applied after switch-out animation ends
setPendingPanel: (panel: MessagePanel | null) => set((state) => ({
chat: {
...state.chat,
pendingPanel: panel
}
})),
// Apply pending panel atomically and update related fields
applyPendingPanel: () => set((state) => ({
chat: {
...state.chat,
activePanel: state.chat.pendingPanel || state.chat.activePanel,
// when switching to public chat, keep reference if type matches
publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel)
? (state.chat.pendingPanel as PublicChatPanel)
: state.chat.publicChatPanel,
dmPanel: (state.chat.pendingPanel instanceof DMPanel)
? (state.chat.pendingPanel as DMPanel)
: state.chat.dmPanel,
// update currentChat from panel title if available
currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat,
pendingPanel: null
}
})),
switchToPublicChat: async (chatName: string) => {
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
chat.setIsSwitching(true);
// Create or get public chat panel
let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) {
publicChatPanel = new PublicChatPanel(chatName, user);
} else {
publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken);
// Reset messages for the new chat
publicChatPanel.clearMessages();
}
// Activate panel
await publicChatPanel.activate();
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
...state.chat,
pendingPanel: publicChatPanel,
activeTab: "chats"
}
}));
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
},
switchToDM: async (dmData: DMPanelData) => {
const { user, chat } = get();
if (!user.authToken) return;
// Start chat switching animation
chat.setIsSwitching(true);
// Create or get DM panel
let dmPanel = chat.dmPanel;
if (!dmPanel) {
dmPanel = new DMPanel(user);
} else {
dmPanel.setAuthToken(user.authToken);
// Reset messages for the new DM
dmPanel.clearMessages();
}
// Set DM data
dmPanel.setDMData(dmData);
// Activate panel
await dmPanel.activate();
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
...state.chat,
pendingPanel: dmPanel,
activeDm: {
userId: dmData.userId,
username: dmData.username,
publicKey: dmData.publicKey
},
activeTab: "dms"
}
}));
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
}
}));