diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 567c67eb..36b528e9 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -37,6 +37,7 @@ 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"; @@ -148,6 +149,8 @@ function MessageDialogPageContent() { 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 ── */ @@ -1362,12 +1365,19 @@ function MessageDialogPageContent() { return ( -
- {focusMode ? ( - setFocusMode(false)} /> +
+ {workspaceMode ? ( + ) : null}
{cm.loading ? ( diff --git a/frontend/src/app/messages/[chatId]/settings/page.tsx b/frontend/src/app/messages/[chatId]/settings/page.tsx index f5c43cba..742cca7e 100644 --- a/frontend/src/app/messages/[chatId]/settings/page.tsx +++ b/frontend/src/app/messages/[chatId]/settings/page.tsx @@ -19,9 +19,12 @@ import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/cha 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() { @@ -29,6 +32,9 @@ 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); @@ -390,18 +396,39 @@ export default function ChatSettingsPage() { if (loading) { return ( - + +
+ {workspaceMode ? ( + + ) : null}

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

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

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

+
); } @@ -427,8 +455,19 @@ export default function ChatSettingsPage() { const canEdit = isOwner || isAdmin; return ( - -
+ +
+ {workspaceMode ? ( + + ) : null} +
+
{/* Хедер */}
+
+
); } diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 42305793..e6157e22 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -1,23 +1,25 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { AppShell } from "../../components/AppShell"; import { apiClient } from "@/lib/api"; -import { resolveMediaUrl } from "@/lib/url"; -import { ChatIdentity, getChatAvatar, getChatInitials, getChatTitle, isDepartmentCommentsChat } from "@/lib/messages/chatUtils"; +import { ChatIdentity, getChatTitle, isDepartmentCommentsChat } from "@/lib/messages/chatUtils"; import type { Chat } from "@/types/api"; -import { Search, MessageCircle, Pin, BellOff, Filter, Plus, X, Users, Globe, Radio, Image as ImageIcon, Megaphone, MessageSquare, Building2 } from "lucide-react"; -import Link from "next/link"; +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 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 wsManager from "@/lib/websocketManager"; +import { useMessagesImmersiveMode } from "@/hooks/useMessagesImmersiveMode"; +import { useMessagesListFilters } from "@/hooks/useMessagesListFilters"; +import { useMessagesChatList } from "@/hooks/useMessagesChatList"; -type UnreadFilter = "all" | "unread" | "read"; -type PinnedFilter = "all" | "pinned" | "unpinned"; type CreateChatPayload = { type: string; name: string; @@ -25,59 +27,6 @@ type CreateChatPayload = { avatar?: File; }; -type RealtimeChatEvent = { - type: "notification" | "list_update" | "message_edited" | string; - chat_id?: number; - notification?: { - verb?: string; - } | null; -}; - -const chatTypeOptions = [ - { key: "all", label: "Все" }, - { key: "global", label: "Глобальный" }, - { key: "channel", label: "Каналы" }, - { key: "comments", label: "Отделы" }, - { key: "private", label: "Личные" }, - { key: "group", label: "Группы" }, - { key: "announcement", label: "Объявления" }, -] as const; - -function getChatTypeIcon(chat: Chat) { - const chatType = chat.chat_type || chat.type; - switch (chatType) { - case 'global': - return ; - case 'channel': - return ; - case 'group': - return ; - case 'private': - case 'direct': - return ; - case 'announcement': - return ; - case 'comments': - return isDepartmentCommentsChat(chat) - ? - : ; - default: - return null; - } -} - -function formatTime(date?: string): string { - if (!date) return ""; - const d = new Date(date); - if (Number.isNaN(d.getTime())) return ""; - const now = new Date(); - const isToday = d.toDateString() === now.toDateString(); - if (isToday) { - return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); - } - return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); -} - function getChatSortTimestamp(chat: Chat): number { const lastMessage = chat.last_message; @@ -117,6 +66,14 @@ 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; @@ -141,15 +98,11 @@ export default function MessagesPage() { }); return ids; }, [user?.departments]); - const [chats, setChats] = useState([]); - const [search, setSearch] = useState(""); - const [chatTypeFilter, setChatTypeFilter] = useState("all"); - const [filterUnread, setFilterUnread] = useState('all'); - const [filterPinned, setFilterPinned] = useState('all'); - const [filtersOpen, setFiltersOpen] = useState(false); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - + 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'); @@ -158,10 +111,6 @@ export default function MessagesPage() { const [newChatAvatar, setNewChatAvatar] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null); const [creating, setCreating] = useState(false); - const refreshAllChatsInFlightRef = useRef(false); - const refreshChatInFlightRef = useRef>(new Set()); - const hasCompletedInitialLoadRef = useRef(false); - const wasWsConnectedRef = useRef(false); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -172,115 +121,6 @@ export default function MessagesPage() { window.history.replaceState(null, "", query ? `/messages?${query}` : "/messages"); }, []); - const loadChats = useCallback(async (options?: { showLoader?: boolean }) => { - if (refreshAllChatsInFlightRef.current) { - return; - } - - const showLoader = options?.showLoader ?? true; - refreshAllChatsInFlightRef.current = true; - - try { - if (showLoader) { - setLoading(true); - } - setError(null); - const items = await apiClient.getAllChats(); - setChats( - items.filter((chat) => - isChatVisibleInMessagesList(chat, visibleDepartmentIds) - ) - ); - hasCompletedInitialLoadRef.current = true; - } catch (e: unknown) { - console.error("Ошибка загрузки чатов:", e); - setError("Не удалось загрузить чаты"); - } finally { - refreshAllChatsInFlightRef.current = false; - if (showLoader) { - setLoading(false); - } - } - }, [visibleDepartmentIds]); - - const refreshChat = useCallback(async (chatId: number) => { - if (!Number.isFinite(chatId) || chatId <= 0) { - return; - } - - if (refreshAllChatsInFlightRef.current || refreshChatInFlightRef.current.has(chatId)) { - return; - } - - refreshChatInFlightRef.current.add(chatId); - - try { - const chat = await apiClient.getChat(chatId); - setChats((prev) => { - if (!isChatVisibleInMessagesList(chat, visibleDepartmentIds)) { - return prev.filter((item) => item.id !== chatId); - } - - const existingIndex = prev.findIndex((item) => item.id === chatId); - if (existingIndex === -1) { - return [chat, ...prev]; - } - - const next = [...prev]; - next[existingIndex] = { - ...prev[existingIndex], - ...chat, - }; - return next; - }); - } catch (e) { - console.warn(`Не удалось точечно обновить чат ${chatId}, перегружаю список`, e); - await loadChats({ showLoader: false }); - } finally { - refreshChatInFlightRef.current.delete(chatId); - } - }, [loadChats, visibleDepartmentIds]); - - useEffect(() => { - void loadChats(); - }, [loadChats]); - - useEffect(() => { - if (typeof window === "undefined" || !localStorage.getItem("access_token")) { - return; - } - - const unsubscribeMessages = wsManager.subscribe((event) => { - const data = event as RealtimeChatEvent; - - if ((data.type === "list_update" || data.type === "message_edited") && typeof data.chat_id === "number") { - void refreshChat(data.chat_id); - return; - } - - if (data.type !== "notification" || !data.notification) { - return; - } - - if (data.notification.verb === "chat_added_to_chat") { - void loadChats({ showLoader: false }); - return; - } - }); - - const unsubscribeStatus = wsManager.subscribeStatus((status) => { - if (status.isConnected && !wasWsConnectedRef.current && hasCompletedInitialLoadRef.current) { - void loadChats({ showLoader: false }); - } - wasWsConnectedRef.current = status.isConnected; - }); - - return () => { - unsubscribeMessages(); - unsubscribeStatus(); - }; - }, [loadChats, refreshChat]); - const filteredChats = useMemo(() => { const q = search.trim().toLowerCase(); @@ -423,11 +263,12 @@ export default function MessagesPage() { return ( -
+
{loading ? (
@@ -437,12 +278,21 @@ export default function MessagesPage() {

{error}

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

Сообщения

+
- {/* Поиск и кнопка фильтров */} -
-
- - setSearch(e.target.value)} - placeholder="Поиск по чатам" - className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm" - /> -
- -
- - {/* Панель дополнительных фильтров */} - {filtersOpen && ( -
- - - - - {(filterUnread !== 'all' || filterPinned !== 'all') && ( - - )} -
- )} - - {/* Фильтры по типу чата с бэйджами */} -
- {chatTypeOptions - .filter(({ key }) => key === "all" || (chatTypeCounts[key]?.total ?? 0) > 0) - .map(({ key, label }) => { - const counts = chatTypeCounts[key]; - const active = chatTypeFilter === key; - - return ( - - ); - })} -
+
{filteredChats.length === 0 ? ( @@ -560,71 +312,12 @@ export default function MessagesPage() {
) : ( filteredChats.map((chat: Chat) => ( - (() => { - const chatTitle = getChatTitle(chat, currentUserId, currentUserForMatch); - const chatAvatar = getChatAvatar(chat, currentUserId); - return ( - 0 - ? "app-selected" - : "border-transparent hover:border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]" - }`} - > -
-
-
- {chatAvatar ? ( - {chatTitle} - ) : ( - getChatInitials(chat, currentUserId, currentUserForMatch) - )} -
- {/* Иконка типа чата */} - - {getChatTypeIcon(chat)} - - {/* Счетчик непрочитанных */} - {(chat.unread_count ?? 0) > 0 ? ( - - {chat.unread_count! > 99 ? '99+' : chat.unread_count} - - ) : null} -
-
-
-
-

0 ? "font-bold" : "font-semibold" - }`}>{chatTitle}

- {chat.is_pinned ? ( - - ) : null} - {chat.notifications_enabled === false ? ( - - ) : null} -
- {formatTime(chat.last_message?.created_at)} -
-

0 ? "font-medium text-[var(--foreground)]" : "app-text-muted" - }`}> - {chat.last_message?.content || "Нет сообщений"} -

-
-
- - ); - })() + chat={chat} + currentUserId={currentUserId} + identity={currentUserForMatch} + /> )) )}
@@ -639,7 +332,7 @@ export default function MessagesPage() { ) : null} )} - {focusMode && !loading && !error ? ( + {workspaceMode && !loading && !error ? (
diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 15cf7c8f..1595b95e 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -22,6 +22,7 @@ import type { GuestVisit, GuestVisitComment } from "@/types/api"; type AppShellProps = { children: ReactNode; desktopWideMode?: boolean; + desktopImmersiveMode?: boolean; onDesktopWideModeChange?: (enabled: boolean) => void; suppressPushOnboardingPrompt?: boolean; }; @@ -415,7 +416,7 @@ function RoutedLeftNavContent(props: Omit +