feat(messages): add focused desktop workspace

This commit is contained in:
lizerk 2026-08-05 11:39:25 +03:00
parent 02c72089a0
commit 6084bb695c
6 changed files with 323 additions and 12 deletions

View File

@ -16,6 +16,7 @@ import ChatDialogHeader from "@/components/messages/ChatDialogHeader";
import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal";
import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem";
import ChatSearchPanel from "@/components/messages/ChatSearchPanel";
import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar";
import MessageReadersModal from "@/components/messages/MessageReadersModal";
import MessageComposer from "@/components/messages/MessageComposer";
import ReactionPickerModal from "@/components/messages/ReactionPickerModal";
@ -35,6 +36,7 @@ import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useChatMessages } from "@/hooks/useChatMessages";
import { useMarkRead } from "@/hooks/useMarkRead";
import { useMessagesFocusMode } from "@/hooks/useMessagesFocusMode";
import { useChatScroll, shiftDateInputValue } from "@/hooks/useChatScroll";
import { mergeDisplayMessages, uniqueMessagesById } from "@/lib/messages/chatUtils";
import { formatDayDivider, getMessageDate, getMessagePreviewText, getReplyToId } from "@/lib/messages/messageUtils";
@ -145,6 +147,7 @@ function MessageDialogPageContent() {
const searchParams = useSearchParams();
const chatId = Number(params.chatId);
const { user, loading: userLoading } = useUser();
const [focusMode, setFocusMode] = useMessagesFocusMode(user?.id);
const { handleReconnectExhausted, resetReloadGuard } = useSilentChatReloadGuard(chatId || null);
/* ── refs ── */
@ -1358,7 +1361,15 @@ function MessageDialogPageContent() {
* ────────────────────────────────────────────────────── */
return (
<AppShell>
<AppShell
desktopWideMode={focusMode}
onDesktopWideModeChange={setFocusMode}
>
<div className={`min-h-0 h-full ${focusMode ? "lg:flex lg:gap-3" : ""}`}>
{focusMode ? (
<MessagesFocusSidebar activeChatId={chatId} onExit={() => setFocusMode(false)} />
) : null}
<div className="min-h-0 h-full min-w-0 flex-1">
{cm.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-[var(--border-subtle)] border-t-[var(--accent-primary)]" />
@ -1590,6 +1601,8 @@ function MessageDialogPageContent() {
</section>
</div>
)}
</div>
</div>
{mediaPreview ? <ChatMediaPreviewModal preview={mediaPreview} onClose={() => setMediaPreview(null)} /> : null}

View File

@ -12,6 +12,8 @@ import Image from "next/image";
import { useUser } from "@/contexts/UserContext";
import { useRouter } from "next/navigation";
import { Modal } from "@/components/ui";
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
import { useMessagesFocusMode } from "@/hooks/useMessagesFocusMode";
import wsManager from "@/lib/websocketManager";
type UnreadFilter = "all" | "unread" | "read";
@ -114,6 +116,7 @@ function isChatVisibleInMessagesList(
export default function MessagesPage() {
const { user } = useUser();
const router = useRouter();
const [focusMode, setFocusMode] = useMessagesFocusMode(user?.id);
const currentUserId = user?.id;
const currentUserFirstName = user?.first_name;
const currentUserLastName = user?.last_name;
@ -160,6 +163,15 @@ export default function MessagesPage() {
const hasCompletedInitialLoadRef = useRef(false);
const wasWsConnectedRef = useRef(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("createChat") !== "1") return;
setShowCreateModal(true);
params.delete("createChat");
const query = params.toString();
window.history.replaceState(null, "", query ? `/messages?${query}` : "/messages");
}, []);
const loadChats = useCallback(async (options?: { showLoader?: boolean }) => {
if (refreshAllChatsInFlightRef.current) {
return;
@ -410,7 +422,12 @@ export default function MessagesPage() {
};
return (
<AppShell>
<AppShell
desktopWideMode={focusMode}
onDesktopWideModeChange={setFocusMode}
suppressPushOnboardingPrompt={focusMode}
>
<div className={focusMode ? "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" />
@ -421,16 +438,19 @@ export default function MessagesPage() {
<p className="text-sm text-red-800">{error}</p>
</div>
) : (
<section className="app-surface rounded-2xl p-4">
<section className={`app-surface rounded-2xl p-4 ${focusMode ? "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>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition"
>
<Plus size={14} /> Создать чат
</button>
<div className="flex items-center gap-1">
<MessagesFocusModeButton enabled={focusMode} onChange={setFocusMode} />
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition"
>
<Plus size={14} /> Создать чат
</button>
</div>
</div>
{/* Поиск и кнопка фильтров */}
@ -619,6 +639,16 @@ export default function MessagesPage() {
) : null}
</section>
)}
{focusMode && !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" />
<p className="text-sm font-semibold text-[var(--foreground)]">Выберите чат</p>
<p className="app-text-muted mt-1 text-xs">Диалог откроется справа от списка чатов</p>
</div>
</section>
) : null}
</div>
{/* Модальное окно создания чата */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={

View File

@ -23,6 +23,7 @@ type AppShellProps = {
children: ReactNode;
desktopWideMode?: boolean;
onDesktopWideModeChange?: (enabled: boolean) => void;
suppressPushOnboardingPrompt?: boolean;
};
type PageHeaderProps = {
@ -590,7 +591,12 @@ function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) {
);
}
export function AppShell({ children, desktopWideMode = false, onDesktopWideModeChange }: AppShellProps) {
export function AppShell({
children,
desktopWideMode = false,
onDesktopWideModeChange,
suppressPushOnboardingPrompt = false,
}: AppShellProps) {
const { user, loading } = useUser();
const { mobileNavPlacement } = useMobileNavPlacement();
const router = useRouter();
@ -681,7 +687,7 @@ export function AppShell({ children, desktopWideMode = false, onDesktopWideModeC
<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} />
<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 ? <PushOnboardingPrompt /> : null}
{!isMessageDialogPage && !suppressPushOnboardingPrompt ? <PushOnboardingPrompt /> : null}
{children}
</main>
<CalendarSidebar

View File

@ -0,0 +1,28 @@
"use client";
import { Maximize2, Minimize2 } from "lucide-react";
type MessagesFocusModeButtonProps = {
enabled: boolean;
onChange: (enabled: boolean) => void;
};
export default function MessagesFocusModeButton({
enabled,
onChange,
}: MessagesFocusModeButtonProps) {
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 ? <Minimize2 size={17} /> : <Maximize2 size={17} />}
</button>
);
}

View File

@ -0,0 +1,191 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BellOff, MessageCircle, Pin, Plus, Search } from "lucide-react";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
import {
ChatIdentity,
getChatAvatar,
getChatInitials,
getChatTitle,
isDepartmentCommentsChat,
} from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
import wsManager from "@/lib/websocketManager";
import type { Chat } from "@/types/api";
import MessagesFocusModeButton from "@/components/messages/MessagesFocusModeButton";
type MessagesFocusSidebarProps = {
activeChatId?: number;
onExit: () => void;
};
function chatTimestamp(chat: Chat): number {
const raw = chat.last_message?.created_at || chat.created_at;
const timestamp = new Date(raw).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function formatTime(value?: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
if (date.toDateString() === new Date().toDateString()) {
return date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
return date.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
export default function MessagesFocusSidebar({ activeChatId, onExit }: MessagesFocusSidebarProps) {
const { user } = useUser();
const [chats, setChats] = useState<Chat[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const identity = useMemo<ChatIdentity>(() => ({
first_name: user?.first_name,
last_name: user?.last_name,
patronymic: user?.patronymic,
email: user?.email,
}), [user?.email, user?.first_name, user?.last_name, user?.patronymic]);
const departmentIds = useMemo(
() => new Set((user?.departments || []).map((department) => department.id)),
[user?.departments],
);
const loadChats = useCallback(async (showLoader = false) => {
if (showLoader) setLoading(true);
try {
const items = await apiClient.getAllChats();
setChats(items.filter((chat) => {
const type = chat.chat_type || chat.type;
if (type !== "comments") return true;
return isDepartmentCommentsChat(chat)
&& typeof chat.context_object_id === "number"
&& departmentIds.has(chat.context_object_id);
}));
} catch (error) {
console.error("Ошибка загрузки списка чатов в режиме мессенджера:", error);
} finally {
if (showLoader) setLoading(false);
}
}, [departmentIds]);
useEffect(() => {
void loadChats(true);
}, [loadChats]);
useEffect(() => {
const unsubscribe = wsManager.subscribe((event) => {
const data = event as { type?: string; chat_id?: number; notification?: { verb?: string } };
if (
data.type === "list_update"
|| data.type === "message_edited"
|| (data.type === "notification" && data.notification?.verb === "chat_added_to_chat")
) {
void loadChats();
}
});
return unsubscribe;
}, [loadChats]);
const visibleChats = useMemo(() => {
const query = search.trim().toLowerCase();
return [...chats]
.sort((a, b) => Number(Boolean(b.is_pinned)) - Number(Boolean(a.is_pinned)) || chatTimestamp(b) - chatTimestamp(a))
.filter((chat) => {
if (!query) return true;
const title = getChatTitle(chat, user?.id, identity).toLowerCase();
return title.includes(query) || (chat.last_message?.content || "").toLowerCase().includes(query);
});
}, [chats, identity, search, user?.id]);
return (
<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">
<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"
/>
</div>
</div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto px-2 pb-2">
{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>
);
})}
</div>
{!loading && visibleChats.length === 0 ? (
<MessageCircle size={18} className="app-text-muted mx-auto mb-3" />
) : null}
</aside>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
const STORAGE_PREFIX = "messages.desktopFocusMode";
const CHANGE_EVENT = "messages-desktop-focus-mode-change";
function storageKey(userId: number): string {
return `${STORAGE_PREFIX}.${userId}`;
}
export function useMessagesFocusMode(userId?: number | null) {
const subscribe = useCallback((notify: () => void) => {
if (!userId) return () => undefined;
const key = storageKey(userId);
const handleStorage = (event: StorageEvent) => {
if (event.key === key) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(CHANGE_EVENT, notify);
};
}, [userId]);
const getSnapshot = useCallback(
() => (userId ? window.localStorage.getItem(storageKey(userId)) === "1" : false),
[userId],
);
const enabled = useSyncExternalStore(subscribe, getSnapshot, () => false);
const setEnabled = useCallback((next: boolean) => {
if (!userId) return;
window.localStorage.setItem(storageKey(userId), next ? "1" : "0");
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [userId]);
return [enabled, setEnabled] as const;
}