+
{/* Хедер */}
@@ -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 ? (
-
- ) : 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"
- />
-
-
-
-
+ if (error) {
+ return (
+
+ );
+ }
- {/* Аватар */}
-
-
- {avatarPreview ? (
-
-
-
-
-
-
{newChatAvatar?.name}
-
-
-
- ) : (
-
- )}
-
-
-
-
+ return (
+
+
+
Сообщения
+
+
setMode(enabled ? "wide" : "standard")} />
+ setMode(enabled ? "immersive" : "standard")} />
+
+
+
+
+
+
+
+ {visibleChats.length === 0 ? (
+
+ ) : visibleChats.map((chat) => (
+
+ ))}
+
+
);
}
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx
index 1595b95e..23509f1c 100644
--- a/frontend/src/components/AppShell.tsx
+++ b/frontend/src/components/AppShell.tsx
@@ -23,6 +23,7 @@ type AppShellProps = {
children: ReactNode;
desktopWideMode?: boolean;
desktopImmersiveMode?: boolean;
+ mobileFullscreen?: boolean;
onDesktopWideModeChange?: (enabled: boolean) => void;
suppressPushOnboardingPrompt?: boolean;
};
@@ -596,6 +597,7 @@ export function AppShell({
children,
desktopWideMode = false,
desktopImmersiveMode = false,
+ mobileFullscreen = false,
onDesktopWideModeChange,
suppressPushOnboardingPrompt = false,
}: AppShellProps) {
@@ -603,10 +605,6 @@ export function AppShell({
const { mobileNavPlacement } = useMobileNavPlacement();
const router = useRouter();
const pathname = usePathname();
- const isMessageDialogPage = pathname.startsWith('/messages/') &&
- pathname !== '/messages' &&
- !pathname.includes('/settings');
-
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
@@ -679,19 +677,19 @@ export function AppShell({
}
return (
-
+
setIsMobileLeftNavOpen(true)}
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>
-
+
{!desktopImmersiveMode ?
: null}
-
- {!isMessageDialogPage && !suppressPushOnboardingPrompt ? : null}
+
+ {!mobileFullscreen && !suppressPushOnboardingPrompt ? : null}
{children}
{!desktopImmersiveMode ? void;
+};
+
+type CreateChatPayload = {
+ type: string;
+ name: string;
+ description?: string;
+ avatar?: File;
+};
+
+export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProps) {
+ const router = useRouter();
+ const [chatType, setChatType] = useState<"group" | "channel" | "global">("group");
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [avatar, setAvatar] = useState(null);
+ const [avatarPreview, setAvatarPreview] = useState(null);
+ const [creating, setCreating] = useState(false);
+
+ const resetForm = () => {
+ setChatType("group");
+ setName("");
+ setDescription("");
+ setAvatar(null);
+ setAvatarPreview(null);
+ };
+
+ const close = () => {
+ if (creating) return;
+ onClose();
+ };
+
+ const handleCreate = async () => {
+ if (creating) return;
+ if (!name.trim()) {
+ alert("Введите название чата");
+ return;
+ }
+
+ setCreating(true);
+ try {
+ const payload: CreateChatPayload = {
+ type: chatType,
+ name: name.trim(),
+ };
+ if (description.trim()) payload.description = description.trim();
+ if (avatar) payload.avatar = avatar;
+
+ const chat = await apiClient.createChat(payload);
+ onClose();
+ resetForm();
+ router.push(`/messages/${chat.id}/settings`);
+ } catch (error: unknown) {
+ console.error("Ошибка создания чата:", error);
+ const message = error instanceof Error ? error.message : String(error);
+ alert(`Не удалось создать чат: ${message}`);
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleAvatarChange = (event: ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ if (!file.type.startsWith("image/")) {
+ alert("Можно загружать только изображения");
+ return;
+ }
+
+ setAvatar(file);
+ const reader = new FileReader();
+ reader.onloadend = () => setAvatarPreview(reader.result as string);
+ reader.readAsDataURL(file);
+ };
+
+ return (
+
+
+
+
+ )}
+ >
+
+
+
+
+ {([
+ ["group", "Групповой", Users],
+ ["channel", "Канал", Radio],
+ ["global", "Глобальный", Globe],
+ ] as const).map(([value, label, Icon]) => (
+
+ ))}
+
+
+
+
+
+ setName(event.target.value)} placeholder="Введите название чата..." className="app-input w-full rounded-lg px-3 py-2 text-sm" />
+
+
+
+
+
+
+
+
+ {avatarPreview ? (
+
+
+
+
{avatar?.name}
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/messages/MessagesFocusSidebar.tsx b/frontend/src/components/messages/MessagesFocusSidebar.tsx
index 37473196..2c68665a 100644
--- a/frontend/src/components/messages/MessagesFocusSidebar.tsx
+++ b/frontend/src/components/messages/MessagesFocusSidebar.tsx
@@ -1,115 +1,28 @@
"use client";
-import Link from "next/link";
-import { useMemo } from "react";
import { Plus } from "lucide-react";
-import { useUser } from "@/contexts/UserContext";
-import {
- ChatIdentity,
- getChatTitle,
- isDepartmentCommentsChat,
-} from "@/lib/messages/chatUtils";
-import type { Chat } from "@/types/api";
import MessagesChatListControls from "@/components/messages/MessagesChatListControls";
import MessagesChatListItem from "@/components/messages/MessagesChatListItem";
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
import MessagesImmersiveModeButton from "@/components/messages/MessagesImmersiveModeButton";
-import { useMessagesListFilters } from "@/hooks/useMessagesListFilters";
-import { useMessagesChatList } from "@/hooks/useMessagesChatList";
+import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
+import type { MessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
type MessagesFocusSidebarProps = {
activeChatId?: number;
- focusMode: boolean;
- onFocusModeChange: (enabled: boolean) => void;
- immersiveMode: boolean;
- onImmersiveModeChange: (enabled: boolean) => void;
- onCreateChat?: () => void;
+ mode: MessagesWorkspaceMode;
+ onModeChange: (mode: MessagesWorkspaceMode) => void;
+ onCreateChat: () => void;
};
-function chatTimestamp(chat: Chat): number {
- const raw = chat.last_message?.created_at || chat.created_at;
- const timestamp = new Date(raw).getTime();
- return Number.isNaN(timestamp) ? 0 : timestamp;
-}
-
export default function MessagesFocusSidebar({
activeChatId,
- focusMode,
- onFocusModeChange,
- immersiveMode,
- onImmersiveModeChange,
+ mode,
+ onModeChange,
onCreateChat,
}: MessagesFocusSidebarProps) {
- const { user } = useUser();
- const {
- search,
- chatTypeFilter,
- filterUnread,
- filterPinned,
- } = useMessagesListFilters(user?.id);
-
- const identity = useMemo
(() => ({
- first_name: user?.first_name,
- last_name: user?.last_name,
- patronymic: user?.patronymic,
- email: user?.email,
- }), [user?.email, user?.first_name, user?.last_name, user?.patronymic]);
-
- const departmentIds = useMemo(
- () => new Set((user?.departments || []).map((department) => department.id)),
- [user?.departments],
- );
- const { chats: allChats, loading } = useMessagesChatList(user?.id);
- const chats = useMemo(
- () => allChats.filter((chat) => {
- const type = chat.chat_type || chat.type;
- if (type !== "comments") return true;
- return isDepartmentCommentsChat(chat)
- && typeof chat.context_object_id === "number"
- && departmentIds.has(chat.context_object_id);
- }),
- [allChats, departmentIds],
- );
-
- const visibleChats = useMemo(() => {
- const query = search.trim().toLowerCase();
- return [...chats]
- .sort((a, b) => Number(Boolean(b.is_pinned)) - Number(Boolean(a.is_pinned)) || chatTimestamp(b) - chatTimestamp(a))
- .filter((chat) => {
- const chatType = chat.chat_type || chat.type;
- if (chatTypeFilter !== "all" && 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 (!query) return true;
- const title = getChatTitle(chat, user?.id, identity).toLowerCase();
- return title.includes(query) || (chat.last_message?.content || "").toLowerCase().includes(query);
- });
- }, [chatTypeFilter, chats, filterPinned, filterUnread, identity, search, user?.id]);
-
- 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) => {
- const type = chat.chat_type || chat.type;
- const unread = chat.unread_count || 0;
- if (unread > 0) counts.all.unread += unread;
- if (!type || !counts[type]) return;
- counts[type].total += 1;
- if (unread > 0) counts[type].unread += unread;
- });
- return counts;
- }, [chats]);
+ const { user, identity, visibleChats, chatTypeCounts, loading } = useMessagesDirectory();
return (
diff --git a/frontend/src/components/messages/MessagesWorkspaceContext.tsx b/frontend/src/components/messages/MessagesWorkspaceContext.tsx
new file mode 100644
index 00000000..d18593f6
--- /dev/null
+++ b/frontend/src/components/messages/MessagesWorkspaceContext.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+import type { MessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
+
+type MessagesWorkspaceContextValue = {
+ mode: MessagesWorkspaceMode;
+ setMode: (mode: MessagesWorkspaceMode) => void;
+ workspaceMode: boolean;
+ immersiveMode: boolean;
+ openCreateChat: () => void;
+};
+
+export const MessagesWorkspaceContext = createContext
(null);
+
+export function useMessagesWorkspace() {
+ const value = useContext(MessagesWorkspaceContext);
+ if (!value) {
+ throw new Error("useMessagesWorkspace must be used within MessagesWorkspaceShell");
+ }
+ return value;
+}
diff --git a/frontend/src/components/messages/MessagesWorkspaceShell.tsx b/frontend/src/components/messages/MessagesWorkspaceShell.tsx
new file mode 100644
index 00000000..ed38921b
--- /dev/null
+++ b/frontend/src/components/messages/MessagesWorkspaceShell.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useMemo, useState } from "react";
+import { usePathname } from "next/navigation";
+
+import { AppShell } from "@/components/AppShell";
+import CreateChatModal from "@/components/messages/CreateChatModal";
+import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar";
+import { MessagesWorkspaceContext } from "@/components/messages/MessagesWorkspaceContext";
+import { useUser } from "@/contexts/UserContext";
+import { useMessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
+import { getMessagesActiveChatId, isMessagesDialogPath } from "@/lib/messages/workspace.js";
+
+type MessagesWorkspaceShellProps = {
+ children: ReactNode;
+};
+
+export default function MessagesWorkspaceShell({ children }: MessagesWorkspaceShellProps) {
+ const pathname = usePathname();
+ const { user } = useUser();
+ const [createChatOpen, setCreateChatOpen] = useState(false);
+ const [mode, setMode] = useMessagesWorkspaceMode(user?.id);
+ const workspaceMode = mode !== "standard";
+ const immersiveMode = mode === "immersive";
+ const activeChatId = getMessagesActiveChatId(pathname);
+ const mobileFullscreen = isMessagesDialogPath(pathname);
+
+ const contextValue = useMemo(() => ({
+ mode,
+ setMode,
+ workspaceMode,
+ immersiveMode,
+ openCreateChat: () => setCreateChatOpen(true),
+ }), [immersiveMode, mode, setMode, workspaceMode]);
+
+ return (
+
+ setMode(enabled ? "wide" : "standard")}
+ suppressPushOnboardingPrompt={workspaceMode}
+ >
+
+ {workspaceMode ? (
+
setCreateChatOpen(true)}
+ />
+ ) : null}
+ {children}
+
+ setCreateChatOpen(false)} />
+
+
+ );
+}
diff --git a/frontend/src/hooks/useMessagesDirectory.ts b/frontend/src/hooks/useMessagesDirectory.ts
new file mode 100644
index 00000000..d5ffcda1
--- /dev/null
+++ b/frontend/src/hooks/useMessagesDirectory.ts
@@ -0,0 +1,104 @@
+"use client";
+
+import { useMemo } from "react";
+
+import { useUser } from "@/contexts/UserContext";
+import {
+ type ChatIdentity,
+ getChatTitle,
+ isDepartmentCommentsChat,
+} from "@/lib/messages/chatUtils";
+import type { Chat } from "@/types/api";
+import { useMessagesChatList } from "@/hooks/useMessagesChatList";
+import { useMessagesListFilters } from "@/hooks/useMessagesListFilters";
+
+export type MessageChatTypeCounts = Record;
+
+function chatTimestamp(chat: Chat): number {
+ const lastMessage = chat.last_message;
+ if (typeof lastMessage?.created_ts === "number") return lastMessage.created_ts;
+
+ const raw = lastMessage?.created_at || lastMessage?.created || chat.created_at;
+ const timestamp = new Date(raw).getTime();
+ return Number.isNaN(timestamp) ? 0 : timestamp;
+}
+
+export function useMessagesDirectory() {
+ const { user } = useUser();
+ const { search, chatTypeFilter, filterUnread, filterPinned } = useMessagesListFilters(user?.id);
+ const { chats: allChats, loading, error } = useMessagesChatList(user?.id);
+
+ const identity = useMemo(() => ({
+ first_name: user?.first_name,
+ last_name: user?.last_name,
+ patronymic: user?.patronymic,
+ email: user?.email,
+ }), [user?.email, user?.first_name, user?.last_name, user?.patronymic]);
+
+ const departmentIds = useMemo(
+ () => new Set((user?.departments || []).map((department) => department.id)),
+ [user?.departments],
+ );
+
+ const chats = useMemo(
+ () => allChats.filter((chat) => {
+ const type = chat.chat_type || chat.type;
+ if (type !== "comments") return true;
+ return isDepartmentCommentsChat(chat)
+ && typeof chat.context_object_id === "number"
+ && departmentIds.has(chat.context_object_id);
+ }),
+ [allChats, departmentIds],
+ );
+
+ const visibleChats = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ return [...chats]
+ .sort((a, b) => Number(Boolean(b.is_pinned)) - Number(Boolean(a.is_pinned)) || chatTimestamp(b) - chatTimestamp(a))
+ .filter((chat) => {
+ const type = chat.chat_type || chat.type;
+ const unread = chat.unread_count || 0;
+ if (chatTypeFilter !== "all" && type !== chatTypeFilter) return false;
+ if (filterUnread === "unread" && unread === 0) return false;
+ if (filterUnread === "read" && unread > 0) return false;
+ if (filterPinned === "pinned" && !chat.is_pinned) return false;
+ if (filterPinned === "unpinned" && chat.is_pinned) return false;
+ if (!query) return true;
+
+ const title = getChatTitle(chat, user?.id, identity).toLowerCase();
+ return title.includes(query) || (chat.last_message?.content || "").toLowerCase().includes(query);
+ });
+ }, [chatTypeFilter, chats, filterPinned, filterUnread, identity, search, user?.id]);
+
+ const chatTypeCounts = useMemo(() => {
+ const counts: MessageChatTypeCounts = {
+ 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) => {
+ const type = chat.chat_type || chat.type;
+ const unread = chat.unread_count || 0;
+ if (unread > 0) counts.all.unread += unread;
+ if (!type || !counts[type]) return;
+ counts[type].total += 1;
+ if (unread > 0) counts[type].unread += unread;
+ });
+
+ return counts;
+ }, [chats]);
+
+ return {
+ user,
+ identity,
+ visibleChats,
+ chatTypeCounts,
+ loading,
+ error,
+ };
+}
diff --git a/frontend/src/hooks/useMessagesFocusMode.ts b/frontend/src/hooks/useMessagesFocusMode.ts
deleted file mode 100644
index 5372c72f..00000000
--- a/frontend/src/hooks/useMessagesFocusMode.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import { useCallback, useSyncExternalStore } from "react";
-
-const STORAGE_PREFIX = "messages.desktopFocusMode";
-const CHANGE_EVENT = "messages-desktop-focus-mode-change";
-
-function storageKey(userId: number): string {
- return `${STORAGE_PREFIX}.${userId}`;
-}
-
-export function useMessagesFocusMode(userId?: number | null) {
- const subscribe = useCallback((notify: () => void) => {
- if (!userId) return () => undefined;
-
- const key = storageKey(userId);
- const handleStorage = (event: StorageEvent) => {
- if (event.key === key) notify();
- };
-
- window.addEventListener("storage", handleStorage);
- window.addEventListener(CHANGE_EVENT, notify);
- return () => {
- window.removeEventListener("storage", handleStorage);
- window.removeEventListener(CHANGE_EVENT, notify);
- };
- }, [userId]);
-
- const getSnapshot = useCallback(() => {
- if (!userId) return false;
- const stored = window.localStorage.getItem(storageKey(userId));
- return stored === null ? true : stored === "1";
- }, [userId]);
-
- const enabled = useSyncExternalStore(subscribe, getSnapshot, () => false);
-
- const setEnabled = useCallback((next: boolean) => {
- if (!userId) return;
- window.localStorage.setItem(storageKey(userId), next ? "1" : "0");
- window.dispatchEvent(new Event(CHANGE_EVENT));
- }, [userId]);
-
- return [enabled, setEnabled] as const;
-}
diff --git a/frontend/src/hooks/useMessagesImmersiveMode.ts b/frontend/src/hooks/useMessagesImmersiveMode.ts
deleted file mode 100644
index cd7cccf4..00000000
--- a/frontend/src/hooks/useMessagesImmersiveMode.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-
-import { useCallback, useSyncExternalStore } from "react";
-
-const STORAGE_PREFIX = "messages.desktopImmersiveMode";
-const CHANGE_EVENT = "messages-desktop-immersive-mode-change";
-
-function storageKey(userId: number): string {
- return `${STORAGE_PREFIX}.${userId}`;
-}
-
-export function useMessagesImmersiveMode(userId?: number | null) {
- const subscribe = useCallback((notify: () => void) => {
- if (!userId) return () => undefined;
-
- const key = storageKey(userId);
- const handleStorage = (event: StorageEvent) => {
- if (event.key === key) notify();
- };
-
- window.addEventListener("storage", handleStorage);
- window.addEventListener(CHANGE_EVENT, notify);
- return () => {
- window.removeEventListener("storage", handleStorage);
- window.removeEventListener(CHANGE_EVENT, notify);
- };
- }, [userId]);
-
- const getSnapshot = useCallback(
- () => (userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false),
- [userId],
- );
-
- const enabled = useSyncExternalStore(subscribe, getSnapshot, () => false);
-
- const setEnabled = useCallback((next: boolean) => {
- if (!userId) return;
- window.localStorage.setItem(storageKey(userId), next ? "1" : "0");
- window.dispatchEvent(new Event(CHANGE_EVENT));
- }, [userId]);
-
- return [enabled, setEnabled] as const;
-}
diff --git a/frontend/src/hooks/useMessagesWorkspaceMode.ts b/frontend/src/hooks/useMessagesWorkspaceMode.ts
new file mode 100644
index 00000000..70ee1d6c
--- /dev/null
+++ b/frontend/src/hooks/useMessagesWorkspaceMode.ts
@@ -0,0 +1,60 @@
+"use client";
+
+import { useCallback, useSyncExternalStore } from "react";
+
+import {
+ resolveMessagesWorkspaceMode,
+ type MessagesWorkspaceMode,
+} from "@/lib/messages/workspace.js";
+
+export type { MessagesWorkspaceMode } from "@/lib/messages/workspace.js";
+
+const STORAGE_PREFIX = "messages.workspaceMode";
+const LEGACY_FOCUS_PREFIX = "messages.desktopFocusMode";
+const LEGACY_IMMERSIVE_PREFIX = "messages.desktopImmersiveMode";
+const CHANGE_EVENT = "messages-workspace-mode-change";
+
+function storageKey(prefix: string, userId: number): string {
+ return `${prefix}.${userId}`;
+}
+
+function readMode(userId: number): MessagesWorkspaceMode {
+ return resolveMessagesWorkspaceMode({
+ current: window.localStorage.getItem(storageKey(STORAGE_PREFIX, userId)),
+ legacyFocus: window.localStorage.getItem(storageKey(LEGACY_FOCUS_PREFIX, userId)),
+ legacyImmersive: window.localStorage.getItem(storageKey(LEGACY_IMMERSIVE_PREFIX, userId)),
+ });
+}
+
+export function useMessagesWorkspaceMode(userId?: number | null) {
+ const subscribe = useCallback((notify: () => void) => {
+ if (!userId) return () => undefined;
+
+ const key = storageKey(STORAGE_PREFIX, userId);
+ const handleStorage = (event: StorageEvent) => {
+ if (event.key === key) notify();
+ };
+
+ window.addEventListener("storage", handleStorage);
+ window.addEventListener(CHANGE_EVENT, notify);
+ return () => {
+ window.removeEventListener("storage", handleStorage);
+ window.removeEventListener(CHANGE_EVENT, notify);
+ };
+ }, [userId]);
+
+ const getSnapshot = useCallback((): MessagesWorkspaceMode =>
+ (userId ? readMode(userId) : "wide"),
+ [userId],
+ );
+
+ const mode = useSyncExternalStore(subscribe, getSnapshot, () => "wide");
+
+ const setMode = useCallback((next: MessagesWorkspaceMode) => {
+ if (!userId) return;
+ window.localStorage.setItem(storageKey(STORAGE_PREFIX, userId), next);
+ window.dispatchEvent(new Event(CHANGE_EVENT));
+ }, [userId]);
+
+ return [mode, setMode] as const;
+}
diff --git a/frontend/src/lib/messages/workspace.d.ts b/frontend/src/lib/messages/workspace.d.ts
new file mode 100644
index 00000000..e7ccb53d
--- /dev/null
+++ b/frontend/src/lib/messages/workspace.d.ts
@@ -0,0 +1,10 @@
+export type MessagesWorkspaceMode = "standard" | "wide" | "immersive";
+
+export function resolveMessagesWorkspaceMode(stored: {
+ current: string | null;
+ legacyFocus: string | null;
+ legacyImmersive: string | null;
+}): MessagesWorkspaceMode;
+
+export function getMessagesActiveChatId(pathname: string): number | undefined;
+export function isMessagesDialogPath(pathname: string): boolean;
diff --git a/frontend/src/lib/messages/workspace.js b/frontend/src/lib/messages/workspace.js
new file mode 100644
index 00000000..a69d873a
--- /dev/null
+++ b/frontend/src/lib/messages/workspace.js
@@ -0,0 +1,34 @@
+/**
+ * @typedef {"standard" | "wide" | "immersive"} MessagesWorkspaceMode
+ */
+
+/**
+ * @param {{ current: string | null; legacyFocus: string | null; legacyImmersive: string | null }} stored
+ * @returns {MessagesWorkspaceMode}
+ */
+export function resolveMessagesWorkspaceMode({ current, legacyFocus, legacyImmersive }) {
+ if (current === "standard" || current === "wide" || current === "immersive") {
+ return current;
+ }
+ if (legacyImmersive === "1") return "immersive";
+ return legacyFocus === "0" ? "standard" : "wide";
+}
+
+/**
+ * @param {string} pathname
+ * @returns {number | undefined}
+ */
+export function getMessagesActiveChatId(pathname) {
+ const match = pathname.match(/^\/messages\/(\d+)(?:\/settings)?\/?$/);
+ if (!match) return undefined;
+ const chatId = Number(match[1]);
+ return Number.isFinite(chatId) ? chatId : undefined;
+}
+
+/**
+ * @param {string} pathname
+ * @returns {boolean}
+ */
+export function isMessagesDialogPath(pathname) {
+ return /^\/messages\/\d+\/?$/.test(pathname);
+}
diff --git a/frontend/src/lib/messages/workspace.test.js b/frontend/src/lib/messages/workspace.test.js
new file mode 100644
index 00000000..f701a5bd
--- /dev/null
+++ b/frontend/src/lib/messages/workspace.test.js
@@ -0,0 +1,28 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ getMessagesActiveChatId,
+ isMessagesDialogPath,
+ resolveMessagesWorkspaceMode,
+} from "./workspace.js";
+
+test("workspace mode keeps the current setting", () => {
+ assert.equal(resolveMessagesWorkspaceMode({ current: "immersive", legacyFocus: "0", legacyImmersive: "0" }), "immersive");
+ assert.equal(resolveMessagesWorkspaceMode({ current: "standard", legacyFocus: "1", legacyImmersive: "1" }), "standard");
+});
+
+test("workspace mode migrates legacy settings", () => {
+ assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: "0", legacyImmersive: "0" }), "standard");
+ assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: "1", legacyImmersive: "0" }), "wide");
+ assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: "0", legacyImmersive: "1" }), "immersive");
+ assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: null, legacyImmersive: null }), "wide");
+});
+
+test("message paths expose chat ownership without treating settings as a dialog", () => {
+ assert.equal(getMessagesActiveChatId("/messages/42"), 42);
+ assert.equal(getMessagesActiveChatId("/messages/42/settings"), 42);
+ assert.equal(getMessagesActiveChatId("/messages"), undefined);
+ assert.equal(isMessagesDialogPath("/messages/42"), true);
+ assert.equal(isMessagesDialogPath("/messages/42/settings"), false);
+});