From f8919dcdbd09532c48b6a88bc7afc01fde4fa48a Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 5 Aug 2026 14:27:57 +0300 Subject: [PATCH] refactor(messages): persist workspace across routes --- frontend/src/app/messages/[chatId]/page.tsx | 36 +- .../app/messages/[chatId]/settings/page.tsx | 73 +-- frontend/src/app/messages/layout.tsx | 7 + frontend/src/app/messages/page.tsx | 534 ++---------------- frontend/src/components/AppShell.tsx | 16 +- .../components/messages/CreateChatModal.tsx | 176 ++++++ .../messages/MessagesFocusSidebar.tsx | 137 +---- .../messages/MessagesWorkspaceContext.tsx | 23 + .../messages/MessagesWorkspaceShell.tsx | 61 ++ frontend/src/hooks/useMessagesDirectory.ts | 104 ++++ frontend/src/hooks/useMessagesFocusMode.ts | 44 -- .../src/hooks/useMessagesImmersiveMode.ts | 43 -- .../src/hooks/useMessagesWorkspaceMode.ts | 60 ++ frontend/src/lib/messages/workspace.d.ts | 10 + frontend/src/lib/messages/workspace.js | 34 ++ frontend/src/lib/messages/workspace.test.js | 28 + 16 files changed, 608 insertions(+), 778 deletions(-) create mode 100644 frontend/src/app/messages/layout.tsx create mode 100644 frontend/src/components/messages/CreateChatModal.tsx create mode 100644 frontend/src/components/messages/MessagesWorkspaceContext.tsx create mode 100644 frontend/src/components/messages/MessagesWorkspaceShell.tsx create mode 100644 frontend/src/hooks/useMessagesDirectory.ts delete mode 100644 frontend/src/hooks/useMessagesFocusMode.ts delete mode 100644 frontend/src/hooks/useMessagesImmersiveMode.ts create mode 100644 frontend/src/hooks/useMessagesWorkspaceMode.ts create mode 100644 frontend/src/lib/messages/workspace.d.ts create mode 100644 frontend/src/lib/messages/workspace.js create mode 100644 frontend/src/lib/messages/workspace.test.js diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 36b528e9..98b90226 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -10,13 +10,11 @@ import { Link2, Loader2, } from "lucide-react"; -import { AppShell } from "../../../components/AppShell"; import { Modal } from "@/components/ui"; import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; import ChatSearchPanel from "@/components/messages/ChatSearchPanel"; -import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar"; import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; @@ -36,8 +34,6 @@ import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; import { useWebSocket } from "@/hooks/useWebSocket"; import { useChatMessages } from "@/hooks/useChatMessages"; import { useMarkRead } from "@/hooks/useMarkRead"; -import { useMessagesFocusMode } from "@/hooks/useMessagesFocusMode"; -import { useMessagesImmersiveMode } from "@/hooks/useMessagesImmersiveMode"; import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll"; import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; @@ -132,12 +128,10 @@ export default function MessageDialogPage() { function MessageDialogPageFallback() { return ( - -
-
-

Загрузка чата...

-
-
+
+
+

Загрузка чата...

+
); } @@ -148,9 +142,6 @@ function MessageDialogPageContent() { const searchParams = useSearchParams(); const chatId = Number(params.chatId); const { user, loading: userLoading } = useUser(); - const [focusMode, setFocusMode] = useMessagesFocusMode(user?.id); - const [immersiveMode, setImmersiveMode] = useMessagesImmersiveMode(user?.id); - const workspaceMode = focusMode || immersiveMode; const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); /* ── refs ── */ @@ -1364,21 +1355,7 @@ function MessageDialogPageContent() { * ────────────────────────────────────────────────────── */ return ( - -
- {workspaceMode ? ( - - ) : null} + <>
{cm.loading ? (
@@ -1612,7 +1589,6 @@ function MessageDialogPageContent() {
)}
-
{mediaPreview ? setMediaPreview(null)} /> : null} @@ -1900,6 +1876,6 @@ function MessageDialogPageContent() { }} /> ) : null} -
+ ); } diff --git a/frontend/src/app/messages/[chatId]/settings/page.tsx b/frontend/src/app/messages/[chatId]/settings/page.tsx index 742cca7e..c1a9c097 100644 --- a/frontend/src/app/messages/[chatId]/settings/page.tsx +++ b/frontend/src/app/messages/[chatId]/settings/page.tsx @@ -13,18 +13,14 @@ import { UserMinus, Pencil } from "lucide-react"; -import { AppShell } from "../../../../components/AppShell"; import { apiClient } from "@/lib/api"; import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils"; import ChatParticipantsSection from "@/components/messages/ChatParticipantsSection"; import EditChatModal from "@/components/messages/EditChatModal"; import AddChatMemberModal from "@/components/messages/AddChatMemberModal"; -import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar"; import { getChatTypeLabel, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils"; import type { Chat } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; -import { useMessagesFocusMode } from "@/hooks/useMessagesFocusMode"; -import { useMessagesImmersiveMode } from "@/hooks/useMessagesImmersiveMode"; import { resolveMediaUrl } from "@/lib/url"; export default function ChatSettingsPage() { @@ -32,9 +28,6 @@ export default function ChatSettingsPage() { const router = useRouter(); const chatId = Number(params.chatId); const { user } = useUser(); - const [focusMode, setFocusMode] = useMessagesFocusMode(user?.id); - const [immersiveMode, setImmersiveMode] = useMessagesImmersiveMode(user?.id); - const workspaceMode = focusMode || immersiveMode; const currentUserId = user?.id; const [chat, setChat] = useState(null); @@ -396,50 +389,21 @@ export default function ChatSettingsPage() { if (loading) { return ( - -
- {workspaceMode ? ( - - ) : null} -
-
-

Загрузка настроек...

-
-
- +
+
+

Загрузка настроек...

+
); } if (error || !chat) { return ( - -
- {workspaceMode ? ( - - ) : null} -
-

{error || "Чат не найден"}

- - Вернуться к списку чатов - -
-
-
+
+

{error || "Чат не найден"}

+ + Вернуться к списку чатов + +
); } @@ -455,18 +419,7 @@ export default function ChatSettingsPage() { const canEdit = isOwner || isAdmin; return ( - -
- {workspaceMode ? ( - - ) : null} -
+
{/* Хедер */}
@@ -672,8 +625,6 @@ export default function ChatSettingsPage() { onSearchChange={handleMemberSearch} onAddMember={handleAddMember} /> -
-
- +
); } diff --git a/frontend/src/app/messages/layout.tsx b/frontend/src/app/messages/layout.tsx new file mode 100644 index 00000000..60793429 --- /dev/null +++ b/frontend/src/app/messages/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +import MessagesWorkspaceShell from "@/components/messages/MessagesWorkspaceShell"; + +export default function MessagesLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index e6157e22..14aec280 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -1,495 +1,81 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { AppShell } from "../../components/AppShell"; -import { apiClient } from "@/lib/api"; -import { ChatIdentity, getChatTitle, isDepartmentCommentsChat } from "@/lib/messages/chatUtils"; -import type { Chat } from "@/types/api"; -import { MessageCircle, Plus, X, Users, Globe, Radio, Image as ImageIcon } from "lucide-react"; -import Image from "next/image"; -import { useUser } from "@/contexts/UserContext"; -import { useRouter } from "next/navigation"; -import { Modal } from "@/components/ui"; +import { MessageCircle, Plus } from "lucide-react"; import MessagesChatListControls from "@/components/messages/MessagesChatListControls"; import MessagesChatListItem from "@/components/messages/MessagesChatListItem"; import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton"; -import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar"; import MessagesImmersiveModeButton from "@/components/messages/MessagesImmersiveModeButton"; -import { useMessagesFocusMode } from "@/hooks/useMessagesFocusMode"; -import { useMessagesImmersiveMode } from "@/hooks/useMessagesImmersiveMode"; -import { useMessagesListFilters } from "@/hooks/useMessagesListFilters"; -import { useMessagesChatList } from "@/hooks/useMessagesChatList"; - -type CreateChatPayload = { - type: string; - name: string; - description?: string; - avatar?: File; -}; - -function getChatSortTimestamp(chat: Chat): number { - const lastMessage = chat.last_message; - - if (typeof lastMessage?.created_ts === "number") { - return lastMessage.created_ts; - } - - const lastCreatedRaw = lastMessage?.created_at || lastMessage?.created; - if (lastCreatedRaw) { - const ts = new Date(lastCreatedRaw).getTime(); - if (!Number.isNaN(ts)) return ts; - } - - const chatCreatedTs = new Date(chat.created_at).getTime(); - return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs; -} - -function isChatVisibleInMessagesList( - chat: Chat, - visibleDepartmentIds: ReadonlySet -): boolean { - const chatType = chat.chat_type || chat.type; - - if (chatType !== "comments") { - return true; - } - - if (!isDepartmentCommentsChat(chat)) { - return false; - } - - return typeof chat.context_object_id === "number" - && visibleDepartmentIds.has(chat.context_object_id); -} +import { useMessagesWorkspace } from "@/components/messages/MessagesWorkspaceContext"; +import { useMessagesDirectory } from "@/hooks/useMessagesDirectory"; export default function MessagesPage() { - const { user } = useUser(); - const router = useRouter(); - const [focusMode, setFocusMode] = useMessagesFocusMode(user?.id); - const [immersiveMode, setImmersiveMode] = useMessagesImmersiveMode(user?.id); - const { - search, - chatTypeFilter, - filterUnread, - filterPinned, - } = useMessagesListFilters(user?.id); - const workspaceMode = focusMode || immersiveMode; - const currentUserId = user?.id; - const currentUserFirstName = user?.first_name; - const currentUserLastName = user?.last_name; - const currentUserPatronymic = user?.patronymic; - const currentUserEmail = user?.email; + const { workspaceMode } = useMessagesWorkspace(); - const currentUserForMatch = useMemo( - () => ({ - first_name: currentUserFirstName, - last_name: currentUserLastName, - patronymic: currentUserPatronymic, - email: currentUserEmail, - }), - [currentUserEmail, currentUserFirstName, currentUserLastName, currentUserPatronymic] - ); - const visibleDepartmentIds = useMemo(() => { - const ids = new Set(); - (user?.departments || []).forEach((department) => { - if (typeof department.id === "number") { - ids.add(department.id); - } - }); - return ids; - }, [user?.departments]); - const { chats: allChats, loading, error } = useMessagesChatList(user?.id); - const chats = useMemo( - () => allChats.filter((chat) => isChatVisibleInMessagesList(chat, visibleDepartmentIds)), - [allChats, visibleDepartmentIds], - ); - // Состояния для создания чата - const [showCreateModal, setShowCreateModal] = useState(false); - const [newChatType, setNewChatType] = useState<'group' | 'channel' | 'global'>('group'); - const [newChatName, setNewChatName] = useState(""); - const [newChatDescription, setNewChatDescription] = useState(""); - const [newChatAvatar, setNewChatAvatar] = useState(null); - const [avatarPreview, setAvatarPreview] = useState(null); - const [creating, setCreating] = useState(false); - - useEffect(() => { - const params = new URLSearchParams(window.location.search); - if (params.get("createChat") !== "1") return; - setShowCreateModal(true); - params.delete("createChat"); - const query = params.toString(); - window.history.replaceState(null, "", query ? `/messages?${query}` : "/messages"); - }, []); - - const filteredChats = useMemo(() => { - const q = search.trim().toLowerCase(); - - // Сортировка: закрепленные сверху, потом по времени последнего сообщения - const sorted = [...chats].sort((a, b) => { - // Сначала сортируем по закрепленности - const aPinned = a.is_pinned ? 1 : 0; - const bPinned = b.is_pinned ? 1 : 0; - if (aPinned !== bPinned) return bPinned - aPinned; - - // Затем по времени последнего сообщения - return getChatSortTimestamp(b) - getChatSortTimestamp(a); - }); - - // Фильтруем по типу и поисковому запросу - return sorted.filter((chat: Chat) => { - // Фильтр по типу чата - if (chatTypeFilter !== "all") { - const chatType = chat.chat_type || chat.type; - if (chatType !== chatTypeFilter) return false; - } - - // Фильтр по прочитанности - if (filterUnread === 'unread' && (chat.unread_count || 0) === 0) return false; - if (filterUnread === 'read' && (chat.unread_count || 0) > 0) return false; - - // Фильтр по закрепленности - if (filterPinned === 'pinned' && !chat.is_pinned) return false; - if (filterPinned === 'unpinned' && chat.is_pinned) return false; - - // Фильтр по поисковому запросу - if (q) { - const title = getChatTitle(chat, currentUserId, currentUserForMatch).toLowerCase(); - const lastMessage = chat.last_message?.content?.toLowerCase() || ""; - if (!title.includes(q) && !lastMessage.includes(q)) return false; - } - - return true; - }); - }, [chats, currentUserForMatch, currentUserId, search, chatTypeFilter, filterUnread, filterPinned]); - - // Подсчет чатов по типам - const chatTypeCounts = useMemo(() => { - const counts: Record = { - all: { total: chats.length, unread: 0 }, - global: { total: 0, unread: 0 }, - channel: { total: 0, unread: 0 }, - comments: { total: 0, unread: 0 }, - private: { total: 0, unread: 0 }, - group: { total: 0, unread: 0 }, - announcement: { total: 0, unread: 0 }, - }; - - chats.forEach((chat: Chat) => { - const chatType = chat.chat_type || chat.type; - const unreadCount = chat.unread_count || 0; - - if (unreadCount > 0) { - counts.all.unread += unreadCount; - } - - if (chatType && counts[chatType] !== undefined) { - counts[chatType].total++; - if (unreadCount > 0) { - counts[chatType].unread += unreadCount; - } - } - }); - - return counts; - }, [chats]); - - const handleCreateChat = async () => { - if (creating) return; - - // Валидация: все типы кроме comments требуют название - if (!newChatName.trim()) { - alert('Введите название чата'); - return; - } - - setCreating(true); - try { - const chatData: CreateChatPayload = { - type: newChatType, - name: newChatName.trim(), - }; - - if (newChatDescription.trim()) { - chatData.description = newChatDescription.trim(); - } - - if (newChatAvatar) { - chatData.avatar = newChatAvatar; - } - - const newChat = await apiClient.createChat(chatData); - - // Закрываем модалку и сбрасываем форму - setShowCreateModal(false); - setNewChatType('group'); - setNewChatName(''); - setNewChatDescription(''); - setNewChatAvatar(null); - setAvatarPreview(null); - - // Перенаправляем на страницу настроек созданного чата - router.push(`/messages/${newChat.id}/settings`); - } catch (e: unknown) { - console.error('Ошибка создания чата:', e); - const errorMessage = e instanceof Error ? e.message : String(e); - alert(`Не удалось создать чат: ${errorMessage}`); - } finally { - setCreating(false); - } - }; - - const handleAvatarChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - if (!file.type.startsWith('image/')) { - alert('Можно загружать только изображения'); - return; - } - setNewChatAvatar(file); - - // Создаем превью - const reader = new FileReader(); - reader.onloadend = () => { - setAvatarPreview(reader.result as string); - }; - reader.readAsDataURL(file); - } - }; - - const handleRemoveAvatar = () => { - setNewChatAvatar(null); - setAvatarPreview(null); - }; - - return ( - -
- {loading ? ( -
-
-

Загрузка чатов...

+ if (workspaceMode) { + return ( + <> +
+
- ) : error ? ( -
-

{error}

-
- ) : workspaceMode ? ( - setShowCreateModal(true)} - /> - ) : ( -
-
-

Сообщения

-
- - - -
-
- - - -
- {filteredChats.length === 0 ? ( -
-

Чаты не найдены

-
- ) : ( - filteredChats.map((chat: Chat) => ( - - )) - )} -
- - {filteredChats.length === 0 ? ( -
-
- -

Создайте или найдите диалог

-
-
- ) : null} -
- )} - {workspaceMode && !loading && !error ? ( -
+

Выберите чат

Диалог откроется справа от списка чатов

- ) : null} + + ); + } + + return ; +} + +function StandardMessagesList() { + const { setMode, openCreateChat } = useMessagesWorkspace(); + const { user, identity, visibleChats, chatTypeCounts, loading, error } = useMessagesDirectory(); + + if (loading) { + return ( +
+
+

Загрузка чатов...

- - {/* Модальное окно создания чата */} - setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={ -
- - -
- }> -
- {/* Тип чата */} -
- -
- - - -
-
+ ); + } - {/* Название и описание */} -
- - setNewChatName(e.target.value)} - placeholder="Введите название чата..." - className="app-input w-full rounded-lg px-3 py-2 text-sm" - /> -
-
- -