refactor(messages): persist workspace across routes
This commit is contained in:
parent
8914f1998f
commit
f8919dcdbd
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
7
frontend/src/app/messages/layout.tsx
Normal file
7
frontend/src/app/messages/layout.tsx
Normal 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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
176
frontend/src/components/messages/CreateChatModal.tsx
Normal file
176
frontend/src/components/messages/CreateChatModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
61
frontend/src/components/messages/MessagesWorkspaceShell.tsx
Normal file
61
frontend/src/components/messages/MessagesWorkspaceShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
frontend/src/hooks/useMessagesDirectory.ts
Normal file
104
frontend/src/hooks/useMessagesDirectory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
60
frontend/src/hooks/useMessagesWorkspaceMode.ts
Normal file
60
frontend/src/hooks/useMessagesWorkspaceMode.ts
Normal 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;
|
||||
}
|
||||
10
frontend/src/lib/messages/workspace.d.ts
vendored
Normal file
10
frontend/src/lib/messages/workspace.d.ts
vendored
Normal 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;
|
||||
34
frontend/src/lib/messages/workspace.js
Normal file
34
frontend/src/lib/messages/workspace.js
Normal 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);
|
||||
}
|
||||
28
frontend/src/lib/messages/workspace.test.js
Normal file
28
frontend/src/lib/messages/workspace.test.js
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user