fix(messages): stabilize focused workspace layout

This commit is contained in:
lizerk 2026-08-05 12:17:31 +03:00
parent 6084bb695c
commit 8914f1998f
14 changed files with 860 additions and 496 deletions

View File

@ -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 (
<AppShell
desktopWideMode={focusMode}
desktopWideMode={workspaceMode}
desktopImmersiveMode={immersiveMode}
onDesktopWideModeChange={setFocusMode}
>
<div className={`min-h-0 h-full ${focusMode ? "lg:flex lg:gap-3" : ""}`}>
{focusMode ? (
<MessagesFocusSidebar activeChatId={chatId} onExit={() => setFocusMode(false)} />
<div className={`min-h-0 h-full ${workspaceMode ? "lg:flex lg:gap-3" : ""}`}>
{workspaceMode ? (
<MessagesFocusSidebar
activeChatId={chatId}
focusMode={focusMode}
onFocusModeChange={setFocusMode}
immersiveMode={immersiveMode}
onImmersiveModeChange={setImmersiveMode}
/>
) : null}
<div className="min-h-0 h-full min-w-0 flex-1">
{cm.loading ? (

View File

@ -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<Chat | null>(null);
@ -390,18 +396,39 @@ export default function ChatSettingsPage() {
if (loading) {
return (
<AppShell>
<AppShell desktopWideMode={workspaceMode} desktopImmersiveMode={immersiveMode} onDesktopWideModeChange={setFocusMode} suppressPushOnboardingPrompt={workspaceMode}>
<div className={`h-full min-h-0 ${workspaceMode ? "lg:flex lg:gap-3" : ""}`}>
{workspaceMode ? (
<MessagesFocusSidebar
activeChatId={chatId}
focusMode={focusMode}
onFocusModeChange={setFocusMode}
immersiveMode={immersiveMode}
onImmersiveModeChange={setImmersiveMode}
/>
) : null}
<div className="app-surface mx-auto max-w-3xl rounded-2xl p-8 text-center">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted text-sm">Загрузка настроек...</p>
</div>
</div>
</AppShell>
);
}
if (error || !chat) {
return (
<AppShell>
<AppShell desktopWideMode={workspaceMode} desktopImmersiveMode={immersiveMode} onDesktopWideModeChange={setFocusMode} suppressPushOnboardingPrompt={workspaceMode}>
<div className={`h-full min-h-0 ${workspaceMode ? "lg:flex lg:gap-3" : ""}`}>
{workspaceMode ? (
<MessagesFocusSidebar
activeChatId={chatId}
focusMode={focusMode}
onFocusModeChange={setFocusMode}
immersiveMode={immersiveMode}
onImmersiveModeChange={setImmersiveMode}
/>
) : null}
<div className="app-feedback-danger mx-auto max-w-3xl rounded-2xl p-6 text-center">
<p className="text-sm">{error || "Чат не найден"}</p>
<Link
@ -411,6 +438,7 @@ export default function ChatSettingsPage() {
Вернуться к списку чатов
</Link>
</div>
</div>
</AppShell>
);
}
@ -427,8 +455,19 @@ export default function ChatSettingsPage() {
const canEdit = isOwner || isAdmin;
return (
<AppShell>
<div className="mx-auto max-w-3xl">
<AppShell desktopWideMode={workspaceMode} desktopImmersiveMode={immersiveMode} onDesktopWideModeChange={setFocusMode} suppressPushOnboardingPrompt={workspaceMode}>
<div className={`h-full min-h-0 ${workspaceMode ? "lg:flex lg:gap-3" : ""}`}>
{workspaceMode ? (
<MessagesFocusSidebar
activeChatId={chatId}
focusMode={focusMode}
onFocusModeChange={setFocusMode}
immersiveMode={immersiveMode}
onImmersiveModeChange={setImmersiveMode}
/>
) : null}
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl pb-4">
{/* Хедер */}
<div className="mb-6 flex items-center gap-3">
<Link
@ -633,6 +672,8 @@ export default function ChatSettingsPage() {
onSearchChange={handleMemberSearch}
onAddMember={handleAddMember}
/>
</div>
</div>
</AppShell>
);
}

View File

@ -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 <Globe size={10} className="app-accent-text" />;
case 'channel':
return <Radio size={10} className="app-accent-text" />;
case 'group':
return <Users size={10} className="app-accent-text" />;
case 'private':
case 'direct':
return <MessageCircle size={10} className="app-accent-text" />;
case 'announcement':
return <Megaphone size={10} className="app-accent-text" />;
case 'comments':
return isDepartmentCommentsChat(chat)
? <Building2 size={10} className="app-accent-text" />
: <MessageSquare size={10} className="app-accent-text" />;
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<Chat[]>([]);
const [search, setSearch] = useState("");
const [chatTypeFilter, setChatTypeFilter] = useState<string>("all");
const [filterUnread, setFilterUnread] = useState<UnreadFilter>('all');
const [filterPinned, setFilterPinned] = useState<PinnedFilter>('all');
const [filtersOpen, setFiltersOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const refreshAllChatsInFlightRef = useRef(false);
const refreshChatInFlightRef = useRef<Set<number>>(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 (
<AppShell
desktopWideMode={focusMode}
desktopWideMode={workspaceMode}
desktopImmersiveMode={immersiveMode}
onDesktopWideModeChange={setFocusMode}
suppressPushOnboardingPrompt={focusMode}
suppressPushOnboardingPrompt={workspaceMode}
>
<div className={focusMode ? "lg:flex lg:min-h-0 lg:flex-1 lg:gap-3" : ""}>
<div className={workspaceMode ? "lg:flex lg:min-h-0 lg:flex-1 lg:gap-3" : ""}>
{loading ? (
<div className="app-surface rounded-2xl p-8 text-center">
<div className="mb-4 inline-block h-8 w-8 animate-spin rounded-full border-4 border-sky-400 border-t-transparent" />
@ -437,12 +278,21 @@ export default function MessagesPage() {
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-6 text-center">
<p className="text-sm text-red-800">{error}</p>
</div>
) : workspaceMode ? (
<MessagesFocusSidebar
focusMode={focusMode}
onFocusModeChange={setFocusMode}
immersiveMode={immersiveMode}
onImmersiveModeChange={setImmersiveMode}
onCreateChat={() => setShowCreateModal(true)}
/>
) : (
<section className={`app-surface rounded-2xl p-4 ${focusMode ? "lg:min-h-0 lg:w-[24rem] lg:shrink-0 lg:overflow-y-auto" : ""}`}>
<section className={`app-surface rounded-2xl p-4 ${workspaceMode ? "lg:min-h-0 lg:w-[24rem] lg:shrink-0 lg:overflow-y-auto" : ""}`}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<p className="app-text-muted text-sm font-semibold uppercase tracking-wide">Сообщения</p>
<div className="flex items-center gap-1">
<MessagesFocusModeButton enabled={focusMode} onChange={setFocusMode} />
<MessagesImmersiveModeButton enabled={immersiveMode} onChange={setImmersiveMode} />
<button
type="button"
onClick={() => setShowCreateModal(true)}
@ -453,105 +303,7 @@ export default function MessagesPage() {
</div>
</div>
{/* Поиск и кнопка фильтров */}
<div className="mb-4 flex gap-2">
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 app-text-muted" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по чатам"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<button
type="button"
onClick={() => setFiltersOpen((v) => !v)}
className={`relative inline-flex items-center justify-center rounded-lg p-2.5 transition ${
filtersOpen
? "app-selected app-accent-text"
: "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
}`}
>
<Filter size={16} />
{(filterUnread !== 'all' || filterPinned !== 'all') && (
<span className="app-badge app-badge-accent absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border px-1 text-[10px] font-bold">
{[filterUnread !== 'all', filterPinned !== 'all'].filter(Boolean).length}
</span>
)}
</button>
</div>
{/* Панель дополнительных фильтров */}
{filtersOpen && (
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
<select
value={filterUnread}
onChange={(e) => setFilterUnread(e.target.value as UnreadFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="all">Все чаты</option>
<option value="unread">Только с непрочитанными</option>
<option value="read">Только прочитанные</option>
</select>
<select
value={filterPinned}
onChange={(e) => setFilterPinned(e.target.value as PinnedFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="all">Все чаты</option>
<option value="pinned">Только закрепленные</option>
<option value="unpinned">Только незакрепленные</option>
</select>
{(filterUnread !== 'all' || filterPinned !== 'all') && (
<button
type="button"
onClick={() => {
setFilterUnread('all');
setFilterPinned('all');
}}
className="app-surface-elevated rounded-lg px-3 py-2 text-sm font-medium text-[var(--muted-foreground)] transition hover:bg-[var(--surface-secondary)]"
>
Очистить фильтры
</button>
)}
</div>
)}
{/* Фильтры по типу чата с бэйджами */}
<div className="mb-4 flex flex-wrap gap-2">
{chatTypeOptions
.filter(({ key }) => key === "all" || (chatTypeCounts[key]?.total ?? 0) > 0)
.map(({ key, label }) => {
const counts = chatTypeCounts[key];
const active = chatTypeFilter === key;
return (
<button
key={key}
onClick={() => setChatTypeFilter(key)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span>{label}</span>
<span className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}>
<span>{counts.total}</span>
{counts.unread > 0 ? (
<>
<span className="app-text-muted">•</span>
<span className="app-accent-text">{counts.unread}</span>
</>
) : null}
</span>
</button>
);
})}
</div>
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
<div className="space-y-2">
{filteredChats.length === 0 ? (
@ -560,71 +312,12 @@ export default function MessagesPage() {
</div>
) : (
filteredChats.map((chat: Chat) => (
(() => {
const chatTitle = getChatTitle(chat, currentUserId, currentUserForMatch);
const chatAvatar = getChatAvatar(chat, currentUserId);
return (
<Link
<MessagesChatListItem
key={chat.id}
href={`/messages/${chat.id}`}
className={`block w-full rounded-xl border p-3 text-left transition ${
(chat.unread_count ?? 0) > 0
? "app-selected"
: "border-transparent hover:border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]"
}`}
>
<div className="flex items-start gap-3">
<div className="relative h-10 w-10 overflow-visible">
<div className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
{chatAvatar ? (
<Image
src={resolveMediaUrl(chatAvatar)}
alt={chatTitle}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getChatInitials(chat, currentUserId, currentUserForMatch)
)}
</div>
{/* Иконка типа чата */}
<span className="app-badge app-badge-accent absolute -bottom-0.5 -left-0.5 z-10 flex h-4 w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)]">
{getChatTypeIcon(chat)}
</span>
{/* Счетчик непрочитанных */}
{(chat.unread_count ?? 0) > 0 ? (
<span className="chat-list-unread-badge app-counter-danger absolute right-0 top-0 z-20 flex min-w-[1.35rem] -translate-y-1/4 translate-x-1/4 items-center justify-center px-1.5 text-[10px] font-bold leading-none">
{chat.unread_count! > 99 ? '99+' : chat.unread_count}
</span>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0">
<p className={`truncate text-sm text-[var(--foreground)] ${
(chat.unread_count ?? 0) > 0 ? "font-bold" : "font-semibold"
}`}>{chatTitle}</p>
{chat.is_pinned ? (
<Pin size={12} className="app-accent-text shrink-0 fill-current" />
) : null}
{chat.notifications_enabled === false ? (
<BellOff size={12} className="app-text-muted shrink-0" />
) : null}
</div>
<span className="app-text-muted shrink-0 text-xs">{formatTime(chat.last_message?.created_at)}</span>
</div>
<p className={`mt-1 truncate text-xs ${
(chat.unread_count ?? 0) > 0 ? "font-medium text-[var(--foreground)]" : "app-text-muted"
}`}>
{chat.last_message?.content || "Нет сообщений"}
</p>
</div>
</div>
</Link>
);
})()
chat={chat}
currentUserId={currentUserId}
identity={currentUserForMatch}
/>
))
)}
</div>
@ -639,7 +332,7 @@ export default function MessagesPage() {
) : null}
</section>
)}
{focusMode && !loading && !error ? (
{workspaceMode && !loading && !error ? (
<section className="app-surface-muted hidden min-h-0 flex-1 items-center justify-center rounded-xl border border-dashed border-[var(--border-subtle)] lg:flex">
<div className="max-w-xs text-center">
<MessageCircle size={28} className="app-text-muted mx-auto mb-3" />

View File

@ -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<LeftNavContentProps, "documentSection"
function LeftNav({ fixedDesktop = false, compact = false }: { fixedDesktop?: boolean; compact?: boolean }) {
return (
<aside className={`hidden flex-shrink-0 transition-[width] duration-200 lg:block ${compact ? "w-14" : "w-64"}`}>
<aside className={`hidden shrink-0 transition-[width] duration-200 lg:block ${compact ? "w-14" : "w-64"}`}>
<div
className={`space-y-4 lg:overflow-y-auto ${
fixedDesktop
@ -594,6 +595,7 @@ function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) {
export function AppShell({
children,
desktopWideMode = false,
desktopImmersiveMode = false,
onDesktopWideModeChange,
suppressPushOnboardingPrompt = false,
}: AppShellProps) {
@ -677,20 +679,22 @@ export function AppShell({
}
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden lg:pt-14' : 'min-h-screen pt-10 lg:pt-14'} ${desktopWideMode ? "lg:h-[100dvh] lg:overflow-hidden" : ""} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !isMessageDialogPage ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen pt-10'} ${desktopImmersiveMode ? "lg:pt-0" : "lg:pt-14"} ${desktopWideMode ? "lg:h-[100dvh] lg:overflow-hidden" : ""} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !isMessageDialogPage ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<div className={desktopImmersiveMode ? "lg:hidden" : ""}>
<Header
mobileNavPlacement={mobileNavPlacement}
suppressMobileChrome={isMessageDialogPage}
onOpenLeftNav={() => setIsMobileLeftNavOpen(true)}
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${desktopWideMode ? "lg:max-w-none" : ""} ${isMessageDialogPage ? 'gap-0 px-0 py-0 lg:gap-6 lg:px-8 lg:py-4' : `gap-6 px-4 py-4 sm:px-8 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : ""}`}`}>
<LeftNav fixedDesktop compact={desktopWideMode} />
</div>
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${desktopWideMode ? "lg:max-w-none" : ""} ${desktopImmersiveMode ? "gap-0 px-0 py-0 lg:gap-0 lg:px-3 lg:py-3" : isMessageDialogPage ? `gap-0 px-0 py-0 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : "lg:gap-6 lg:px-8"}` : `gap-6 px-4 py-4 sm:px-8 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : ""}`}`}>
{!desktopImmersiveMode ? <LeftNav fixedDesktop compact={desktopWideMode} /> : null}
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''} ${desktopWideMode ? "lg:flex lg:flex-col lg:gap-4 lg:space-y-0 lg:overflow-hidden" : ""}`}>
{!isMessageDialogPage && !suppressPushOnboardingPrompt ? <PushOnboardingPrompt /> : null}
{children}
</main>
<CalendarSidebar
{!desktopImmersiveMode ? <CalendarSidebar
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
@ -701,7 +705,7 @@ export function AppShell({
fixedDesktop
compact={desktopWideMode}
onExpand={() => onDesktopWideModeChange?.(false)}
/>
/> : null}
</div>
<MobileLeftDrawer

View File

@ -32,7 +32,7 @@ export const CalendarSidebar = memo(function CalendarSidebar(props: CalendarSide
if (compact) {
return (
<aside className="hidden w-14 flex-shrink-0 transition-[width] duration-200 lg:block">
<aside className="hidden w-14 shrink-0 transition-[width] duration-200 lg:block">
<div
className={`lg:w-14 ${
fixedDesktop
@ -57,7 +57,7 @@ export const CalendarSidebar = memo(function CalendarSidebar(props: CalendarSide
}
return (
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
<aside className="hidden w-72 shrink-0 space-y-4 lg:block">
<div
className={`space-y-4 lg:overflow-y-auto ${
fixedDesktop

View File

@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { Filter, Search } from "lucide-react";
import {
MESSAGE_CHAT_TYPE_OPTIONS,
type MessagePinnedFilter,
type MessageUnreadFilter,
useMessagesListFilters,
} from "@/hooks/useMessagesListFilters";
type ChatTypeCounts = Record<string, { total: number; unread: number }>;
type MessagesChatListControlsProps = {
userId?: number;
chatTypeCounts: ChatTypeCounts;
};
export default function MessagesChatListControls({
userId,
chatTypeCounts,
}: MessagesChatListControlsProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const {
search,
setSearch,
chatTypeFilter,
setChatTypeFilter,
filterUnread,
setFilterUnread,
filterPinned,
setFilterPinned,
} = useMessagesListFilters(userId);
return (
<>
<div className="mb-4 flex gap-2">
<div className="relative min-w-0 flex-1">
<Search
size={16}
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
/>
<input
data-testid="messages-chat-search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск по чатам"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
/>
</div>
<button
type="button"
onClick={() => setFiltersOpen((current) => !current)}
className={`relative inline-flex shrink-0 items-center justify-center rounded-lg p-2.5 transition ${
filtersOpen
? "app-selected app-accent-text"
: "app-surface-muted app-text-muted hover:bg-[var(--surface-tertiary)]"
}`}
title="Дополнительные фильтры"
aria-label="Дополнительные фильтры"
aria-expanded={filtersOpen}
>
<Filter size={16} />
{filterUnread !== "all" || filterPinned !== "all" ? (
<span className="app-badge app-badge-accent absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full border px-1 text-[10px] font-bold">
{[filterUnread !== "all", filterPinned !== "all"].filter(Boolean).length}
</span>
) : null}
</button>
</div>
{filtersOpen ? (
<div className="app-surface-muted mb-4 flex flex-col gap-2 rounded-xl p-3">
<select
value={filterUnread}
onChange={(event) => setFilterUnread(event.target.value as MessageUnreadFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="all">Все чаты</option>
<option value="unread">Только с непрочитанными</option>
<option value="read">Только прочитанные</option>
</select>
<select
value={filterPinned}
onChange={(event) => setFilterPinned(event.target.value as MessagePinnedFilter)}
className="app-select rounded-lg px-3 py-2 text-sm"
>
<option value="all">Все чаты</option>
<option value="pinned">Только закрепленные</option>
<option value="unpinned">Только незакрепленные</option>
</select>
{filterUnread !== "all" || filterPinned !== "all" ? (
<button
type="button"
onClick={() => {
setFilterUnread("all");
setFilterPinned("all");
}}
className="app-surface-elevated rounded-lg px-3 py-2 text-sm font-medium text-[var(--muted-foreground)] transition hover:bg-[var(--surface-secondary)]"
>
Очистить фильтры
</button>
) : null}
</div>
) : null}
<div className="mb-4 flex flex-wrap gap-2">
{MESSAGE_CHAT_TYPE_OPTIONS
.filter(({ key }) => key === "all" || (chatTypeCounts[key]?.total ?? 0) > 0)
.map(({ key, label }) => {
const counts = chatTypeCounts[key];
const active = chatTypeFilter === key;
return (
<button
key={key}
data-testid="messages-chat-type-filter"
type="button"
onClick={() => setChatTypeFilter(key)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition ${
active ? "app-pill-active" : "app-pill"
}`}
>
<span>{label}</span>
<span
className={`app-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-bold ${
active ? "app-pill-count-active" : "app-pill-count"
}`}
>
<span>{counts.total}</span>
{counts.unread > 0 ? (
<>
<span className="app-text-muted">•</span>
<span className="app-accent-text">{counts.unread}</span>
</>
) : null}
</span>
</button>
);
})}
</div>
</>
);
}

View File

@ -0,0 +1,132 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import {
BellOff,
Building2,
Globe,
Megaphone,
MessageCircle,
MessageSquare,
Pin,
Radio,
Users,
} from "lucide-react";
import {
type ChatIdentity,
getChatAvatar,
getChatInitials,
getChatTitle,
isDepartmentCommentsChat,
} from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat } from "@/types/api";
type MessagesChatListItemProps = {
chat: Chat;
currentUserId?: number;
identity: ChatIdentity;
active?: boolean;
};
function getChatTypeIcon(chat: Chat) {
const chatType = chat.chat_type || chat.type;
switch (chatType) {
case "global":
return <Globe size={10} className="app-accent-text" />;
case "channel":
return <Radio size={10} className="app-accent-text" />;
case "group":
return <Users size={10} className="app-accent-text" />;
case "private":
case "direct":
return <MessageCircle size={10} className="app-accent-text" />;
case "announcement":
return <Megaphone size={10} className="app-accent-text" />;
case "comments":
return isDepartmentCommentsChat(chat) ? (
<Building2 size={10} className="app-accent-text" />
) : (
<MessageSquare size={10} className="app-accent-text" />
);
default:
return null;
}
}
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 MessagesChatListItem({
chat,
currentUserId,
identity,
active = false,
}: MessagesChatListItemProps) {
const title = getChatTitle(chat, currentUserId, identity);
const avatar = getChatAvatar(chat, currentUserId);
const unread = chat.unread_count ?? 0;
return (
<Link
data-testid="messages-chat-list-item"
href={`/messages/${chat.id}`}
className={`block w-full rounded-xl border p-3 text-left transition ${
active || unread > 0
? "app-selected"
: "border-transparent hover:border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]"
}`}
>
<div className="flex items-start gap-3">
<div className="relative h-10 w-10 shrink-0 overflow-visible">
<div data-testid="messages-chat-avatar" className="app-avatar-fallback flex h-10 w-10 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
{avatar ? (
<Image
src={resolveMediaUrl(avatar)}
alt={title}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
/>
) : (
getChatInitials(chat, currentUserId, identity)
)}
</div>
<span className="app-badge app-badge-accent absolute -bottom-0.5 -left-0.5 z-10 flex h-4 w-4 items-center justify-center rounded-full border-2 border-[var(--surface-primary)]">
{getChatTypeIcon(chat)}
</span>
{unread > 0 ? (
<span className="chat-list-unread-badge app-counter-danger absolute right-0 top-0 z-20 flex min-w-[1.35rem] -translate-y-1/4 translate-x-1/4 items-center justify-center px-1.5 text-[10px] font-bold leading-none">
{unread > 99 ? "99+" : unread}
</span>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<p className={`truncate text-sm text-[var(--foreground)] ${unread > 0 ? "font-bold" : "font-semibold"}`}>
{title}
</p>
{chat.is_pinned ? <Pin size={12} className="app-accent-text shrink-0 fill-current" /> : null}
{chat.notifications_enabled === false ? <BellOff size={12} className="app-text-muted shrink-0" /> : null}
</div>
<span className="app-text-muted shrink-0 text-xs">{formatTime(chat.last_message?.created_at)}</span>
</div>
<p className={`mt-1 truncate text-xs ${unread > 0 ? "font-medium text-[var(--foreground)]" : "app-text-muted"}`}>
{chat.last_message?.content || "Нет сообщений"}
</p>
</div>
</div>
</Link>
);
}

View File

@ -11,7 +11,7 @@ export default function MessagesFocusModeButton({
enabled,
onChange,
}: MessagesFocusModeButtonProps) {
const label = enabled ? "Вернуть обычный вид" : "Открыть режим мессенджера";
const label = enabled ? "Вернуть обычный вид" : "Развернуть рабочую область";
return (
<button

View File

@ -1,27 +1,30 @@
"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 { useMemo } from "react";
import { Plus } 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 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";
type MessagesFocusSidebarProps = {
activeChatId?: number;
onExit: () => void;
focusMode: boolean;
onFocusModeChange: (enabled: boolean) => void;
immersiveMode: boolean;
onImmersiveModeChange: (enabled: boolean) => void;
onCreateChat?: () => void;
};
function chatTimestamp(chat: Chat): number {
@ -30,21 +33,21 @@ function chatTimestamp(chat: Chat): number {
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) {
export default function MessagesFocusSidebar({
activeChatId,
focusMode,
onFocusModeChange,
immersiveMode,
onImmersiveModeChange,
onCreateChat,
}: MessagesFocusSidebarProps) {
const { user } = useUser();
const [chats, setChats] = useState<Chat[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const {
search,
chatTypeFilter,
filterUnread,
filterPinned,
} = useMessagesListFilters(user?.id);
const identity = useMemo<ChatIdentity>(() => ({
first_name: user?.first_name,
@ -57,135 +60,110 @@ export default function MessagesFocusSidebar({ activeChatId, onExit }: MessagesF
() => 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 { 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);
}));
} 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]);
}),
[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);
});
}, [chats, identity, search, user?.id]);
}, [chatTypeFilter, chats, filterPinned, filterUnread, identity, search, user?.id]);
const chatTypeCounts = useMemo(() => {
const counts: Record<string, { total: number; unread: number }> = {
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 (
<aside className="app-surface hidden min-h-0 w-[21rem] shrink-0 flex-col overflow-hidden rounded-xl lg:flex">
<div className="app-divider flex items-center justify-between gap-2 border-b p-3">
<aside data-testid="messages-focus-sidebar" className="app-surface hidden min-h-0 w-[24rem] shrink-0 flex-col overflow-hidden rounded-2xl lg:flex">
<div className="flex items-center justify-between gap-2 p-4 pb-0">
<p className="app-card-caption min-w-0 truncate">Сообщения</p>
<div className="flex shrink-0 items-center gap-1">
<MessagesFocusModeButton enabled onChange={() => onExit()} />
<Link
href="/messages?createChat=1"
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-xs font-semibold"
>
<Plus size={14} />
Создать чат
</Link>
</div>
</div>
<div className="p-3 pb-2">
<div className="relative">
<Search size={15} className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Поиск по чатам"
className="app-input w-full rounded-lg py-2 pl-9 pr-3 text-sm"
<MessagesFocusModeButton
enabled={focusMode}
onChange={onFocusModeChange}
/>
<MessagesImmersiveModeButton
enabled={immersiveMode}
onChange={onImmersiveModeChange}
/>
{onCreateChat ? (
<button
type="button"
onClick={onCreateChat}
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold"
>
<Plus size={14} />
Создать чат
</button>
) : (
<Link
href="/messages?createChat=1"
className="app-action-primary inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-semibold"
>
<Plus size={14} />
Создать чат
</Link>
)}
</div>
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto px-2 pb-2">
<div className="p-4 pb-0">
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-4 pb-4">
{loading ? (
<p className="app-text-muted px-3 py-6 text-center text-xs">Загрузка чатов...</p>
) : visibleChats.length === 0 ? (
<p className="app-text-muted px-3 py-6 text-center text-xs">Чаты не найдены</p>
) : visibleChats.map((chat) => {
const title = getChatTitle(chat, user?.id, identity);
const avatar = getChatAvatar(chat, user?.id);
const active = chat.id === activeChatId;
return (
<Link
key={chat.id}
href={`/messages/${chat.id}`}
className={`flex items-center gap-2.5 rounded-lg p-2 transition ${
active ? "app-selected" : "hover:bg-[var(--surface-secondary)]"
}`}
>
<div className="app-avatar-fallback relative flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full text-xs font-semibold">
{avatar ? (
<Image
src={resolveMediaUrl(avatar)}
alt={title}
width={36}
height={36}
unoptimized
className="h-full w-full object-cover"
/>
) : getChatInitials(chat, user?.id, identity)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className={`min-w-0 flex-1 truncate text-xs ${chat.unread_count ? "font-bold" : "font-semibold"}`}>{title}</p>
{chat.is_pinned ? <Pin size={11} className="app-accent-text shrink-0 fill-current" /> : null}
{chat.notifications_enabled === false ? <BellOff size={11} className="app-text-muted shrink-0" /> : null}
<span className="app-text-muted shrink-0 text-[10px]">{formatTime(chat.last_message?.created_at)}</span>
</div>
<div className="mt-0.5 flex items-center gap-2">
<p className="app-text-muted min-w-0 flex-1 truncate text-[11px]">{chat.last_message?.content || "Нет сообщений"}</p>
{(chat.unread_count || 0) > 0 ? (
<span className="app-counter-danger flex min-w-5 shrink-0 items-center justify-center px-1 text-[10px] font-bold">
{chat.unread_count! > 99 ? "99+" : chat.unread_count}
</span>
) : null}
</div>
</div>
</Link>
);
})}
) : visibleChats.map((chat) => (
<MessagesChatListItem
key={chat.id}
chat={chat}
currentUserId={user?.id}
identity={identity}
active={chat.id === activeChatId}
/>
))}
</div>
{!loading && visibleChats.length === 0 ? (
<MessageCircle size={18} className="app-text-muted mx-auto mb-3" />
) : null}
</aside>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { PanelTopClose, PanelTopOpen } from "lucide-react";
type MessagesImmersiveModeButtonProps = {
enabled: boolean;
onChange: (enabled: boolean) => void;
};
export default function MessagesImmersiveModeButton({
enabled,
onChange,
}: MessagesImmersiveModeButtonProps) {
const label = enabled ? "Выйти из режима мессенджера" : "Открыть режим мессенджера";
return (
<button
type="button"
onClick={() => onChange(!enabled)}
className="app-action-ghost hidden h-9 w-9 shrink-0 items-center justify-center rounded-md lg:inline-flex"
title={label}
aria-label={label}
aria-pressed={enabled}
>
{enabled ? <PanelTopOpen size={17} /> : <PanelTopClose size={17} />}
</button>
);
}

View File

@ -0,0 +1,167 @@
"use client";
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { apiClient } from "@/lib/api";
import wsManager from "@/lib/websocketManager";
import type { Chat } from "@/types/api";
type ChatListSnapshot = {
chats: Chat[];
loading: boolean;
error: string | null;
loaded: boolean;
};
type RealtimeChatEvent = {
type?: string;
chat_id?: number;
notification?: { verb?: string } | null;
};
const EMPTY_SNAPSHOT: ChatListSnapshot = {
chats: [],
loading: true,
error: null,
loaded: false,
};
let activeUserId: number | null = null;
let snapshot: ChatListSnapshot = EMPTY_SNAPSHOT;
let loadPromise: Promise<void> | null = null;
let consumerCount = 0;
let realtimeCleanup: (() => void) | null = null;
let cleanupTimer: ReturnType<typeof setTimeout> | null = null;
let wasWsConnected = false;
let hasConnectedOnce = false;
const listeners = new Set<() => void>();
const refreshingChatIds = new Set<number>();
function emitChange() {
listeners.forEach((listener) => listener());
}
function updateSnapshot(next: ChatListSnapshot) {
snapshot = next;
emitChange();
}
async function loadAllChats(showLoader: boolean) {
if (loadPromise) return loadPromise;
if (showLoader) {
updateSnapshot({ ...snapshot, loading: true, error: null });
}
loadPromise = (async () => {
try {
const chats = await apiClient.getAllChats();
updateSnapshot({ chats, loading: false, error: null, loaded: true });
} catch (error) {
console.error("Ошибка загрузки чатов:", error);
updateSnapshot({ ...snapshot, loading: false, error: "Не удалось загрузить чаты" });
} finally {
loadPromise = null;
}
})();
return loadPromise;
}
async function refreshChat(chatId: number) {
if (!Number.isFinite(chatId) || chatId <= 0 || refreshingChatIds.has(chatId)) return;
refreshingChatIds.add(chatId);
try {
const chat = await apiClient.getChat(chatId);
const existingIndex = snapshot.chats.findIndex((item) => item.id === chatId);
const chats = [...snapshot.chats];
if (existingIndex === -1) {
chats.unshift(chat);
} else {
chats[existingIndex] = { ...chats[existingIndex], ...chat };
}
updateSnapshot({ ...snapshot, chats, error: null });
} catch (error) {
console.warn(`Не удалось обновить чат ${chatId}:`, error);
} finally {
refreshingChatIds.delete(chatId);
}
}
function startRealtime() {
if (realtimeCleanup || !window.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?.verb === "chat_added_to_chat") {
void loadAllChats(false);
}
});
const unsubscribeStatus = wsManager.subscribeStatus((status) => {
if (status.isConnected && hasConnectedOnce && !wasWsConnected && snapshot.loaded) {
void loadAllChats(false);
}
if (status.isConnected) hasConnectedOnce = true;
wasWsConnected = status.isConnected;
});
realtimeCleanup = () => {
unsubscribeMessages();
unsubscribeStatus();
realtimeCleanup = null;
wasWsConnected = false;
hasConnectedOnce = false;
};
}
function retainChatList(userId: number) {
if (cleanupTimer) {
clearTimeout(cleanupTimer);
cleanupTimer = null;
}
if (activeUserId !== userId) {
activeUserId = userId;
snapshot = EMPTY_SNAPSHOT;
loadPromise = null;
refreshingChatIds.clear();
emitChange();
}
consumerCount += 1;
startRealtime();
if (!snapshot.loaded && !loadPromise) void loadAllChats(true);
return () => {
consumerCount = Math.max(0, consumerCount - 1);
if (consumerCount > 0) return;
cleanupTimer = setTimeout(() => {
if (consumerCount === 0) realtimeCleanup?.();
cleanupTimer = null;
}, 250);
};
}
export function useMessagesChatList(userId?: number | null) {
const subscribe = useCallback((listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
}, []);
const getSnapshot = useCallback(
() => (userId && activeUserId === userId ? snapshot : EMPTY_SNAPSHOT),
[userId],
);
const state = useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_SNAPSHOT);
useEffect(() => {
if (!userId) return;
return retainChatList(userId);
}, [userId]);
return state;
}

View File

@ -26,10 +26,11 @@ export function useMessagesFocusMode(userId?: number | null) {
};
}, [userId]);
const getSnapshot = useCallback(
() => (userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false),
[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);

View File

@ -0,0 +1,43 @@
"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;
}

View File

@ -0,0 +1,120 @@
"use client";
import { useCallback, useMemo, useSyncExternalStore } from "react";
export type MessageChatTypeFilter = "all" | "global" | "channel" | "comments" | "private" | "group" | "announcement";
export type MessageUnreadFilter = "all" | "unread" | "read";
export type MessagePinnedFilter = "all" | "pinned" | "unpinned";
export const MESSAGE_CHAT_TYPE_OPTIONS: ReadonlyArray<{
key: MessageChatTypeFilter;
label: string;
}> = [
{ key: "all", label: "Все" },
{ key: "global", label: "Глобальный" },
{ key: "channel", label: "Каналы" },
{ key: "comments", label: "Отделы" },
{ key: "private", label: "Личные" },
{ key: "group", label: "Группы" },
{ key: "announcement", label: "Объявления" },
];
type StoredFilters = {
search: string;
chatTypeFilter: MessageChatTypeFilter;
filterUnread: MessageUnreadFilter;
filterPinned: MessagePinnedFilter;
};
const DEFAULT_FILTERS: StoredFilters = {
search: "",
chatTypeFilter: "all",
filterUnread: "all",
filterPinned: "all",
};
const DEFAULT_FILTERS_SNAPSHOT = JSON.stringify(DEFAULT_FILTERS);
const CHANGE_EVENT = "messages-list-filters-change";
const CHAT_TYPES = new Set(MESSAGE_CHAT_TYPE_OPTIONS.map(({ key }) => key));
const UNREAD_FILTERS = new Set<MessageUnreadFilter>(["all", "unread", "read"]);
const PINNED_FILTERS = new Set<MessagePinnedFilter>(["all", "pinned", "unpinned"]);
function storageKey(userId: number): string {
return `messages.listFilters.${userId}`;
}
function parseStoredFilters(raw: string | null): StoredFilters {
try {
if (!raw) return DEFAULT_FILTERS;
const parsed = JSON.parse(raw) as Partial<StoredFilters>;
return {
search: typeof parsed.search === "string" ? parsed.search : "",
chatTypeFilter: CHAT_TYPES.has(parsed.chatTypeFilter as MessageChatTypeFilter)
? parsed.chatTypeFilter as MessageChatTypeFilter
: "all",
filterUnread: UNREAD_FILTERS.has(parsed.filterUnread as MessageUnreadFilter)
? parsed.filterUnread as MessageUnreadFilter
: "all",
filterPinned: PINNED_FILTERS.has(parsed.filterPinned as MessagePinnedFilter)
? parsed.filterPinned as MessagePinnedFilter
: "all",
};
} catch {
return DEFAULT_FILTERS;
}
}
export function useMessagesListFilters(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.sessionStorage.getItem(storageKey(userId)) || DEFAULT_FILTERS_SNAPSHOT : DEFAULT_FILTERS_SNAPSHOT),
[userId],
);
const snapshot = useSyncExternalStore(subscribe, getSnapshot, () => DEFAULT_FILTERS_SNAPSHOT);
const filters = useMemo(() => parseStoredFilters(snapshot), [snapshot]);
const updateFilters = useCallback((updates: Partial<StoredFilters>) => {
if (!userId) return;
const current = parseStoredFilters(window.sessionStorage.getItem(storageKey(userId)));
window.sessionStorage.setItem(storageKey(userId), JSON.stringify({ ...current, ...updates }));
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [userId]);
const setSearch = useCallback((search: string) => {
updateFilters({ search });
}, [updateFilters]);
const setChatTypeFilter = useCallback((chatTypeFilter: MessageChatTypeFilter) => {
updateFilters({ chatTypeFilter });
}, [updateFilters]);
const setFilterUnread = useCallback((filterUnread: MessageUnreadFilter) => {
updateFilters({ filterUnread });
}, [updateFilters]);
const setFilterPinned = useCallback((filterPinned: MessagePinnedFilter) => {
updateFilters({ filterPinned });
}, [updateFilters]);
return {
...filters,
setSearch,
setChatTypeFilter,
setFilterUnread,
setFilterPinned,
};
}