feat(messages): add focused desktop workspace
This commit is contained in:
parent
02c72089a0
commit
6084bb695c
@ -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}
|
||||
|
||||
|
||||
@ -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={
|
||||
|
||||
@ -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
|
||||
|
||||
28
frontend/src/components/messages/MessagesFocusModeButton.tsx
Normal file
28
frontend/src/components/messages/MessagesFocusModeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/components/messages/MessagesFocusSidebar.tsx
Normal file
191
frontend/src/components/messages/MessagesFocusSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/hooks/useMessagesFocusMode.ts
Normal file
43
frontend/src/hooks/useMessagesFocusMode.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user