diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index c9acf89d..567c67eb 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -16,6 +16,7 @@ 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"; @@ -35,6 +36,7 @@ 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 { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll"; import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils"; import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils"; @@ -145,6 +147,7 @@ function MessageDialogPageContent() { const searchParams = useSearchParams(); const chatId = Number(params.chatId); const { user, loading: userLoading } = useUser(); + const [focusMode, setFocusMode] = useMessagesFocusMode(user?.id); const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null); /* ── refs ── */ @@ -1358,7 +1361,15 @@ function MessageDialogPageContent() { * ────────────────────────────────────────────────────── */ return ( - + +
+ {focusMode ? ( + setFocusMode(false)} /> + ) : null} +
{cm.loading ? (
@@ -1590,6 +1601,8 @@ function MessageDialogPageContent() {
)} +
+
{mediaPreview ? setMediaPreview(null)} /> : null} diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index d41a3124..42305793 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -12,6 +12,8 @@ import Image from "next/image"; import { useUser } from "@/contexts/UserContext"; import { useRouter } from "next/navigation"; import { Modal } from "@/components/ui"; +import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton"; +import { useMessagesFocusMode } from "@/hooks/useMessagesFocusMode"; import wsManager from "@/lib/websocketManager"; type UnreadFilter = "all" | "unread" | "read"; @@ -114,6 +116,7 @@ function isChatVisibleInMessagesList( export default function MessagesPage() { const { user } = useUser(); const router = useRouter(); + const [focusMode, setFocusMode] = useMessagesFocusMode(user?.id); const currentUserId = user?.id; const currentUserFirstName = user?.first_name; const currentUserLastName = user?.last_name; @@ -160,6 +163,15 @@ export default function MessagesPage() { const hasCompletedInitialLoadRef = useRef(false); const wasWsConnectedRef = useRef(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 loadChats = useCallback(async (options?: { showLoader?: boolean }) => { if (refreshAllChatsInFlightRef.current) { return; @@ -410,7 +422,12 @@ export default function MessagesPage() { }; return ( - + +
{loading ? (
@@ -421,16 +438,19 @@ export default function MessagesPage() {

{error}

) : ( -
+

Сообщения

- +
+ + +
{/* Поиск и кнопка фильтров */} @@ -619,6 +639,16 @@ export default function MessagesPage() { ) : null}
)} + {focusMode && !loading && !error ? ( +
+
+ +

Выберите чат

+

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

+
+
+ ) : null} +
{/* Модальное окно создания чата */} setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={ diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index b969fad8..15cf7c8f 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -23,6 +23,7 @@ type AppShellProps = { children: ReactNode; desktopWideMode?: boolean; onDesktopWideModeChange?: (enabled: boolean) => void; + suppressPushOnboardingPrompt?: boolean; }; type PageHeaderProps = { @@ -590,7 +591,12 @@ function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) { ); } -export function AppShell({ children, desktopWideMode = false, onDesktopWideModeChange }: AppShellProps) { +export function AppShell({ + children, + desktopWideMode = false, + onDesktopWideModeChange, + suppressPushOnboardingPrompt = false, +}: AppShellProps) { const { user, loading } = useUser(); const { mobileNavPlacement } = useMobileNavPlacement(); const router = useRouter(); @@ -681,7 +687,7 @@ export function AppShell({ children, desktopWideMode = false, onDesktopWideModeC
- {!isMessageDialogPage ? : null} + {!isMessageDialogPage && !suppressPushOnboardingPrompt ? : null} {children}
void; +}; + +export default function MessagesFocusModeButton({ + enabled, + onChange, +}: MessagesFocusModeButtonProps) { + const label = enabled ? "Вернуть обычный вид" : "Открыть режим мессенджера"; + + return ( + + ); +} diff --git a/frontend/src/components/messages/MessagesFocusSidebar.tsx b/frontend/src/components/messages/MessagesFocusSidebar.tsx new file mode 100644 index 00000000..c6020152 --- /dev/null +++ b/frontend/src/components/messages/MessagesFocusSidebar.tsx @@ -0,0 +1,191 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { BellOff, MessageCircle, Pin, Plus, Search } from "lucide-react"; + +import { useUser } from "@/contexts/UserContext"; +import { apiClient } from "@/lib/api"; +import { + ChatIdentity, + getChatAvatar, + getChatInitials, + getChatTitle, + isDepartmentCommentsChat, +} from "@/lib/messages/chatUtils"; +import { resolveMediaUrl } from "@/lib/url"; +import wsManager from "@/lib/websocketManager"; +import type { Chat } from "@/types/api"; +import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton"; + +type MessagesFocusSidebarProps = { + activeChatId?: number; + onExit: () => 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; +} + +function formatTime(value?: string): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + if (date.toDateString() === new Date().toDateString()) { + return date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); + } + return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); +} + +export default function MessagesFocusSidebar({ activeChatId, onExit }: MessagesFocusSidebarProps) { + const { user } = useUser(); + const [chats, setChats] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + + 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 loadChats = useCallback(async (showLoader = false) => { + if (showLoader) setLoading(true); + try { + const items = await apiClient.getAllChats(); + setChats(items.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); + })); + } catch (error) { + console.error("Ошибка загрузки списка чатов в режиме мессенджера:", error); + } finally { + if (showLoader) setLoading(false); + } + }, [departmentIds]); + + useEffect(() => { + void loadChats(true); + }, [loadChats]); + + useEffect(() => { + const unsubscribe = wsManager.subscribe((event) => { + const data = event as { type?: string; chat_id?: number; notification?: { verb?: string } }; + if ( + data.type === "list_update" + || data.type === "message_edited" + || (data.type === "notification" && data.notification?.verb === "chat_added_to_chat") + ) { + void loadChats(); + } + }); + return unsubscribe; + }, [loadChats]); + + 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) => { + if (!query) return true; + const title = getChatTitle(chat, user?.id, identity).toLowerCase(); + return title.includes(query) || (chat.last_message?.content || "").toLowerCase().includes(query); + }); + }, [chats, identity, search, user?.id]); + + return ( + + ); +} diff --git a/frontend/src/hooks/useMessagesFocusMode.ts b/frontend/src/hooks/useMessagesFocusMode.ts new file mode 100644 index 00000000..be3ce52d --- /dev/null +++ b/frontend/src/hooks/useMessagesFocusMode.ts @@ -0,0 +1,43 @@ +"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( + () => (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; +}