feat(web): sprint1 ui core with global toasts and improved chat layout
Some checks failed
CI / test (push) Failing after 19s

This commit is contained in:
2026-03-08 10:35:21 +03:00
parent 1119cc65b8
commit a77516cfea
6 changed files with 81 additions and 32 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect } from "react";
import { ToastViewport } from "../components/ToastViewport";
import { AuthPage } from "../pages/AuthPage";
import { ChatsPage } from "../pages/ChatsPage";
import { useAuthStore } from "../store/authStore";
@@ -27,5 +28,10 @@ export function App() {
if (!accessToken || !me) {
return <AuthPage />;
}
return <ChatsPage />;
return (
<>
<ChatsPage />
<ToastViewport />
</>
);
}

View File

@@ -17,6 +17,7 @@ import { blockUser, getUserById, listBlockedUsers, searchUsers, unblockUser } fr
import type { AuthUser, ChatAttachment, ChatDetail, ChatMember, UserSearchItem } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
interface Props {
chatId: number | null;
@@ -28,6 +29,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
const me = useAuthStore((s) => s.me);
const loadChats = useChatStore((s) => s.loadChats);
const setActiveChatId = useChatStore((s) => s.setActiveChatId);
const showToast = useUiStore((s) => s.showToast);
const [chat, setChat] = useState<ChatDetail | null>(null);
const [members, setMembers] = useState<ChatMember[]>([]);
const [memberUsers, setMemberUsers] = useState<Record<number, AuthUser>>({});
@@ -223,6 +225,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
setInviteLink(link.invite_url);
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(link.invite_url);
showToast("Invite link copied");
}
} catch {
setError("Failed to create invite link");
@@ -489,7 +492,9 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
onClick={async () => {
try {
await navigator.clipboard.writeText(attachmentCtx.url);
showToast("Link copied");
} catch {
showToast("Copy failed");
return;
} finally {
setAttachmentCtx(null);

View File

@@ -10,6 +10,7 @@ import {
import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage";
@@ -45,6 +46,7 @@ export function MessageList() {
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage);
const restoreMessages = useChatStore((s) => s.restoreMessages);
const showToast = useUiStore((s) => s.showToast);
const [ctx, setCtx] = useState<ContextMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
@@ -58,7 +60,6 @@ export function MessageList() {
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
const [undoTick, setUndoTick] = useState(0);
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
const [toast, setToast] = useState<string | null>(null);
const messages = useMemo(() => {
if (!activeChatId) {
@@ -113,14 +114,6 @@ export function MessageList() {
setReactionsByMessage({});
}, [activeChatId]);
useEffect(() => {
if (!toast) {
return;
}
const timer = window.setTimeout(() => setToast(null), 2200);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!pendingDelete) {
return;
@@ -339,6 +332,12 @@ export function MessageList() {
{messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id;
const prev = messageIndex > 0 ? messages[messageIndex - 1] : null;
const groupedWithPrev = Boolean(
prev &&
prev.sender_id === message.sender_id &&
Math.abs(new Date(message.created_at).getTime() - new Date(prev.created_at).getTime()) < 4 * 60 * 1000
);
const replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
const isSelected = selectedIds.has(message.id);
const messageReactions = reactionsByMessage[message.id] ?? [];
@@ -355,13 +354,13 @@ export function MessageList() {
</div>
) : null}
<div className={`mb-2 flex ${own ? "justify-end" : "justify-start"}`}>
<div className={`${groupedWithPrev ? "mb-1" : "mb-2"} flex ${own ? "justify-end" : "justify-start"}`}>
<div
id={`message-${message.id}`}
className={`max-w-[90%] rounded-2xl px-3 py-2.5 shadow-sm md:max-w-[70%] ${
className={`max-w-[90%] px-3 py-2.5 shadow-sm md:max-w-[70%] ${
own
? "rounded-br-md bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950"
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100"
? `${groupedWithPrev ? "rounded-2xl rounded-tr-md" : "rounded-2xl rounded-br-md"} bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950`
: `${groupedWithPrev ? "rounded-2xl rounded-tl-md" : "rounded-2xl rounded-bl-md"} border border-slate-700/60 bg-slate-900/80 text-slate-100`
} ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => {
if (selectedIds.size > 0) {
@@ -534,9 +533,9 @@ export function MessageList() {
}
try {
await downloadFileFromUrl(url);
setToast("File downloaded");
showToast("File downloaded");
} catch {
setToast("Download failed");
showToast("Download failed");
} finally {
setCtx(null);
}
@@ -554,9 +553,9 @@ export function MessageList() {
}
try {
await navigator.clipboard.writeText(url);
setToast("Link copied");
showToast("Link copied");
} catch {
setToast("Copy failed");
showToast("Copy failed");
} finally {
setCtx(null);
}
@@ -657,11 +656,6 @@ export function MessageList() {
</div>
</div>
) : null}
{toast ? (
<div className="pointer-events-none absolute bottom-3 left-0 right-0 z-[120] flex justify-center px-3">
<div className="rounded-lg border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">{toast}</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useUiStore } from "../store/uiStore";
export function ToastViewport() {
const message = useUiStore((s) => s.toastMessage);
const clearToast = useUiStore((s) => s.clearToast);
useEffect(() => {
if (!message) {
return;
}
const timer = window.setTimeout(() => clearToast(), 2200);
return () => window.clearTimeout(timer);
}, [message, clearToast]);
if (!message) {
return null;
}
return createPortal(
<div className="pointer-events-none fixed bottom-3 left-0 right-0 z-[300] flex justify-center px-3">
<div className="rounded-lg border border-slate-700/80 bg-slate-900/95 px-3 py-2 text-xs text-slate-100 shadow-xl">
{message}
</div>
</div>,
document.body
);
}

View File

@@ -104,21 +104,21 @@ export function ChatsPage() {
}, [notificationsOpen]);
return (
<main className="h-screen w-full p-2 text-text md:p-4">
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-4">
<main className="h-screen w-full p-2 text-text md:p-3">
<div className="mx-auto flex h-full max-w-[1500px] gap-2 md:gap-3">
<section className={`tg-panel overflow-hidden rounded-2xl ${activeChatId ? "hidden md:block md:w-[360px]" : "w-full md:w-[360px]"}`}>
<ChatList />
</section>
<section className={`tg-panel tg-chat-wallpaper min-w-0 flex-1 overflow-hidden rounded-2xl ${activeChatId ? "flex" : "hidden md:flex"} flex-col`}>
<div className="flex items-center justify-between border-b border-slate-700/50 bg-slate-900/55 px-4 py-3">
<div className="flex h-16 items-center justify-between border-b border-slate-700/50 bg-slate-900/65 px-4">
<div className="flex min-w-0 items-center gap-3">
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}>
Back
</button>
{activeChatId ? (
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs" onClick={() => setInfoOpen(true)}>
Info
<button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-700/70 text-xs font-semibold" onClick={() => setInfoOpen(true)}>
i
</button>
) : null}
<div className="min-w-0">
@@ -126,9 +126,9 @@ export function ChatsPage() {
<p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<button
className="relative rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
className="relative rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80"
onClick={() => setNotificationsOpen(true)}
>
Notifications
@@ -136,10 +136,10 @@ export function ChatsPage() {
<span className="ml-1 rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] text-slate-950">{notifications.length}</span>
) : null}
</button>
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
<button className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={() => setSearchOpen(true)}>
Search
</button>
<button className="rounded-full bg-slate-700/70 px-3 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
<button className="rounded-full bg-slate-700/70 px-2.5 py-1.5 text-xs font-semibold hover:bg-slate-600/80" onClick={logout}>
Logout
</button>
</div>

14
web/src/store/uiStore.ts Normal file
View File

@@ -0,0 +1,14 @@
import { create } from "zustand";
interface UiState {
toastMessage: string | null;
showToast: (message: string) => void;
clearToast: () => void;
}
export const useUiStore = create<UiState>((set) => ({
toastMessage: null,
showToast: (message) => set({ toastMessage: message }),
clearToast: () => set({ toastMessage: null })
}));