refactor(messages): persist workspace across routes

This commit is contained in:
lizerk 2026-08-05 14:27:57 +03:00
parent 8914f1998f
commit f8919dcdbd
16 changed files with 608 additions and 778 deletions

View File

@ -10,13 +10,11 @@ import {
Link2,
Loader2,
} from "lucide-react";
import { AppShell } from "../../../components/AppShell";
import { Modal } from "@/components/ui";
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";
@ -36,8 +34,6 @@ 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 { 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";
@ -132,12 +128,10 @@ export default function MessageDialogPage() {
function MessageDialogPageFallback() {
return (
<AppShell>
<section className="app-surface rounded-2xl p-6 text-center">
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
<p className="app-text-muted mt-3 text-sm">Загрузка чата...</p>
</section>
</AppShell>
<section className="app-surface rounded-2xl p-6 text-center">
<div className="inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
<p className="app-text-muted mt-3 text-sm">Загрузка чата...</p>
</section>
);
}
@ -148,9 +142,6 @@ function MessageDialogPageContent() {
const searchParams = useSearchParams();
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 ── */
@ -1364,21 +1355,7 @@ function MessageDialogPageContent() {
* ────────────────────────────────────────────────────── */
return (
<AppShell
desktopWideMode={workspaceMode}
desktopImmersiveMode={immersiveMode}
onDesktopWideModeChange={setFocusMode}
>
<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 ? (
<div className="app-surface rounded-2xl p-8 text-center">
@ -1612,7 +1589,6 @@ function MessageDialogPageContent() {
</div>
)}
</div>
</div>
{mediaPreview ? <ChatMediaPreviewModal preview={mediaPreview} onClose={() => setMediaPreview(null)} /> : null}
@ -1900,6 +1876,6 @@ function MessageDialogPageContent() {
}}
/>
) : null}
</AppShell>
</>
);
}

View File

