mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Restructure
This commit is contained in:
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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();
|
||||
+2
-2
@@ -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();
|
||||
+4
-4
@@ -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";
|
||||
+3
-3
@@ -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();
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+5
-5
@@ -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";
|
||||
|
||||
+7
-7
@@ -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
-1
@@ -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;
|
||||
+125
-11
@@ -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
-1
@@ -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 {
|
||||
+6
-6
@@ -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;
|
||||
+5
-5
@@ -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;
|
||||
+3
-3
@@ -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;
|
||||
+2
-2
@@ -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;
|
||||
+5
-5
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}));
|
||||
Reference in New Issue
Block a user