From a77516cfeaa3500298be452570766ddb8022c225 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 10:35:21 +0300 Subject: [PATCH] feat(web): sprint1 ui core with global toasts and improved chat layout --- web/src/app/App.tsx | 8 +++++- web/src/components/ChatInfoPanel.tsx | 5 ++++ web/src/components/MessageList.tsx | 38 ++++++++++++---------------- web/src/components/ToastViewport.tsx | 30 ++++++++++++++++++++++ web/src/pages/ChatsPage.tsx | 18 ++++++------- web/src/store/uiStore.ts | 14 ++++++++++ 6 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 web/src/components/ToastViewport.tsx create mode 100644 web/src/store/uiStore.ts diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 0cbb2d5..a743eb3 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -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 ; } - return ; + return ( + <> + + + + ); } diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 0213fe4..09ed759 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -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(null); const [members, setMembers] = useState([]); const [memberUsers, setMemberUsers] = useState>({}); @@ -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); diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index 5a97c91..33c26fa 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -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(null); const [forwardMessageId, setForwardMessageId] = useState(null); @@ -58,7 +60,6 @@ export function MessageList() { const [pendingDelete, setPendingDelete] = useState(null); const [undoTick, setUndoTick] = useState(0); const [reactionsByMessage, setReactionsByMessage] = useState>({}); - const [toast, setToast] = useState(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() { ) : null} -
+
{ 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() {
) : null} - {toast ? ( -
-
{toast}
-
- ) : null}
); } diff --git a/web/src/components/ToastViewport.tsx b/web/src/components/ToastViewport.tsx new file mode 100644 index 0000000..9454c9d --- /dev/null +++ b/web/src/components/ToastViewport.tsx @@ -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( +
+
+ {message} +
+
, + document.body + ); +} + diff --git a/web/src/pages/ChatsPage.tsx b/web/src/pages/ChatsPage.tsx index 015236b..4a9b960 100644 --- a/web/src/pages/ChatsPage.tsx +++ b/web/src/pages/ChatsPage.tsx @@ -104,21 +104,21 @@ export function ChatsPage() { }, [notificationsOpen]); return ( -
-
+
+
-
+
{activeChatId ? ( - ) : null}
@@ -126,9 +126,9 @@ export function ChatsPage() {

{activeChat ? headerMetaLabel(activeChat) : "Select a chat"}

-
+
- -
diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts new file mode 100644 index 0000000..8156893 --- /dev/null +++ b/web/src/store/uiStore.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; + +interface UiState { + toastMessage: string | null; + showToast: (message: string) => void; + clearToast: () => void; +} + +export const useUiStore = create((set) => ({ + toastMessage: null, + showToast: (message) => set({ toastMessage: message }), + clearToast: () => set({ toastMessage: null }) +})); +