@ -13,18 +13,14 @@ import {
UserMinus,
Pencil
} from "lucide-react";
import { AppShell } from "../../../../components/AppShell";
import { apiClient } from "@/lib/api";
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
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() {
@ -32,9 +28,6 @@ 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);
@ -396,50 +389,21 @@ export default function ChatSettingsPage() {
if (loading) {
return (
<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>
<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>
);
}
if (error || !chat) {
return (
<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
href="/messages"
className="app-link-accent mt-4 inline-block text-sm"
>
Вернуться к списку чатов
</Link>
</div>
</div>
</AppShell>
<div className="app-feedback-danger mx-auto max-w-3xl rounded-2xl p-6 text-center">
<p className="text-sm">{error || "Чат не найден"}</p>
<Link href="/messages" className="app-link-accent mt-4 inline-block text-sm">
Вернуться к списку чатов
</Link>
</div>
);
}
@ -455,18 +419,7 @@ export default function ChatSettingsPage() {
const canEdit = isOwner || isAdmin;
return (
<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="h-full 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">
@ -672,8 +625,6 @@ export default function ChatSettingsPage() {
onSearchChange={handleMemberSearch}
onAddMember={handleAddMember}
/>
</div>
</div>
</AppShell>
</div>
);
}

View File

@ -0,0 +1,7 @@
import type { ReactNode } from "react";
import MessagesWorkspaceShell from "@/components/messages/MessagesWorkspaceShell";
export default function MessagesLayout({ children }: { children: ReactNode }) {
return <MessagesWorkspaceShell>{children}</MessagesWorkspaceShell>;
}

View File

@ -1,495 +1,81 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AppShell } from "../../components/AppShell";
import { apiClient } from "@/lib/api";
import { ChatIdentity, getChatTitle, isDepartmentCommentsChat } from "@/lib/messages/chatUtils";
import type { Chat } from "@/types/api";
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 { MessageCircle, Plus } from "lucide-react";
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 { useMessagesImmersiveMode } from "@/hooks/useMessagesImmersiveMode";
import { useMessagesListFilters } from "@/hooks/useMessagesListFilters";
import { useMessagesChatList } from "@/hooks/useMessagesChatList";
type CreateChatPayload = {
type: string;
name: string;
description?: string;
avatar?: File;
};
function getChatSortTimestamp(chat: Chat): number {
const lastMessage = chat.last_message;
if (typeof lastMessage?.created_ts === "number") {
return lastMessage.created_ts;
}
const lastCreatedRaw = lastMessage?.created_at || lastMessage?.created;
if (lastCreatedRaw) {
const ts = new Date(lastCreatedRaw).getTime();
if (!Number.isNaN(ts)) return ts;
}
const chatCreatedTs = new Date(chat.created_at).getTime();
return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs;
}
function isChatVisibleInMessagesList(
chat: Chat,
visibleDepartmentIds: ReadonlySet<number>
): boolean {
const chatType = chat.chat_type || chat.type;
if (chatType !== "comments") {
return true;
}
if (!isDepartmentCommentsChat(chat)) {
return false;
}
return typeof chat.context_object_id === "number"
&& visibleDepartmentIds.has(chat.context_object_id);
}
import { useMessagesWorkspace } from "@/components/messages/MessagesWorkspaceContext";
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
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;
const currentUserPatronymic = user?.patronymic;
const currentUserEmail = user?.email;
const { workspaceMode } = useMessagesWorkspace();
const currentUserForMatch = useMemo<ChatIdentity>(
() => ({
first_name: currentUserFirstName,
last_name: currentUserLastName,
patronymic: currentUserPatronymic,
email: currentUserEmail,
}),
[currentUserEmail, currentUserFirstName, currentUserLastName, currentUserPatronymic]
);
const visibleDepartmentIds = useMemo(() => {
const ids = new Set<number>();
(user?.departments || []).forEach((department) => {
if (typeof department.id === "number") {
ids.add(department.id);
}
});
return ids;
}, [user?.departments]);
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');
const [newChatName, setNewChatName] = useState("");
const [newChatDescription, setNewChatDescription] = useState("");
const [newChatAvatar, setNewChatAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [creating, setCreating] = useState(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 filteredChats = useMemo(() => {
const q = search.trim().toLowerCase();
// Сортировка: закрепленные сверху, потом по времени последнего сообщения
const sorted = [...chats].sort((a, b) => {
// Сначала сортируем по закрепленности
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
// Затем по времени последнего сообщения
return getChatSortTimestamp(b) - getChatSortTimestamp(a);
});
// Фильтруем по типу и поисковому запросу
return sorted.filter((chat: Chat) => {
// Фильтр по типу чата
if (chatTypeFilter !== "all") {
const chatType = chat.chat_type || chat.type;
if (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 (q) {
const title = getChatTitle(chat, currentUserId, currentUserForMatch).toLowerCase();
const lastMessage = chat.last_message?.content?.toLowerCase() || "";
if (!title.includes(q) && !lastMessage.includes(q)) return false;
}
return true;
});
}, [chats, currentUserForMatch, currentUserId, search, chatTypeFilter, filterUnread, filterPinned]);
// Подсчет чатов по типам
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: Chat) => {
const chatType = chat.chat_type || chat.type;
const unreadCount = chat.unread_count || 0;
if (unreadCount > 0) {
counts.all.unread += unreadCount;
}
if (chatType && counts[chatType] !== undefined) {
counts[chatType].total++;
if (unreadCount > 0) {
counts[chatType].unread += unreadCount;
}
}
});
return counts;
}, [chats]);
const handleCreateChat = async () => {
if (creating) return;
// Валидация: все типы кроме comments требуют название
if (!newChatName.trim()) {
alert('Введите название чата');
return;
}
setCreating(true);
try {
const chatData: CreateChatPayload = {
type: newChatType,
name: newChatName.trim(),
};
if (newChatDescription.trim()) {
chatData.description = newChatDescription.trim();
}
if (newChatAvatar) {
chatData.avatar = newChatAvatar;
}
const newChat = await apiClient.createChat(chatData);
// Закрываем модалку и сбрасываем форму
setShowCreateModal(false);
setNewChatType('group');
setNewChatName('');
setNewChatDescription('');
setNewChatAvatar(null);
setAvatarPreview(null);
// Перенаправляем на страницу настроек созданного чата
router.push(`/messages/${newChat.id}/settings`);
} catch (e: unknown) {
console.error('Ошибка создания чата:', e);
const errorMessage = e instanceof Error ? e.message : String(e);
alert(`Не удалось создать чат: ${errorMessage}`);
} finally {
setCreating(false);
}
};
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
alert('Можно загружать только изображения');
return;
}
setNewChatAvatar(file);
// Создаем превью
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveAvatar = () => {
setNewChatAvatar(null);
setAvatarPreview(null);
};
return (
<AppShell
desktopWideMode={workspaceMode}
desktopImmersiveMode={immersiveMode}
onDesktopWideModeChange={setFocusMode}
suppressPushOnboardingPrompt={workspaceMode}
>
<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" />
<p className="app-text-muted text-sm">Загрузка чатов...</p>
if (workspaceMode) {
return (
<>
<div className="lg:hidden">
<StandardMessagesList />
</div>
) : error ? (
<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 ${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)}
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>
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
<div className="space-y-2">
{filteredChats.length === 0 ? (
<div className="app-surface-muted rounded-xl p-6 text-center">
<p className="app-text-muted text-sm">Чаты не найдены</p>
</div>
) : (
filteredChats.map((chat: Chat) => (
<MessagesChatListItem
key={chat.id}
chat={chat}
currentUserId={currentUserId}
identity={currentUserForMatch}
/>
))
)}
</div>
{filteredChats.length === 0 ? (
<div className="app-surface-muted mt-4 flex min-h-[120px] items-center justify-center rounded-xl text-center">
<div>
<MessageCircle size={20} className="app-text-muted mx-auto mb-2" />
<p className="app-text-muted text-sm">Создайте или найдите диалог</p>
</div>
</div>
) : null}
</section>
)}
{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">
<section className="app-surface-muted hidden h-full min-h-0 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}
</>
);
}
return <StandardMessagesList />;
}
function StandardMessagesList() {
const { setMode, openCreateChat } = useMessagesWorkspace();
const { user, identity, visibleChats, chatTypeCounts, loading, error } = useMessagesDirectory();
if (loading) {
return (
<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" />
<p className="app-text-muted text-sm">Загрузка чатов...</p>
</div>
{/* Модальное окно создания чата */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="app-action-secondary flex-1 rounded-lg px-4 py-2 text-sm font-medium transition"
>
Отмена
</button>
<button
type="button"
onClick={handleCreateChat}
disabled={creating}
className="app-action-primary flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50"
>
{creating ? 'Создание...' : 'Создать'}
</button>
</div>
}>
<div className="space-y-4">
{/* Тип чата */}
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Тип чата
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setNewChatType('group')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg p-3 transition ${
newChatType === 'group'
? 'app-selected'
: 'app-action-secondary'
}`}
>
<Users size={18} />
<span className="text-sm font-medium">Групповой</span>
</button>
<button
type="button"
onClick={() => setNewChatType('channel')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg p-3 transition ${
newChatType === 'channel'
? 'app-selected'
: 'app-action-secondary'
}`}
>
<Radio size={18} />
<span className="text-sm font-medium">Канал</span>
</button>
<button
type="button"
onClick={() => setNewChatType('global')}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg p-3 transition ${
newChatType === 'global'
? 'app-selected'
: 'app-action-secondary'
}`}
>
<Globe size={18} />
<span className="text-sm font-medium">Глобальный</span>
</button>
</div>
</div>
);
}
{/* Название и описание */}
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Название *
</label>
<input
type="text"
value={newChatName}
onChange={(e) => setNewChatName(e.target.value)}
placeholder="Введите название чата..."
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Описание (необязательно)
</label>
<textarea
value={newChatDescription}
onChange={(e) => setNewChatDescription(e.target.value)}
placeholder="Краткое описание чата..."
rows={3}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
</div>
if (error) {
return (
<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>
);
}
{/* Аватар */}
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Аватар (необязательно)
</label>
{avatarPreview ? (
<div className="flex items-center gap-3">
<div className="relative h-20 w-20">
<Image
src={avatarPreview}
alt="Preview"
width={80}
height={80}
className="h-20 w-20 rounded-full object-cover"
unoptimized
/>
</div>
<div className="flex-1">
<p className="app-text-muted mb-2 text-sm">{newChatAvatar?.name}</p>
<button
type="button"
onClick={handleRemoveAvatar}
className="app-action-danger inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium transition"
>
<X size={14} />
Удалить
</button>
</div>
</div>
) : (
<label className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-[var(--border-strong)] bg-[var(--surface-secondary)] p-6 transition hover:border-[color:color-mix(in_srgb,var(--accent-primary)_30%,var(--border-strong))] hover:bg-[var(--accent-soft)]">
<input
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
<div className="flex flex-col items-center gap-2">
<div className="rounded-full bg-[var(--accent-soft)] p-3">
<ImageIcon size={24} className="app-accent-text" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Загрузить изображение
</p>
<p className="app-text-muted mt-1 text-xs">
PNG, JPG до 5MB
</p>
</div>
</div>
</label>
)}
</div>
</div>
</Modal>
</AppShell>
return (
<section className="app-surface rounded-2xl p-4">
<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={false} onChange={(enabled) => setMode(enabled ? "wide" : "standard")} />
<MessagesImmersiveModeButton enabled={false} onChange={(enabled) => setMode(enabled ? "immersive" : "standard")} />
<button type="button" onClick={openCreateChat} 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>
<MessagesChatListControls userId={user?.id} chatTypeCounts={chatTypeCounts} />
<div className="space-y-2">
{visibleChats.length === 0 ? (
<div className="app-surface-muted rounded-xl p-6 text-center">
<p className="app-text-muted text-sm">Чаты не найдены</p>
</div>
) : visibleChats.map((chat) => (
<MessagesChatListItem key={chat.id} chat={chat} currentUserId={user?.id} identity={identity} />
))}
</div>
</section>
);
}

View File

@ -23,6 +23,7 @@ type AppShellProps = {
children: ReactNode;
desktopWideMode?: boolean;
desktopImmersiveMode?: boolean;
mobileFullscreen?: boolean;
onDesktopWideModeChange?: (enabled: boolean) => void;
suppressPushOnboardingPrompt?: boolean;
};
@ -596,6 +597,7 @@ export function AppShell({
children,
desktopWideMode = false,
desktopImmersiveMode = false,
mobileFullscreen = false,
onDesktopWideModeChange,
suppressPushOnboardingPrompt = false,
}: AppShellProps) {
@ -603,10 +605,6 @@ export function AppShell({
const { mobileNavPlacement } = useMobileNavPlacement();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') &&
pathname !== '/messages' &&
!pathname.includes('/settings');
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
@ -679,19 +677,19 @@ export function AppShell({
}
return (
<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={`${mobileFullscreen ? '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" && !mobileFullscreen ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<div className={desktopImmersiveMode ? "lg:hidden" : ""}>
<Header
mobileNavPlacement={mobileNavPlacement}
suppressMobileChrome={isMessageDialogPage}
suppressMobileChrome={mobileFullscreen}
onOpenLeftNav={() => setIsMobileLeftNavOpen(true)}
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>
</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" : ""}`}`}>
<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" : mobileFullscreen ? `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}
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${mobileFullscreen ? 'overflow-visible' : ''} ${desktopWideMode ? "lg:flex lg:flex-col lg:gap-4 lg:space-y-0 lg:overflow-hidden" : ""}`}>
{!mobileFullscreen && !suppressPushOnboardingPrompt ? <PushOnboardingPrompt /> : null}
{children}
</main>
{!desktopImmersiveMode ? <CalendarSidebar

View File

@ -0,0 +1,176 @@
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState, type ChangeEvent } from "react";
import { Globe, Image as ImageIcon, Radio, Users, X } from "lucide-react";
import { apiClient } from "@/lib/api";
import { Modal } from "@/components/ui";
type CreateChatModalProps = {
isOpen: boolean;
onClose: () => void;
};
type CreateChatPayload = {
type: string;
name: string;
description?: string;
avatar?: File;
};
export default function CreateChatModal({ isOpen, onClose }: CreateChatModalProps) {
const router = useRouter();
const [chatType, setChatType] = useState<"group" | "channel" | "global">("group");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [avatar, setAvatar] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const resetForm = () => {
setChatType("group");
setName("");
setDescription("");
setAvatar(null);
setAvatarPreview(null);
};
const close = () => {
if (creating) return;
onClose();
};
const handleCreate = async () => {
if (creating) return;
if (!name.trim()) {
alert("Введите название чата");
return;
}
setCreating(true);
try {
const payload: CreateChatPayload = {
type: chatType,
name: name.trim(),
};
if (description.trim()) payload.description = description.trim();
if (avatar) payload.avatar = avatar;
const chat = await apiClient.createChat(payload);
onClose();
resetForm();
router.push(`/messages/${chat.id}/settings`);
} catch (error: unknown) {
console.error("Ошибка создания чата:", error);
const message = error instanceof Error ? error.message : String(error);
alert(`Не удалось создать чат: ${message}`);
} finally {
setCreating(false);
}
};
const handleAvatarChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
alert("Можно загружать только изображения");
return;
}
setAvatar(file);
const reader = new FileReader();
reader.onloadend = () => setAvatarPreview(reader.result as string);
reader.readAsDataURL(file);
};
return (
<Modal
isOpen={isOpen}
onClose={close}
title="Создать чат"
size="sm"
closeOnEsc={!creating}
footer={(
<div className="flex gap-2">
<button type="button" onClick={close} className="app-action-secondary flex-1 rounded-lg px-4 py-2 text-sm font-medium transition">
Отмена
</button>
<button type="button" onClick={handleCreate} disabled={creating} className="app-action-primary flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50">
{creating ? "Создание..." : "Создать"}
</button>
</div>
)}
>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Тип чата</label>
<div className="flex gap-2">
{([
["group", "Групповой", Users],
["channel", "Канал", Radio],
["global", "Глобальный", Globe],
] as const).map(([value, label, Icon]) => (
<button
key={value}
type="button"
onClick={() => setChatType(value)}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg p-3 transition ${chatType === value ? "app-selected" : "app-action-secondary"}`}
>
<Icon size={18} />
<span className="text-sm font-medium">{label}</span>
</button>
))}
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Название *</label>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Введите название чата..." className="app-input w-full rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Описание (необязательно)</label>
<textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="Краткое описание чата..." rows={3} className="app-input w-full rounded-lg px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">Аватар (необязательно)</label>
{avatarPreview ? (
<div className="flex items-center gap-3">
<Image src={avatarPreview} alt="Предпросмотр аватара" width={80} height={80} unoptimized className="h-20 w-20 rounded-full object-cover" />
<div className="min-w-0 flex-1">
<p className="app-text-muted mb-2 truncate text-sm">{avatar?.name}</p>
<button
type="button"
onClick={() => {
setAvatar(null);
setAvatarPreview(null);
}}
className="app-action-danger inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm font-medium transition"
>
<X size={14} />
Удалить
</button>
</div>
</div>
) : (
<label className="flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-[var(--border-strong)] bg-[var(--surface-secondary)] p-6 transition hover:border-[color:color-mix(in_srgb,var(--accent-primary)_30%,var(--border-strong))] hover:bg-[var(--accent-soft)]">
<input type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
<div className="flex flex-col items-center gap-2">
<div className="rounded-full bg-[var(--accent-soft)] p-3">
<ImageIcon size={24} className="app-accent-text" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-[var(--foreground)]">Загрузить изображение</p>
<p className="app-text-muted mt-1 text-xs">Изображение для аватара чата</p>
</div>
</div>
</label>
)}
</div>
</div>
</Modal>
);
}

