Merge branch 'feature/emoji-panel'

This commit is contained in:
2025-10-05 19:53:57 +03:00
Unverified
21 changed files with 783 additions and 380 deletions
+1
View File
@@ -20,3 +20,4 @@ When working with this project, follow these rules:
- When you complete your task, remove unused imports if there are any.
- Follow DRY, SOLID, YAGNI and KISS principles.
- Do NOT use old, outdated or deprecated APIs and functions.
- Don't talk like a robot. Behave more like a human.
+213 -7
View File
@@ -77,6 +77,7 @@
display: flex;
width: 100%;
flex-direction: column;
overflow: hidden;
.chat-main {
flex-grow: 1;
@@ -84,6 +85,7 @@
flex-direction: column;
height: 100%;
position: relative;
overflow: hidden;
.chat-header {
padding: 16px;
@@ -536,11 +538,31 @@
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.buttons, .left-buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.left-buttons {
.emoji-btn {
margin: 10px;
color: $color-dark-on-surface-variant;
transition: color 0.2s ease;
flex-shrink: 0;
align-self: flex-end;
&:hover {
color: $color-dark-primary;
}
}
}
.message-input {
flex: 1;
padding: 20px 20px;
padding-right: 0;
padding: 20px 0;
border: none;
border-radius: 25px;
font-size: 1rem;
@@ -561,11 +583,6 @@
}
.buttons {
align-self: flex-end;
display: flex;
flex-direction: row;
align-items: center;
.send-btn {
margin: 10px;
width: 50px;
@@ -763,3 +780,192 @@
justify-content: center;
}
}
// Emoji Menu Styles
.emoji-menu {
$transition: cubic-bezier(0.4, 0, 0.2, 1);
background: $color-dark-surface-container;
border: 1px solid rgba($color-dark-outline-variant, 0.4);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(20px);
width: 320px;
height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
transform-origin: bottom left;
opacity: 0;
transform: translateY(30px);
transition: transform 0.25s $transition, opacity 0.25s $transition;
user-select: none;
&.open {
opacity: 1;
transform: translateY(0);
}
.emoji-menu-header {
background: $color-dark-surface-container-high;
border-bottom: 1px solid rgba($color-dark-outline-variant, 0.2);
position: sticky;
top: 0;
z-index: 1;
.emoji-category-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
padding: 8px;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container;
border-radius: 2px;
}
&::-webkit-scrollbar-thumb:hover {
background-color: $color-dark-surface-container-high;
}
.emoji-category-tab {
background: transparent;
border: none;
border-radius: 10px;
padding: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 1.2rem;
min-width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&:hover {
background: $color-dark-surface-container;
}
&.active {
background: $color-dark-primary-container;
color: $color-dark-on-primary-container;
transform: scale(1.05);
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $color-dark-primary-container;
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
}
&.active::before {
opacity: 1;
}
span {
position: relative;
z-index: 1;
}
}
}
}
.emoji-grid {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
scroll-behavior: smooth;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: $color-dark-surface-container-high;
border-radius: 3px;
}
.emoji-category-section {
.emoji-category-title {
position: sticky;
top: 0;
padding-top: 5px;
padding-bottom: 5px;
padding-left: 12px;
padding-right: 12px;
font-size: 0.85rem;
font-weight: 600;
color: $color-dark-on-surface-variant;
z-index: 2;
margin: 0;
backdrop-filter: blur(10px);
}
.emoji-category-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 2px;
padding: 8px;
}
}
.emoji-item {
$size: 30px;
background: transparent;
border: none;
border-radius: 6px;
padding: 5px;
cursor: pointer;
transition: all 0.15s ease;
font-size: $size;
width: $size;
height: $size;
box-sizing: content-box;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: $color-dark-surface-container-high;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
.emoji-empty-state {
padding: 20px;
text-align: center;
color: $color-dark-on-surface-variant;
font-size: 0.9rem;
}
}
@@ -1,5 +1,5 @@
import { PRODUCT_NAME } from "../../../core/config";
import { useProfile } from "../../hooks/useProfile";
import useProfile from "../../hooks/useProfile";
import defaultAvatar from "../../../resources/images/default-avatar.png";
import { useState } from "react";
import { ProfileDialog } from "../profile/ProfileDialog";
@@ -1,10 +1,11 @@
import { useState, useEffect } from "react";
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 { useImmer } from "use-immer";
import { EmojiMenu } from "./EmojiMenu";
interface ChatInputWrapperProps {
onSendMessage: (message: string, files: File[]) => void;
@@ -18,6 +19,7 @@ interface ChatInputWrapperProps {
onClearEdit?: () => void;
onCloseEdit?: () => void;
onProvideFileAdder?: (adder: (files: File[]) => void) => void;
messagePanelRef?: React.RefObject<HTMLDivElement | null>;
}
export function ChatInputWrapper(
@@ -32,13 +34,17 @@ export function ChatInputWrapper(
editVisible = false,
onClearEdit,
onCloseEdit,
onProvideFileAdder
onProvideFileAdder,
messagePanelRef
}: ChatInputWrapperProps
) {
const [message, setMessage] = useState("");
const [selectedFiles, setSelectedFiles] = useImmer<File[]>([]);
const [attachmentsVisible, setAttachmentsVisible] = useState(false);
const [errorOpen, setErrorOpen] = useState(false);
const [emojiMenuOpen, setEmojiMenuOpen] = useState(false);
const [emojiMenuPosition, setEmojiMenuPosition] = useState({ x: 0, y: 0 });
const chatInputWrapperRef = useRef<HTMLDivElement>(null);
// Expose a way for parent to programmatically add files
useEffect(() => {
@@ -60,7 +66,26 @@ export function ChatInputWrapper(
setAttachmentsVisible(selectedFiles.length > 0);
}, [selectedFiles]);
const handleSubmit = async (e: React.FormEvent | Event) => {
function handleEmojiButtonClick() {
if (chatInputWrapperRef.current && messagePanelRef?.current) {
const inputRect = chatInputWrapperRef.current.getBoundingClientRect();
const panelRect = messagePanelRef.current.getBoundingClientRect();
// Position menu 10px from message panel edge and 10px above the chat input
// The animation will start 30px below this position
setEmojiMenuPosition({
x: panelRect.left + 10, // 10px from message panel edge
y: window.innerHeight - inputRect.top + 10 // 10px above the top of chat input
});
setEmojiMenuOpen(true);
}
};
function handleEmojiSelect(emoji: string) {
setMessage(prev => prev + emoji);
};
async function handleSubmit(e: React.FormEvent | Event) {
e.preventDefault();
const hasText = Boolean(message.trim());
const hasFiles = selectedFiles.length > 0;
@@ -95,7 +120,7 @@ export function ChatInputWrapper(
}
return (
<div className="chat-input-wrapper">
<div className="chat-input-wrapper" ref={chatInputWrapperRef}>
<form className="input-group" id="message-form" onSubmit={handleSubmit}>
<AnimatedHeight visible={editVisible} onFinish={onCloseEdit}>
{editingMessage && (
@@ -150,6 +175,9 @@ export function ChatInputWrapper(
)}
</AnimatedHeight>
<div className="chat-input">
<div className="left-buttons">
<mdui-button-icon icon="mood" onClick={handleEmojiButtonClick} className="emoji-btn"></mdui-button-icon>
</div>
<RichTextArea
className="message-input"
id="message-input"
@@ -172,6 +200,13 @@ export function ChatInputWrapper(
<div>Общий размер вложений превышает 4 ГБ.</div>
<mdui-button slot="action" onClick={() => setErrorOpen(false)}>Закрыть</mdui-button>
</MaterialDialog>
<EmojiMenu
isOpen={emojiMenuOpen}
onClose={() => setEmojiMenuOpen(false)}
onEmojiSelect={handleEmojiSelect}
position={emojiMenuPosition}
/>
</div>
);
}
@@ -1,8 +1,8 @@
import { useChat } from "../../hooks/useChat";
import { useAppState } from "../../state";
import defaultAvatar from "../../../resources/images/default-avatar.png";
export function ChatMainHeader() {
const { currentChat } = useChat();
const { currentChat } = useAppState().chat;
return (
<div className="chat-header">
@@ -1,4 +1,3 @@
import { useChat } from "../../hooks/useChat";
import { Message } from "./Message";
import { useAppState } from "../../state";
import type { Message as MessageType } from "../../../core/types";
@@ -21,12 +20,10 @@ interface ChatMessagesProps {
dmRecipientPublicKey?: string;
}
export function ChatMessages({ messages: propMessages, children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { messages: hookMessages } = useChat();
export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) {
const { user } = useAppState();
// Use prop messages if provided, otherwise use hook messages
const messages = propMessages || hookMessages;
// Use prop messages (panels provide their own messages)
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
const [selectedUserProfile, setSelectedUserProfile] = useState<UserProfile | null>(null);
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
@@ -127,7 +124,7 @@ export function ChatMessages({ messages: propMessages, children, isDm = false, o
return (
<>
<div className="chat-messages" id="chat-messages">
{messages.map((message) => (
{messages.map((message: MessageType) => (
<Message
key={message.id}
message={message}
@@ -1,15 +1,11 @@
import { useChat } from "../../hooks/useChat";
import { useAppState } from "../../state";
export function ChatTabs() {
const { activeTab, setActiveTab, setCurrentChat } = useChat();
const handleChatClick = (chatName: string) => {
setCurrentChat(chatName);
};
const { chat, setActiveTab, switchToPublicChat } = useAppState();
return (
<div className="chat-tabs">
<mdui-tabs value={activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
<mdui-tabs value={chat.activeTab} full-width onChange={(e: any) => setActiveTab(e.detail.value)}>
<mdui-tab value="chats">
Чаты
</mdui-tab>
@@ -29,7 +25,7 @@ export function ChatTabs() {
headline="Общий чат"
description="Вы: Последнее сообщение"
id="chat-list-chat-1"
onClick={() => handleChatClick("Общий чат")}
onClick={async () => await switchToPublicChat("Общий чат")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
@@ -38,7 +34,7 @@ export function ChatTabs() {
headline="Общий чат 2"
description="Вы: Последнее сообщение"
id="chat-list-chat-2"
onClick={() => handleChatClick("Общий чат 2")}
onClick={async () => await switchToPublicChat("Общий чат 2")}
style={{ cursor: "pointer" }}
>
<img src="./src/resources/images/default-avatar.png" alt="" slot="icon" />
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { useDM } from "../../hooks/useDM";
import { useDM, type DMUser } from "../../hooks/useDM";
import { useAppState } from "../../state";
import { fetchUserPublicKey } from "../../../api/dmApi";
import defaultAvatar from "../../../resources/images/default-avatar.png";
@@ -34,7 +34,7 @@ export function DMUsersList() {
);
}
const handleUserClick = async (user: any) => {
async function handleUserClick(user: DMUser) {
if (!user.publicKey) {
// Get public key if not already loaded
const authToken = useAppState.getState().user.authToken;
@@ -60,7 +60,7 @@ export function DMUsersList() {
return (
<mdui-list>
{dmUsers.map((user) => (
{dmUsers.map((user: DMUser) => (
<mdui-list-item
key={user.id}
headline={user.username}
@@ -0,0 +1,179 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "../../../core/types";
interface EmojiMenuProps {
isOpen: boolean;
onClose: () => void;
onEmojiSelect: (emoji: string) => void;
position: Size2D;
}
export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) {
const [activeCategory, setActiveCategory] = useState("recent");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const categoryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const tabsRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
useEffect(() => {
if (isOpen) {
setRecentEmojis(getRecentEmojis());
}
}, [isOpen]);
const handleScroll = useCallback(() => {
if (!scrollRef.current) return;
// Find which category is currently visible
for (const [categoryName, element] of categoryRefs.current) {
if (element) {
const rect = element.getBoundingClientRect();
const containerRect = scrollRef.current.getBoundingClientRect();
// Check if category header is in view
if (rect.top <= containerRect.top + 50 && rect.bottom > containerRect.top + 50) {
if (activeCategory !== categoryName) {
setActiveCategory(categoryName);
scrollTabIntoView(categoryName);
}
break;
}
}
}
}, [activeCategory]);
function scrollToCategory(categoryName: string) {
const element = categoryRefs.current.get(categoryName);
if (element && scrollRef.current) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
function scrollTabIntoView(categoryName: string) {
const tabElement = tabRefs.current.get(categoryName);
if (tabElement && tabsRef.current) {
const tabsRect = tabsRef.current.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is outside the visible area
if (tabRect.left < tabsRect.left || tabRect.right > tabsRect.right) {
tabElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center'
});
}
}
}
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [isOpen, onClose]);
function handleEmojiClick(emoji: string) {
addRecentEmoji(emoji);
onEmojiSelect(emoji);
onClose();
};
return (
<div
ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""}`}
style={{
position: "fixed",
left: position.x,
bottom: position.y,
zIndex: 1000,
pointerEvents: isOpen ? "auto" : "none"
}}
>
<div className="emoji-menu-header">
<div ref={tabsRef} className="emoji-category-tabs">
{EMOJI_CATEGORIES.map((category) => (
<button
key={category.name}
ref={(el) => {
if (el) tabRefs.current.set(category.name, el);
}}
className={`emoji-category-tab ${activeCategory === category.name ? "active" : ""}`}
onClick={() => scrollToCategory(category.name)}
title={category.name}
>
<span>{category.icon}</span>
</button>
))}
</div>
</div>
<div
ref={scrollRef}
className="emoji-grid"
onScroll={handleScroll}
>
{EMOJI_CATEGORIES.map((category) => {
const emojis = category.name === "recent" ? recentEmojis : category.emojis;
return (
<div
key={category.name}
ref={(el) => {
if (el) categoryRefs.current.set(category.name, el);
}}
className="emoji-category-section"
>
<h3 className="emoji-category-title">
{category.name.charAt(0).toUpperCase() + category.name.slice(1)}
</h3>
{emojis.length > 0 ? (
<div className="emoji-category-grid">
{emojis.map((emoji, index) => (
<button
key={`${category.name}-${index}`}
className="emoji-item"
onClick={() => handleEmojiClick(emoji)}
title={emoji}
>
{emoji}
</button>
))}
</div>
) : (
<div className="emoji-empty-state">
<span>No {category.name} emojis</span>
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -37,17 +37,16 @@ function BottomAppBar() {
function ChatTabs() {
const { chat, switchToTab, switchToPublicChat } = useAppState();
const { chat, setActiveTab, switchToPublicChat } = useAppState();
const { activeTab } = chat;
const handleChatClick = async (chatName: string) => {
async function handleChatClick(chatName: string) {
await switchToPublicChat(chatName);
};
}
const handleTabChange = async (e: FormEvent<Tabs>) => {
const tab = (e.target as Tabs).value as ChatTabs;
await switchToTab(tab);
};
function handleTabChange(e: FormEvent<Tabs>) {
setActiveTab((e.target as Tabs).value as ChatTabs);
}
return (
<div className="chat-tabs">
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useAppState } from "../../state";
import { MessagePanel, type MessagePanelState } from "../../panels/MessagePanel";
import { ChatMessages } from "./ChatMessages";
import { ChatInputWrapper } from "./ChatInputWrapper";
@@ -10,10 +11,11 @@ import type { DMPanel } from "../../panels/DMPanel";
interface MessagePanelRendererProps {
panel: MessagePanel | null;
isChatSwitching: boolean;
}
export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRendererProps) {
export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
const { applyPendingPanel, chat } = useAppState();
const messagePanelRef = useRef<HTMLDivElement>(null);
const [panelState, setPanelState] = useState<MessagePanelState | null>(null);
const [switchIn, setSwitchIn] = useState(false);
const [switchOut, setSwitchOut] = useState(false);
@@ -24,6 +26,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
const [editVisible, setEditVisible] = useState(Boolean(editMessage));
const [pendingAction, setPendingAction] = useState<null | { type: "reply" | "edit"; message: Message }>(null);
// Drag & drop
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
@@ -55,31 +58,26 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
if (panel) {
setPanelState(panel.getState());
// Set up state change listener
const handleStateChange = (newState: MessagePanelState) => {
// Store the handler for cleanup
panel.onStateChange = (newState: MessagePanelState) => {
setPanelState(newState);
};
// Store the handler for cleanup
panel.onStateChange = handleStateChange;
// Set up WebSocket message handler for this panel
if (panel.handleWebSocketMessage) {
setGlobalMessageHandler(panel.handleWebSocketMessage);
}
} else {
setPanelState(null);
// Clear global message handler when no panel is active
setGlobalMessageHandler(null);
}
// Cleanup function
return () => {
if (panel) {
if (panel.onStateChange) {
panel.onStateChange = null;
}
// Call destroy to clean up pending timeouts
if (typeof panel.destroy === 'function') {
panel.destroy();
}
@@ -87,67 +85,70 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
};
}, [panel]);
// Handle chat switching animation
// Handle chat switching animation with event listeners
useEffect(() => {
console.log("🎬 [DEBUG] MessagePanelRenderer: isChatSwitching changed to:", isChatSwitching);
if (isChatSwitching) {
console.log("🎬 [DEBUG] MessagePanelRenderer: Starting switch-out animation");
if (chat.isSwitching) {
setSwitchOut(true);
setTimeout(() => {
console.log("🎬 [DEBUG] MessagePanelRenderer: Starting switch-in animation");
// Use animation event listeners instead of hardcoded delays
function handleAnimationEnd(event: Event) {
const animationEvent = event as AnimationEvent;
if (animationEvent.animationName === 'fadeOutUp') {
// Apply pending panel exactly at the boundary between animations
applyPendingPanel();
setSwitchOut(false);
setSwitchIn(true);
setTimeout(() => {
console.log("🎬 [DEBUG] MessagePanelRenderer: Ending switch-in animation");
} else if (animationEvent.animationName === 'fadeInDown') {
setSwitchIn(false);
}, 200);
}, 250);
// End the chat switching state
chat.setIsSwitching(false);
}
}, [isChatSwitching]);
};
// Scroll to bottom when messages change
// Add event listener to document to catch all animation events
document.addEventListener('animationend', handleAnimationEnd);
// Cleanup function
return () => {
document.removeEventListener('animationend', handleAnimationEnd);
};
}
}, [chat.isSwitching]);
// Load messages when panel changes and animation is not running
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [panelState?.messages]);
if (!chat.activePanel || chat.isSwitching || switchOut || switchIn) return;
if (!panel || !panelState) {
return (
<div className="chat-container">
<div className="chat-main" id="chat-inner">
<div className="chat-header">
<img src={defaultAvatar} alt="Avatar" className="chat-header-avatar" />
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">Выбор чата</h4>
<p>
<span className="online-status"></span>
Выберите чат, чтобы начать переписку
</p>
</div>
</div>
</div>
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите чат на боковой панели, чтобы начать переписку
</div>
</div>
</div>
</div>
);
const panelState = chat.activePanel.getState();
if (panelState.messages.length === 0 && !panelState.isLoading) {
chat.activePanel.loadMessages();
}
}, [chat.activePanel, chat.isSwitching, switchOut, switchIn]);
// Scroll to bottom when messages change, but only when no animation is running
useEffect(() => {
if (!panelState || chat.isSwitching || switchOut || switchIn || panelState.isLoading) return;
const el = messagesEndRef.current;
if (!el) return;
// Defer to next frame to ensure layout is stable
const id = requestAnimationFrame(() => {
el.scrollIntoView({ behavior: "smooth", block: "end" });
});
return () => cancelAnimationFrame(id);
}, [panelState?.messages, panelState?.isLoading, chat.isSwitching, switchOut, switchIn]);
return (
<div className={`chat-container ${switchIn ? "chat-switch-in" : ""} ${switchOut ? "chat-switch-out" : ""}`}>
<div
ref={messagePanelRef}
className="chat-main"
id="chat-inner"
onDragEnter={(e) => {
onDragEnter={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
@@ -155,20 +156,20 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
// Only show overlay when actual files are dragged
const hasFiles = Array.from(e.dataTransfer.types || []).includes("Files");
if (hasFiles) setIsDragging(true);
}}
onDragOver={(e) => {
} : undefined}
onDragOver={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
} : undefined}
onDragLeave={panel ? (e) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
if (dragCounterRef.current === 0) setIsDragging(false);
}}
onDrop={(e) => {
} : undefined}
onDrop={panel ? (e) => {
if (!e.dataTransfer) return;
e.preventDefault();
e.stopPropagation();
@@ -178,28 +179,34 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
}
setIsDragging(false);
dragCounterRef.current = 0;
}}>
} : undefined}>
<div className="chat-header">
<img
src={panelState.profilePicture || defaultAvatar}
src={panelState?.profilePicture || defaultAvatar}
alt="Avatar"
className="chat-header-avatar"
onClick={panel.handleProfileClick}
style={{ cursor: "pointer" }}
onClick={panel?.handleProfileClick}
style={{ cursor: panel ? "pointer" : "default" }}
/>
<div className="chat-header-info">
<div className="info-chat">
<h4 id="chat-name">{panelState.title}</h4>
<h4 id="chat-name">{panelState?.title || "Выбор чата"}</h4>
<p>
<span className={`online-status ${panelState.online ? "online" : "offline"}`}></span>
<span className={`online-status ${panelState?.online ? "online" : ""}`}></span>
{panelState ? (
<>
{panelState.online ? "Online" : "Offline"}
{panelState.isTyping && " • Typing..."}
</>
) : (
"Выберите чат, чтобы начать переписку"
)}
</p>
</div>
</div>
</div>
{panelState.isLoading ? (
{panelState?.isLoading ? (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
@@ -211,7 +218,7 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
Загрузка сообщений...
</div>
</div>
): (
) : panelState && panel ? (
<ChatMessages
messages={panelState.messages}
isDm={panel.isDm()}
@@ -237,8 +244,22 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
>
<div ref={messagesEndRef} />
</ChatMessages>
) : (
<div className="chat-messages" id="chat-messages">
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
color: "var(--mdui-color-on-surface-variant)"
}}>
Выберите чат на боковой панели, чтобы начать переписку
</div>
</div>
)}
{panel && (
<>
<AnimatedOpacity
visible={isDragging}
className="file-overlay"
@@ -290,7 +311,10 @@ export function MessagePanelRenderer({ panel, isChatSwitching }: MessagePanelRen
}
}}
onProvideFileAdder={(adder) => { addFilesRef.current = adder; }}
messagePanelRef={messagePanelRef}
/>
</>
)}
</div>
</div>
);
@@ -4,10 +4,5 @@ import { MessagePanelRenderer } from "./MessagePanelRenderer";
export function RightPanel() {
const { chat } = useAppState();
return (
<MessagePanelRenderer
panel={chat.activePanel}
isChatSwitching={chat.isChatSwitching}
/>
);
return <MessagePanelRenderer panel={chat.activePanel} />
}
@@ -0,0 +1,100 @@
export interface EmojiCategory {
name: string;
icon: string;
emojis: string[];
}
export const EMOJI_CATEGORIES: EmojiCategory[] = [
{
name: "recent",
icon: "🕒",
emojis: []
},
{
name: "smileys",
icon: "😀",
emojis: [
"😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "😋", "😛", "😜", "🤪", "😝", "🤑", "🤗", "🤭", "🤫", "🤔", "🤐", "🤨", "😐", "😑", "😶", "😏", "😒", "🙄", "😬", "🤥", "😔", "😪", "🤤", "😴", "😷", "🤒", "🤕", "🤢", "🤮", "🤧", "🥵", "🥶", "🥴", "😵", "🤯", "🤠", "🥳", "😎", "🤓", "🧐", "😕", "😟", "🙁", "☹️", "😮", "😯", "😲", "😳", "🥺", "😦", "😧", "😨", "😰", "😥", "😢", "😭", "😱", "😖", "😣", "😞", "😓", "😩", "😫", "🥱", "😤", "😡", "😠", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖", "😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾"
]
},
{
name: "people",
icon: "👋",
emojis: [
"👋", "🤚", "🖐", "✋", "🖖", "👌", "🤏", "✌️", "🤞", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", "👍", "👎", "👊", "✊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦿", "🦵", "🦶", "👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁", "👅", "👄", "💋", "🩸", "👶", "🧒", "👦", "👧", "🧑", "👨", "👩", "🧓", "👴", "👵", "👱", "🧔", "👲", "🧕", "👳", "👮", "👷", "💂", "🕵️", "👩‍⚕️", "👨‍⚕️", "👩‍🌾", "👨‍🌾", "👩‍🍳", "👨‍🍳", "👩‍🎓", "👨‍🎓", "👩‍🎤", "👨‍🎤", "👩‍🏫", "👨‍🏫", "👩‍🏭", "👨‍🏭", "👩‍💻", "👨‍💻", "👩‍💼", "👨‍💼", "👩‍🔧", "👨‍🔧", "👩‍🔬", "👨‍🔬", "👩‍🎨", "👨‍🎨", "👩‍🚒", "👨‍🚒", "👩‍✈️", "👨‍✈️", "👩‍🚀", "👨‍🚀", "👩‍⚖️", "👨‍⚖️", "👰", "🤵", "👸", "🤴", "🦸", "🦹", "🤶", "🎅", "🧙", "🧚", "🧛", "🧜", "🧝", "🧞", "🧟", "💆", "💇", "🚶", "🏃", "💃", "🕺", "🕴", "👯", "🧘", "🛀", "🛌", "👭", "👫", "👬", "💏", "💑", "👪"
]
},
{
name: "animals",
icon: "🐶",
emojis: [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐽", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐜", "🦟", "🦗", "🕷", "🕸", "🦂", "🐢", "🐍", "🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠", "🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", "🦧", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌", "🐕", "🐩", "🦮", "🐕‍🦺", "🐈", "🐓", "🦃", "🦚", "🦜", "🦢", "🦩", "🕊", "🐇", "🦝", "🦨", "🦡", "🦦", "🦥", "🐁", "🐀", "🐿", "🦔"
]
},
{
name: "food",
icon: "🍎",
emojis: [
"🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", "🥦", "🥬", "🥒", "🌶", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔", "🍟", "🍕", "🫓", "🥙", "🌮", "🌯", "🫔", "🥗", "🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡", "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬", "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "☕", "🫖", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷", "🥃", "🍸", "🍹", "🧉", "🍾"
]
},
{
name: "travel",
icon: "🚗",
emojis: [
"🚗", "🚕", "🚙", "🚌", "🚎", "🏎", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍", "🛵", "🚲", "🛴", "🛹", "🛼", "🚁", "✈️", "🛩", "🛫", "🛬", "🪂", "💺", "🚀", "🛸", "🚉", "🚊", "🚝", "🚞", "🚋", "🚃", "🚋", "🚋", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🎢", "🎡", "🎠", "⛵", "🛥", "🚤", "⛴", "🛳", "🚢", "⚓", "🚧", "⛽", "🚨", "🚥", "🚦", "🛑", "🚏", "🗺", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟", "🎡", "🎢", "🎠", "⛲", "⛱", "🏖", "🏝", "🏔", "⛰", "🌋", "🗻", "🏕", "⛺", "🏠", "🏡", "🏘", "🏚", "🏗", "🏭", "🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩", "💒", "🏛", "⛪", "🕌", "🛕", "🕍", "🕋", "⛩", "🛤", "🛣", "🗾", "🎑", "🏞", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆", "🏙", "🌃", "🌌", "🌉", "🌁"
]
},
{
name: "activities",
icon: "⚽",
emojis: [
"⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🎱", "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛷", "⛸", "🥌", "🎿", "⛷", "🏂", "🪂", "🏋️‍♀️", "🏋️‍♂️", "🤼‍♀️", "🤼‍♂️", "🤸‍♀️", "🤸‍♂️", "⛹️‍♀️", "⛹️‍♂️", "🤺", "🤾‍♀️", "🤾‍♂️", "🏌️‍♀️", "🏌️‍♂️", "🏇", "🧘‍♀️", "🧘‍♂️", "🏄‍♀️", "🏄‍♂️", "🏊‍♀️", "🏊‍♂️", "🤽‍♀️", "🤽‍♂️", "🚣‍♀️", "🚣‍♂️", "🧗‍♀️", "🧗‍♂️", "🚵‍♀️", "🚵‍♂️", "🚴‍♀️", "🚴‍♂️", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖", "🏵", "🎗", "🎫", "🎟", "🎪", "🤹", "🤹‍♀️", "🤹‍♂️", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧", "🎼", "🎹", "🥁", "🎷", "🎺", "🎸", "🪕", "🎻", "🎲", "♠️", "♥️", "♦️", "♣️", "♟", "🃏", "🀄", "🎴", "🎯", "🎳", "🎮", "🎰", "🧩"
]
},
{
name: "objects",
icon: "📱",
emojis: [
"📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌", "💻", "🖥", "🖨", "⌨️", "🖱", "🖲", "💽", "💾", "💿", "📀", "🧮", "🎥", "📽", "📸", "📹", "📷", "🔍", "🔎", "🕯", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", "📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰", "🗞", "📑", "🔖", "🏷", "💰", "💴", "💵", "💶", "💷", "💸", "💳", "🧾", "💹", "💱", "💲", "✉️", "📧", "📨", "📩", "📤", "📥", "📦", "📫", "📪", "📬", "📭", "📮", "🗳", "✏️", "✒️", "🖋", "🖊", "🖌", "🖍", "📝", "💼", "📁", "📂", "🗂", "📅", "📆", "🗒", "🗓", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇", "📏", "📐", "✂️", "🗃", "🗄", "🗑", "🔒", "🔓", "🔏", "🔐", "🔑", "🗝", "🔨", "⛏", "⚒", "🛠", "🗡", "⚔️", "🔫", "🪃", "🏹", "🛡", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜", "⚖️", "🦯", "🔗", "⛓", "🧰", "🧲", "⚗️", "🧪", "🧫", "🧬", "🔬", "🔭", "📡", "💉", "💊", "🩹", "🩺", "🚪", "🛏", "🛋", "🚽", "🚿", "🛁", "🛀", "🧴", "🧷", "🧹", "🧺", "🧻", "🚰", "🚰", "🪒", "🧽", "🧯", "🛒"
]
},
{
name: "symbols",
icon: "❤️",
emojis: [
"❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "☮️", "✝️", "☪️", "🕉", "☸️", "✡️", "🔯", "🕎", "☯️", "☦️", "🛐", "⛎", "♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓", "🆔", "⚛️", "🉑", "☢️", "☣️", "📴", "📳", "🈶", "🈚", "🈸", "🈺", "🈷️", "✴️", "🆚", "💮", "🉐", "㊙️", "㊗️", "🈴", "🈵", "🈹", "🈲", "🅰️", "🅱️", "🆎", "🅾️", "🆘", "❌", "⭕", "🛑", "⛔", "📛", "🚫", "💯", "💢", "♨️", "🚷", "🚯", "🚳", "🚱", "🔞", "📵", "🚭", "❗", "❕", "❓", "❔", "‼️", "⁉️", "🔅", "🔆", "〽️", "⚠️", "🚸", "🔱", "⚜️", "🔰", "♻️", "✅", "🈯", "💹", "❇️", "✳️", "❎", "🌐", "💠", "Ⓜ️", "🌀", "💤", "🏧", "🚾", "♿", "🅿️", "🛗", "🈳", "🈂️", "🛂", "🛃", "🛄", "🛅", "🚹", "🚺", "🚼", "⚧", "🚻", "🚮", "🎦", "📶", "🈁", "🔣", "️", "🔤", "🔡", "🔠", "🆖", "🆗", "🆙", "🆒", "🆕", "🆓", "0️⃣", "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"
]
},
{
name: "flags",
icon: "🏳️",
emojis: [
"🏳️", "🏴", "🏁", "🚩", "🏳️‍🌈", "🏳️‍⚧️", "🏴‍☠️", "🇦🇨", "🇦🇩", "🇦🇪", "🇦🇫", "🇦🇬", "🇦🇮", "🇦🇱", "🇦🇲", "🇦🇴", "🇦🇶", "🇦🇷", "🇦🇸", "🇦🇹", "🇦🇺", "🇦🇼", "🇦🇽", "🇦🇿", "🇧🇦", "🇧🇧", "🇧🇩", "🇧🇪", "🇧🇫", "🇧🇬", "🇧🇭", "🇧🇮", "🇧🇯", "🇧🇱", "🇧🇲", "🇧🇳", "🇧🇴", "🇧🇶", "🇧🇷", "🇧🇸", "🇧🇹", "🇧🇻", "🇧🇼", "🇧🇾", "🇧🇿", "🇨🇦", "🇨🇨", "🇨🇩", "🇨🇫", "🇨🇬", "🇨🇭", "🇨🇮", "🇨🇰", "🇨🇱", "🇨🇲", "🇨🇳", "🇨🇴", "🇨🇵", "🇨🇷", "🇨🇺", "🇨🇻", "🇨🇼", "🇨🇽", "🇨🇾", "🇨🇿", "🇩🇪", "🇩🇬", "🇩🇯", "🇩🇰", "🇩🇲", "🇩🇴", "🇩🇿", "🇪🇦", "🇪🇨", "🇪🇪", "🇪🇬", "🇪🇭", "🇪🇷", "🇪🇸", "🇪🇹", "🇪🇺", "🇫🇮", "🇫🇯", "🇫🇰", "🇫🇲", "🇫🇴", "🇫🇷", "🇬🇦", "🇬🇧", "🇬🇩", "🇬🇪", "🇬🇫", "🇬🇬", "🇬🇭", "🇬🇮", "🇬🇱", "🇬🇲", "🇬🇳", "🇬🇵", "🇬🇶", "🇬🇷", "🇬🇸", "🇬🇹", "🇬🇺", "🇬🇼", "🇬🇾", "🇭🇰", "🇭🇲", "🇭🇳", "🇭🇷", "🇭🇹", "🇭🇺", "🇮🇨", "🇮🇩", "🇮🇪", "🇮🇱", "🇮🇲", "🇮🇳", "🇮🇴", "🇮🇶", "🇮🇷", "🇮🇸", "🇮🇹", "🇯🇪", "🇯🇲", "🇯🇴", "🇯🇵", "🇰🇪", "🇰🇬", "🇰🇭", "🇰🇮", "🇰🇲", "🇰🇳", "🇰🇵", "🇰🇷", "🇰🇼", "🇰🇾", "🇰🇿", "🇱🇦", "🇱🇧", "🇱🇨", "🇱🇮", "🇱🇰", "🇱🇷", "🇱🇸", "🇱🇹", "🇱🇺", "🇱🇻", "🇱🇾", "🇲🇦", "🇲🇨", "🇲🇩", "🇲🇪", "🇲🇫", "🇲🇬", "🇲🇭", "🇲🇰", "🇲🇱", "🇲🇲", "🇲🇳", "🇲🇴", "🇲🇵", "🇲🇶", "🇲🇷", "🇲🇸", "🇲🇹", "🇲🇺", "🇲🇻", "🇲🇼", "🇲🇽", "🇲🇾", "🇲🇿", "🇳🇦", "🇳🇨", "🇳🇪", "🇳🇫", "🇳🇬", "🇳🇮", "🇳🇱", "🇳🇴", "🇳🇵", "🇳🇷", "🇳🇺", "🇳🇿", "🇴🇲", "🇵🇦", "🇵🇪", "🇵🇫", "🇵🇬", "🇵🇭", "🇵🇰", "🇵🇱", "🇵🇲", "🇵🇳", "🇵🇷", "🇵🇸", "🇵🇹", "🇵🇼", "🇵🇾", "🇶🇦", "🇷🇪", "🇷🇴", "🇷🇸", "🇷🇺", "🇷🇼", "🇸🇦", "🇸🇧", "🇸🇨", "🇸🇩", "🇸🇪", "🇸🇬", "🇸🇭", "🇸🇮", "🇸🇯", "🇸🇰", "🇸🇱", "🇸🇲", "🇸🇳", "🇸🇴", "🇸🇷", "🇸🇸", "🇸🇹", "🇸🇻", "🇸🇽", "🇸🇾", "🇸🇿", "🇹🇦", "🇹🇨", "🇹🇩", "🇹🇫", "🇹🇬", "🇹🇭", "🇹🇯", "🇹🇰", "🇹🇱", "🇹🇲", "🇹🇳", "🇹🇴", "🇹🇷", "🇹🇹", "🇹🇻", "🇹🇼", "🇹🇿", "🇺🇦", "🇺🇬", "🇺🇲", "🇺🇸", "🇺🇾", "🇺🇿", "🇻🇦", "🇻🇨", "🇻🇪", "🇻🇬", "🇻🇮", "🇻🇳", "🇻🇺", "🇼🇫", "🇼🇸", "🇾🇪", "🇾🇹", "🇿🇦", "🇿🇲", "🇿🇼"
]
}
];
export const RECENT_EMOJIS_KEY = "recentEmojis";
export function getRecentEmojis(): string[] {
try {
const stored = localStorage.getItem(RECENT_EMOJIS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
export function addRecentEmoji(emoji: string): void {
try {
let recentEmojis = getRecentEmojis();
recentEmojis = recentEmojis.filter(e => e !== emoji);
recentEmojis.unshift(emoji);
recentEmojis = recentEmojis.slice(0, 50);
localStorage.setItem(RECENT_EMOJIS_KEY, JSON.stringify(recentEmojis));
} catch {
// Ignore localStorage errors
}
}
@@ -3,7 +3,7 @@ import defaultAvatar from "../../../resources/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 useProfile from "../../hooks/useProfile";
import { ImageCropper } from "./ImageCropper";
import { MaterialTextField } from "../core/TextField";
-109
View File
@@ -1,109 +0,0 @@
import { useEffect, useCallback, useRef } from "react";
import { useAppState } from "../state";
import { request } from "../../core/websocket";
import { API_BASE_URL } from "../../core/config";
import type { Message } from "../../core/types";
import { getAuthHeaders } from "../../auth/api";
export function useChat() {
const {
chat,
addMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm,
setIsChatSwitching,
user
} = useAppState();
const messagesLoadedRef = useRef(false);
// Load messages for the current chat
const loadMessages = useCallback(async () => {
if (!user.authToken || messagesLoadedRef.current) return;
try {
const response = await fetch(`${API_BASE_URL}/get_messages`, {
headers: getAuthHeaders(user.authToken)
});
if (response.ok) {
const data = await response.json();
if (data.messages && data.messages.length > 0) {
// Clear existing messages and add new ones
clearMessages();
data.messages.forEach((msg: Message) => {
addMessage(msg);
});
}
}
messagesLoadedRef.current = true;
} catch (error) {
console.error("Error loading messages:", error);
}
}, [user.authToken, addMessage, clearMessages]);
// Send a message
const sendMessage = useCallback(async (content: string) => {
if (!user.authToken || !content.trim()) return;
try {
const response = await request({
data: { content: content.trim() },
credentials: {
scheme: "Bearer",
credentials: user.authToken
},
type: "sendMessage"
});
if (response.error) {
console.error("Error sending message:", response.error);
}
} catch (error) {
console.error("Error sending message:", error);
}
}, [user.authToken]);
// WebSocket messages are now handled by the active panel
// No need for duplicate handling here
// Load messages only once when component mounts and user is authenticated
useEffect(() => {
if (user.authToken && !messagesLoadedRef.current) {
loadMessages();
}
}, [user.authToken, loadMessages]);
// Reset messages loaded flag and clear messages when chat changes
useEffect(() => {
console.log("🔄 [DEBUG] useChat useEffect triggered for chat change:", chat.currentChat);
console.log("📝 [DEBUG] useChat: Resetting messages loaded flag and clearing messages");
messagesLoadedRef.current = false;
clearMessages(); // Clear messages when switching chats
console.log("📥 [DEBUG] useChat: Loading messages for new chat");
loadMessages();
}, [chat.currentChat, clearMessages]);
return {
messages: chat.messages,
currentChat: chat.currentChat,
activeTab: chat.activeTab,
dmUsers: chat.dmUsers,
activeDm: chat.activeDm,
isChatSwitching: chat.isChatSwitching,
setIsChatSwitching,
sendMessage,
updateMessage,
removeMessage,
clearMessages,
setCurrentChat,
setActiveTab,
setDmUsers,
setActiveDm
};
}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
import type { User, Message, DmEncryptedJSON } from "../../core/types";
import { websocket } from "../../core/websocket";
interface DMUser extends User {
export interface DMUser extends User {
lastMessage?: string;
unreadCount: number;
publicKey?: string | null;
@@ -3,7 +3,7 @@ import { useAppState } from "../state";
import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "../../api/profileApi";
import { showSuccess, showError } from "../../utils/notification";
export function useProfile() {
export default function useProfile() {
const { user } = useAppState();
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isLoading, setIsLoading] = useState(false);
+8 -4
View File
@@ -33,15 +33,19 @@ export class DMPanel extends MessagePanel {
}
async activate(): Promise<void> {
if (this.dmData && !this.messagesLoaded) {
await this.loadMessages();
}
// Don't load messages immediately during activation to prevent animation freeze
// Messages will be loaded after the animation completes
}
deactivate(): void {
// DM doesn't need special cleanup
}
clearMessages(): void {
super.clearMessages();
this.messagesLoaded = false;
}
private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) {
const plaintext = await decryptDm(env, this.dmData!.publicKey);
const isAuthor = env.senderId !== this.dmData!.userId;
@@ -164,7 +168,7 @@ export class DMPanel extends MessagePanel {
}
// Handle incoming WebSocket DM messages
handleWebSocketMessage = async (response: DMWebSocketMessage): Promise<void> => {
async handleWebSocketMessage(response: DMWebSocketMessage): Promise<void> {
if (response.type === "dmNew" && this.dmData) {
const envelope = response.data;
@@ -1,4 +1,4 @@
import type { Message } from "../../core/types";
import type { Message, WebSocketMessage } from "../../core/types";
import type { UserState } from "../state";
export interface MessagePanelState {
@@ -46,9 +46,7 @@ export abstract class MessagePanel {
abstract loadMessages(): Promise<void>;
protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise<void>;
abstract isDm(): boolean;
// Optional WebSocket message handler (can be overridden by subclasses)
handleWebSocketMessage?: (response: any) => void;
abstract handleWebSocketMessage(response: WebSocketMessage<any>): Promise<void>;
// Common methods
protected updateState(updates: Partial<MessagePanelState>): void {
@@ -24,15 +24,19 @@ export class PublicChatPanel extends MessagePanel {
}
async activate(): Promise<void> {
if (!this.messagesLoaded) {
await this.loadMessages();
}
// Don't load messages immediately during activation to prevent animation freeze
// Messages will be loaded after the animation completes
}
deactivate(): void {
// Public chat doesn't need special cleanup
}
clearMessages(): void {
super.clearMessages();
this.messagesLoaded = false;
}
async loadMessages(): Promise<void> {
if (!this.currentUser.authToken || this.messagesLoaded) return;
@@ -100,7 +104,7 @@ export class PublicChatPanel extends MessagePanel {
}
// Handle incoming WebSocket messages
handleWebSocketMessage = (response: ChatWebSocketMessage): void => {
async handleWebSocketMessage(response: ChatWebSocketMessage): Promise<void> {
switch (response.type) {
case 'messageEdited':
if (response.data) {
+55 -81
View File
@@ -24,10 +24,12 @@ interface ChatState {
activeTab: ChatTabs;
dmUsers: User[];
activeDm: ActiveDM | null;
isChatSwitching: boolean;
isSwitching: boolean;
setIsSwitching: (value: boolean) => void;
activePanel: MessagePanel | null;
publicChatPanel: PublicChatPanel | null;
dmPanel: DMPanel | null;
pendingPanel?: MessagePanel | null;
}
export interface UserState {
@@ -46,11 +48,11 @@ interface AppState {
setDmUsers: (users: User[]) => void;
setActiveDm: (dm: ChatState["activeDm"]) => void;
clearMessages: () => void;
setIsChatSwitching: (value: boolean) => void;
setActivePanel: (panel: MessagePanel | null) => void;
setPendingPanel: (panel: MessagePanel | null) => void;
applyPendingPanel: () => void;
switchToPublicChat: (chatName: string) => Promise<void>;
switchToDM: (dmData: DMPanelData) => Promise<void>;
switchToTab: (tab: ChatTabs) => Promise<void>;
// User state
user: UserState;
@@ -67,19 +69,17 @@ export const useAppState = create<AppState>((set, get) => ({
activeTab: "chats",
dmUsers: [],
activeDm: null,
isChatSwitching: false,
activePanel: null,
publicChatPanel: null,
dmPanel: null
},
setIsChatSwitching: (value: boolean) => {
console.log("🎬 [DEBUG] setIsChatSwitching called with:", value);
set((state) => ({
isSwitching: false,
setIsSwitching: (value: boolean) => set((state) => ({
chat: {
...state.chat,
isChatSwitching: value
isSwitching: value
}
}));
})),
activePanel: null,
publicChatPanel: null,
dmPanel: null,
pendingPanel: null
},
addMessage: (message: Message) => set((state) => {
// Check if message already exists to prevent duplicates
@@ -258,104 +258,95 @@ export const useAppState = create<AppState>((set, get) => ({
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) => {
console.log("🔄 [DEBUG] switchToPublicChat called with:", chatName);
const state = get();
const { user, chat } = state;
const { user, chat } = get();
if (!user.authToken) {
console.log("❌ [DEBUG] No auth token, returning early");
return;
}
console.log("🎬 [DEBUG] Starting chat switching animation for:", chatName);
console.log("🎬 [DEBUG] Current isChatSwitching state:", chat.isChatSwitching);
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(true);
chat.setIsSwitching(true);
// Create or get public chat panel
let publicChatPanel = chat.publicChatPanel;
if (!publicChatPanel) {
console.log("🆕 [DEBUG] Creating new PublicChatPanel for:", chatName);
publicChatPanel = new PublicChatPanel(chatName, user);
} else {
console.log("♻️ [DEBUG] Reusing existing PublicChatPanel, setting chat name to:", chatName);
publicChatPanel.setChatName(chatName);
publicChatPanel.setAuthToken(user.authToken);
// Reset messages for the new chat
publicChatPanel.clearMessages();
}
console.log("⏳ [DEBUG] Waiting 250ms for animation...");
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
console.log("🚀 [DEBUG] Activating panel...");
// Activate panel
await publicChatPanel.activate();
console.log("📝 [DEBUG] Updating state with new panel and chat name");
// Update state
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
...state.chat,
activePanel: publicChatPanel,
publicChatPanel: publicChatPanel,
currentChat: chatName,
pendingPanel: publicChatPanel,
activeTab: "chats"
}
}));
console.log("✅ [DEBUG] Ending chat switching animation");
// End animation
state.setIsChatSwitching(false);
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
},
switchToDM: async (dmData: DMPanelData) => {
console.log("🔄 [DEBUG] switchToDM called with:", dmData);
const state = get();
const { user, chat } = state;
const { user, chat } = get();
if (!user.authToken) {
console.log("❌ [DEBUG] No auth token, returning early");
return;
}
console.log("🎬 [DEBUG] Starting DM switching animation for user:", dmData.username);
console.log("🎬 [DEBUG] Current isChatSwitching state:", chat.isChatSwitching);
if (!user.authToken) return;
// Start chat switching animation
state.setIsChatSwitching(true);
chat.setIsSwitching(true);
// Create or get DM panel
let dmPanel = chat.dmPanel;
if (!dmPanel) {
console.log("🆕 [DEBUG] Creating new DMPanel for user:", dmData.username);
dmPanel = new DMPanel(user);
} else {
console.log("♻️ [DEBUG] Reusing existing DMPanel, updating auth token");
dmPanel.setAuthToken(user.authToken);
// Reset messages for the new DM
dmPanel.clearMessages();
}
// Set DM data
console.log("📝 [DEBUG] Setting DM data for user:", dmData.username);
dmPanel.setDMData(dmData);
console.log("⏳ [DEBUG] Waiting 250ms for animation...");
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 250));
console.log("🚀 [DEBUG] Activating DM panel...");
// Activate panel
await dmPanel.activate();
console.log("📝 [DEBUG] Updating state with new DM panel");
// Update state
// Defer panel swap until animation switch-out completes
set((state) => ({
chat: {
...state.chat,
activePanel: dmPanel,
dmPanel: dmPanel,
pendingPanel: dmPanel,
activeDm: {
userId: dmData.userId,
username: dmData.username,
@@ -365,24 +356,7 @@ export const useAppState = create<AppState>((set, get) => ({
}
}));
console.log("✅ [DEBUG] Ending DM switching animation");
// End animation
state.setIsChatSwitching(false);
},
switchToTab: async (tab: ChatTabs) => {
console.log("🔄 [DEBUG] switchToTab called with:", tab);
const state = get();
console.log("📝 [DEBUG] Setting active tab to:", tab);
state.setActiveTab(tab);
if (tab === "chats") {
console.log("💬 [DEBUG] Switching to chats tab, calling switchToPublicChat");
await state.switchToPublicChat("Общий чат");
} else if (tab === "dms") {
console.log("💬 [DEBUG] Switching to DMs tab, clearing active panel");
// DM tab - no specific panel until user is selected
state.setActivePanel(null);
}
// Let MessagePanelRenderer handle the animation timing completely
// It will set isChatSwitching to false when the fadeInDown animation completes
}
}));