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

View File

@@ -10,6 +10,7 @@ import {
import type { Message, MessageReaction } from "../chat/types"; import type { Message, MessageReaction } from "../chat/types";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import { useChatStore } from "../store/chatStore"; import { useChatStore } from "../store/chatStore";
import { useUiStore } from "../store/uiStore";
import { formatTime } from "../utils/format"; import { formatTime } from "../utils/format";
import { formatMessageHtml } from "../utils/formatMessage"; import { formatMessageHtml } from "../utils/formatMessage";
@@ -45,6 +46,7 @@ export function MessageList() {
const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage); const updateChatPinnedMessage = useChatStore((s) => s.updateChatPinnedMessage);
const removeMessage = useChatStore((s) => s.removeMessage); const removeMessage = useChatStore((s) => s.removeMessage);
const restoreMessages = useChatStore((s) => s.restoreMessages); const restoreMessages = useChatStore((s) => s.restoreMessages);
const showToast = useUiStore((s) => s.showToast);
const [ctx, setCtx] = useState<ContextMenuState>(null); const [ctx, setCtx] = useState<ContextMenuState>(null);
const [forwardMessageId, setForwardMessageId] = useState<number | null>(null); const [forwardMessageId, setForwardMessageId] = useState<number | null>(null);
@@ -58,7 +60,6 @@ export function MessageList() {
const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null); const [pendingDelete, setPendingDelete] = useState<PendingDeleteState>(null);
const [undoTick, setUndoTick] = useState(0); const [undoTick, setUndoTick] = useState(0);
const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({}); const [reactionsByMessage, setReactionsByMessage] = useState<Record<number, MessageReaction[]>>({});
const [toast, setToast] = useState<string | null>(null);
const messages = useMemo(() => { const messages = useMemo(() => {
if (!activeChatId) { if (!activeChatId) {
@@ -113,14 +114,6 @@ export function MessageList() {
setReactionsByMessage({}); setReactionsByMessage({});
}, [activeChatId]); }, [activeChatId]);
useEffect(() => {
if (!toast) {
return;
}
const timer = window.setTimeout(() => setToast(null), 2200);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => { useEffect(() => {
if (!pendingDelete) { if (!pendingDelete) {
return; return;
@@ -339,6 +332,12 @@ export function MessageList() {
{messages.map((message, messageIndex) => { {messages.map((message, messageIndex) => {
const own = message.sender_id === me?.id; 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 replySource = message.reply_to_message_id ? messagesMap.get(message.reply_to_message_id) : null;
const isSelected = selectedIds.has(message.id); const isSelected = selectedIds.has(message.id);
const messageReactions = reactionsByMessage[message.id] ?? []; const messageReactions = reactionsByMessage[message.id] ?? [];
@@ -355,13 +354,13 @@ export function MessageList() {
</div> </div>
) : null} ) : 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 <div
id={`message-${message.id}`} 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 own
? "rounded-br-md bg-gradient-to-b from-sky-500/95 to-sky-600/90 text-slate-950" ? `${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`
: "rounded-bl-md border border-slate-700/60 bg-slate-900/80 text-slate-100" : `${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" : ""}`} } ${isSelected ? "ring-2 ring-sky-400/80" : ""} ${focusedMessageId === message.id ? "ring-2 ring-amber-300" : ""}`}
onClick={() => { onClick={() => {
if (selectedIds.size > 0) { if (selectedIds.size > 0) {
@@ -534,9 +533,9 @@ export function MessageList() {
} }
try { try {
await downloadFileFromUrl(url); await downloadFileFromUrl(url);
setToast("File downloaded"); showToast("File downloaded");
} catch { } catch {
setToast("Download failed"); showToast("Download failed");
} finally { } finally {
setCtx(null); setCtx(null);
} }
@@ -554,9 +553,9 @@ export function MessageList() {
} }
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setToast("Link copied"); showToast("Link copied");
} catch { } catch {
setToast("Copy failed"); showToast("Copy failed");
} finally { } finally {
setCtx(null); setCtx(null);
} }
@@ -657,11 +656,6 @@ export function MessageList() {
</div> </div>
</div> </div>
) : null} ) : 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> </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]); }, [notificationsOpen]);
return ( return (
<main className="h-screen w-full p-2 text-text md:p-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-4"> <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]"}`}> <section className={`tg-panel overflow-hidden rounded-2xl ${activeChatId ? "hidden md:block md:w-[360px]" : "w-full md:w-[360px]"}`}>
<ChatList /> <ChatList />
</section> </section>
<section className={`tg-panel tg-chat-wallpaper min-w-0 flex-1 overflow-hidden rounded-2xl ${activeChatId ? "flex" : "hidden md:flex"} flex-col`}> <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"> <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)}> <button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs md:hidden" onClick={() => setActiveChatId(null)}>
Back Back
</button> </button>
{activeChatId ? ( {activeChatId ? (
<button className="rounded-full bg-slate-700/70 px-2 py-1 text-xs" onClick={() => setInfoOpen(true)}> <button className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-700/70 text-xs font-semibold" onClick={() => setInfoOpen(true)}>
Info i
</button> </button>
) : null} ) : null}
<div className="min-w-0"> <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> <p className="truncate text-xs text-slate-300/80">{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<button <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)} onClick={() => setNotificationsOpen(true)}
> >
Notifications 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> <span className="ml-1 rounded-full bg-sky-500 px-1.5 py-0.5 text-[10px] text-slate-950">{notifications.length}</span>
) : null} ) : null}
</button> </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 Search
</button> </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 Logout
</button> </button>
</div> </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 })
}));