View File

@ -1,115 +1,28 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { Plus } from "lucide-react";
import { useUser } from "@/contexts/UserContext";
import {
ChatIdentity,
getChatTitle,
isDepartmentCommentsChat,
} from "@/lib/messages/chatUtils";
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";
import { useMessagesDirectory } from "@/hooks/useMessagesDirectory";
import type { MessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
type MessagesFocusSidebarProps = {
activeChatId?: number;
focusMode: boolean;
onFocusModeChange: (enabled: boolean) => void;
immersiveMode: boolean;
onImmersiveModeChange: (enabled: boolean) => void;
onCreateChat?: () => void;
mode: MessagesWorkspaceMode;
onModeChange: (mode: MessagesWorkspaceMode) => void;
onCreateChat: () => 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;
}
export default function MessagesFocusSidebar({
activeChatId,
focusMode,
onFocusModeChange,
immersiveMode,
onImmersiveModeChange,
mode,
onModeChange,
onCreateChat,
}: MessagesFocusSidebarProps) {
const { user } = useUser();
const {
search,
chatTypeFilter,
filterUnread,
filterPinned,
} = useMessagesListFilters(user?.id);
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 { 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);
}),
[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);
});
}, [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]);
const { user, identity, visibleChats, chatTypeCounts, loading } = useMessagesDirectory();
return (
<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">
@ -117,31 +30,21 @@ export default function MessagesFocusSidebar({
<p className="app-card-caption min-w-0 truncate">Сообщения</p>
<div className="flex shrink-0 items-center gap-1">
<MessagesFocusModeButton
enabled={focusMode}
onChange={onFocusModeChange}
enabled={mode !== "standard"}
onChange={(enabled) => onModeChange(enabled ? "wide" : "standard")}
/>
<MessagesImmersiveModeButton
enabled={immersiveMode}
onChange={onImmersiveModeChange}
enabled={mode === "immersive"}
onChange={(enabled) => onModeChange(enabled ? "immersive" : "wide")}
/>
{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>
)}
<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>
</div>
</div>

View File

@ -0,0 +1,23 @@
"use client";
import { createContext, useContext } from "react";
import type { MessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
type MessagesWorkspaceContextValue = {
mode: MessagesWorkspaceMode;
setMode: (mode: MessagesWorkspaceMode) => void;
workspaceMode: boolean;
immersiveMode: boolean;
openCreateChat: () => void;
};
export const MessagesWorkspaceContext = createContext<MessagesWorkspaceContextValue | null>(null);
export function useMessagesWorkspace() {
const value = useContext(MessagesWorkspaceContext);
if (!value) {
throw new Error("useMessagesWorkspace must be used within MessagesWorkspaceShell");
}
return value;
}

View File

@ -0,0 +1,61 @@
"use client";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import CreateChatModal from "@/components/messages/CreateChatModal";
import MessagesFocusSidebar from "@/components/messages/MessagesFocusSidebar";
import { MessagesWorkspaceContext } from "@/components/messages/MessagesWorkspaceContext";
import { useUser } from "@/contexts/UserContext";
import { useMessagesWorkspaceMode } from "@/hooks/useMessagesWorkspaceMode";
import { getMessagesActiveChatId, isMessagesDialogPath } from "@/lib/messages/workspace.js";
type MessagesWorkspaceShellProps = {
children: ReactNode;
};
export default function MessagesWorkspaceShell({ children }: MessagesWorkspaceShellProps) {
const pathname = usePathname();
const { user } = useUser();
const [createChatOpen, setCreateChatOpen] = useState(false);
const [mode, setMode] = useMessagesWorkspaceMode(user?.id);
const workspaceMode = mode !== "standard";
const immersiveMode = mode === "immersive";
const activeChatId = getMessagesActiveChatId(pathname);
const mobileFullscreen = isMessagesDialogPath(pathname);
const contextValue = useMemo(() => ({
mode,
setMode,
workspaceMode,
immersiveMode,
openCreateChat: () => setCreateChatOpen(true),
}), [immersiveMode, mode, setMode, workspaceMode]);
return (
<MessagesWorkspaceContext.Provider value={contextValue}>
<AppShell
desktopWideMode={workspaceMode}
desktopImmersiveMode={immersiveMode}
mobileFullscreen={mobileFullscreen}
onDesktopWideModeChange={(enabled) => setMode(enabled ? "wide" : "standard")}
suppressPushOnboardingPrompt={workspaceMode}
>
<div className={`h-full min-h-0 ${workspaceMode ? "lg:flex lg:gap-3" : ""}`}>
{workspaceMode ? (
<MessagesFocusSidebar
activeChatId={activeChatId}
mode={mode}
onModeChange={setMode}
onCreateChat={() => setCreateChatOpen(true)}
/>
) : null}
<div className="h-full min-h-0 min-w-0 flex-1">{children}</div>
</div>
<CreateChatModal isOpen={createChatOpen} onClose={() => setCreateChatOpen(false)} />
</AppShell>
</MessagesWorkspaceContext.Provider>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { useMemo } from "react";
import { useUser } from "@/contexts/UserContext";
import {
type ChatIdentity,
getChatTitle,
isDepartmentCommentsChat,
} from "@/lib/messages/chatUtils";
import type { Chat } from "@/types/api";
import { useMessagesChatList } from "@/hooks/useMessagesChatList";
import { useMessagesListFilters } from "@/hooks/useMessagesListFilters";
export type MessageChatTypeCounts = Record<string, { total: number; unread: number }>;
function chatTimestamp(chat: Chat): number {
const lastMessage = chat.last_message;
if (typeof lastMessage?.created_ts === "number") return lastMessage.created_ts;
const raw = lastMessage?.created_at || lastMessage?.created || chat.created_at;
const timestamp = new Date(raw).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export function useMessagesDirectory() {
const { user } = useUser();
const { search, chatTypeFilter, filterUnread, filterPinned } = useMessagesListFilters(user?.id);
const { chats: allChats, loading, error } = useMessagesChatList(user?.id);
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 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);
}),
[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 type = chat.chat_type || chat.type;
const unread = chat.unread_count || 0;
if (chatTypeFilter !== "all" && type !== chatTypeFilter) return false;
if (filterUnread === "unread" && unread === 0) return false;
if (filterUnread === "read" && unread > 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);
});
}, [chatTypeFilter, chats, filterPinned, filterUnread, identity, search, user?.id]);
const chatTypeCounts = useMemo<MessageChatTypeCounts>(() => {
const counts: MessageChatTypeCounts = {
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 {
user,
identity,
visibleChats,
chatTypeCounts,
loading,
error,
};
}

View File

@ -1,44 +0,0 @@
"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(() => {
if (!userId) return false;
const stored = window.localStorage.getItem(storageKey(userId));
return stored === null ? true : stored === "1";
}, [userId]);
const enabled = useSyncExternalStore(subscribe, getSnapshot, () => false);
const setEnabled = useCallback((next: boolean) => {
if (!userId) return;
window.localStorage.setItem(storageKey(userId), next ? "1" : "0");
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [userId]);
return [enabled, setEnabled] as const;
}

View File

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

View File

@ -0,0 +1,60 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
import {
resolveMessagesWorkspaceMode,
type MessagesWorkspaceMode,
} from "@/lib/messages/workspace.js";
export type { MessagesWorkspaceMode } from "@/lib/messages/workspace.js";
const STORAGE_PREFIX = "messages.workspaceMode";
const LEGACY_FOCUS_PREFIX = "messages.desktopFocusMode";
const LEGACY_IMMERSIVE_PREFIX = "messages.desktopImmersiveMode";
const CHANGE_EVENT = "messages-workspace-mode-change";
function storageKey(prefix: string, userId: number): string {
return `${prefix}.${userId}`;
}
function readMode(userId: number): MessagesWorkspaceMode {
return resolveMessagesWorkspaceMode({
current: window.localStorage.getItem(storageKey(STORAGE_PREFIX, userId)),
legacyFocus: window.localStorage.getItem(storageKey(LEGACY_FOCUS_PREFIX, userId)),
legacyImmersive: window.localStorage.getItem(storageKey(LEGACY_IMMERSIVE_PREFIX, userId)),
});
}
export function useMessagesWorkspaceMode(userId?: number | null) {
const subscribe = useCallback((notify: () => void) => {
if (!userId) return () => undefined;
const key = storageKey(STORAGE_PREFIX, 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((): MessagesWorkspaceMode =>
(userId ? readMode(userId) : "wide"),
[userId],
);
const mode = useSyncExternalStore<MessagesWorkspaceMode>(subscribe, getSnapshot, () => "wide");
const setMode = useCallback((next: MessagesWorkspaceMode) => {
if (!userId) return;
window.localStorage.setItem(storageKey(STORAGE_PREFIX, userId), next);
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [userId]);
return [mode, setMode] as const;
}

View File

@ -0,0 +1,10 @@
export type MessagesWorkspaceMode = "standard" | "wide" | "immersive";
export function resolveMessagesWorkspaceMode(stored: {
current: string | null;
legacyFocus: string | null;
legacyImmersive: string | null;
}): MessagesWorkspaceMode;
export function getMessagesActiveChatId(pathname: string): number | undefined;
export function isMessagesDialogPath(pathname: string): boolean;

View File

@ -0,0 +1,34 @@
/**
* @typedef {"standard" | "wide" | "immersive"} MessagesWorkspaceMode
*/
/**
* @param {{ current: string | null; legacyFocus: string | null; legacyImmersive: string | null }} stored
* @returns {MessagesWorkspaceMode}
*/
export function resolveMessagesWorkspaceMode({ current, legacyFocus, legacyImmersive }) {
if (current === "standard" || current === "wide" || current === "immersive") {
return current;
}
if (legacyImmersive === "1") return "immersive";
return legacyFocus === "0" ? "standard" : "wide";
}
/**
* @param {string} pathname
* @returns {number | undefined}
*/
export function getMessagesActiveChatId(pathname) {
const match = pathname.match(/^\/messages\/(\d+)(?:\/settings)?\/?$/);
if (!match) return undefined;
const chatId = Number(match[1]);
return Number.isFinite(chatId) ? chatId : undefined;
}
/**
* @param {string} pathname
* @returns {boolean}
*/
export function isMessagesDialogPath(pathname) {
return /^\/messages\/\d+\/?$/.test(pathname);
}

View File

@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getMessagesActiveChatId,
isMessagesDialogPath,
resolveMessagesWorkspaceMode,
} from "./workspace.js";
test("workspace mode keeps the current setting", () => {
assert.equal(resolveMessagesWorkspaceMode({ current: "immersive", legacyFocus: "0", legacyImmersive: "0" }), "immersive");
assert.equal(resolveMessagesWorkspaceMode({ current: "standard", legacyFocus: "1", legacyImmersive: "1" }), "standard");
});
test("workspace mode migrates legacy settings", () => {
assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: "0", legacyImmersive: "0" }), "standard");
assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: "1", legacyImmersive: "0" }), "wide");
assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: "0", legacyImmersive: "1" }), "immersive");
assert.equal(resolveMessagesWorkspaceMode({ current: null, legacyFocus: null, legacyImmersive: null }), "wide");
});
test("message paths expose chat ownership without treating settings as a dialog", () => {
assert.equal(getMessagesActiveChatId("/messages/42"), 42);
assert.equal(getMessagesActiveChatId("/messages/42/settings"), 42);
assert.equal(getMessagesActiveChatId("/messages"), undefined);
assert.equal(isMessagesDialogPath("/messages/42"), true);
assert.equal(isMessagesDialogPath("/messages/42/settings"), false);
});