fix(messages): stabilize focused workspace layout
This commit is contained in:
parent
6084bb695c
commit
8914f1998f
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
147
frontend/src/components/messages/MessagesChatListControls.tsx
Normal file
147
frontend/src/components/messages/MessagesChatListControls.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
132
frontend/src/components/messages/MessagesChatListItem.tsx
Normal file
132
frontend/src/components/messages/MessagesChatListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -11,7 +11,7 @@ export default function MessagesFocusModeButton({
|
||||
enabled,
|
||||
onChange,
|
||||
}: MessagesFocusModeButtonProps) {
|
||||
const label = enabled ? "Вернуть обычный вид" : "Открыть режим мессенджера";
|
||||
const label = enabled ? "Вернуть обычный вид" : "Развернуть рабочую область";
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
167
frontend/src/hooks/useMessagesChatList.ts
Normal file
167
frontend/src/hooks/useMessagesChatList.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
43
frontend/src/hooks/useMessagesImmersiveMode.ts
Normal file
43
frontend/src/hooks/useMessagesImmersiveMode.ts
Normal 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;
|
||||
}
|
||||
120
frontend/src/hooks/useMessagesListFilters.ts
Normal file
120
frontend/src/hooks/useMessagesListFilters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user