diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index 257bb518..683cd9ad 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -42,7 +42,9 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; -import { useInboxStore } from "@/features/inbox"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { inboxListOptions, deduplicateInboxItems } from "@core/inbox/queries"; import { useModalStore } from "@/features/modals"; const primaryNav = [ @@ -73,7 +75,9 @@ export function AppSidebar() { const workspaces = useWorkspaceStore((s) => s.workspaces); const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace); - const unreadCount = useInboxStore((s) => s.unreadCount()); + const wsId = useWorkspaceId(); + const { data: inboxItems = [] } = useQuery(inboxListOptions(wsId)); + const unreadCount = deduplicateInboxItems(inboxItems).filter((i) => !i.read).length; const logout = () => { router.push("/"); diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index ff48d287..fc68488f 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,9 +1,22 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useSearchParams } from "next/navigation"; import { useDefaultLayout } from "react-resizable-panels"; -import { useInboxStore } from "@/features/inbox"; +import { useQuery } from "@tanstack/react-query"; +import { useWorkspaceId } from "@core/hooks"; +import { + inboxListOptions, + deduplicateInboxItems, +} from "@core/inbox/queries"; +import { + useMarkInboxRead, + useArchiveInbox, + useMarkAllInboxRead, + useArchiveAllInbox, + useArchiveAllReadInbox, + useArchiveCompletedInbox, +} from "@core/inbox/mutations"; import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components"; import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config"; import { useActorName } from "@/features/workspace"; @@ -33,7 +46,6 @@ import { DropdownMenuItem, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { api } from "@/shared/api"; // --------------------------------------------------------------------------- // Helpers @@ -235,8 +247,9 @@ export default function InboxPage() { window.history.replaceState(null, "", url); }, []); - const items = useInboxStore((s) => s.dedupedItems()); - const loading = useInboxStore((s) => s.loading); + const wsId = useWorkspaceId(); + const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId)); + const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_inbox_layout", @@ -245,74 +258,58 @@ export default function InboxPage() { const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null; const unreadCount = items.filter((i) => !i.read).length; + const markReadMutation = useMarkInboxRead(); + const archiveMutation = useArchiveInbox(); + const markAllReadMutation = useMarkAllInboxRead(); + const archiveAllMutation = useArchiveAllInbox(); + const archiveAllReadMutation = useArchiveAllReadInbox(); + const archiveCompletedMutation = useArchiveCompletedInbox(); + // Click-to-read: select + auto-mark-read - const handleSelect = async (item: InboxItem) => { + const handleSelect = (item: InboxItem) => { setSelectedKey(item.issue_id ?? item.id); if (!item.read) { - useInboxStore.getState().markRead(item.id); - try { - await api.markInboxRead(item.id); - } catch { - // Rollback: refetch to get server truth - useInboxStore.getState().fetch(); - toast.error("Failed to mark as read"); - } + markReadMutation.mutate(item.id, { + onError: () => toast.error("Failed to mark as read"), + }); } }; - const handleArchive = async (id: string) => { - try { - await api.archiveInbox(id); - useInboxStore.getState().archive(id); - const archived = items.find((i) => i.id === id); - if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey(""); - } catch { - toast.error("Failed to archive"); - } + const handleArchive = (id: string) => { + const archived = items.find((i) => i.id === id); + if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey(""); + archiveMutation.mutate(id, { + onError: () => toast.error("Failed to archive"), + }); }; // Batch operations - const handleMarkAllRead = async () => { - try { - useInboxStore.getState().markAllRead(); - await api.markAllInboxRead(); - } catch { - toast.error("Failed to mark all as read"); - useInboxStore.getState().fetch(); - } + const handleMarkAllRead = () => { + markAllReadMutation.mutate(undefined, { + onError: () => toast.error("Failed to mark all as read"), + }); }; - const handleArchiveAll = async () => { - try { - useInboxStore.getState().archiveAll(); - setSelectedKey(""); - await api.archiveAllInbox(); - } catch { - toast.error("Failed to archive all"); - useInboxStore.getState().fetch(); - } + const handleArchiveAll = () => { + setSelectedKey(""); + archiveAllMutation.mutate(undefined, { + onError: () => toast.error("Failed to archive all"), + }); }; - const handleArchiveAllRead = async () => { - try { - const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id); - useInboxStore.getState().archiveAllRead(); - if (readKeys.includes(selectedKey)) setSelectedKey(""); - await api.archiveAllReadInbox(); - } catch { - toast.error("Failed to archive read items"); - useInboxStore.getState().fetch(); - } + const handleArchiveAllRead = () => { + const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id); + if (readKeys.includes(selectedKey)) setSelectedKey(""); + archiveAllReadMutation.mutate(undefined, { + onError: () => toast.error("Failed to archive read items"), + }); }; - const handleArchiveCompleted = async () => { - try { - await api.archiveCompletedInbox(); - setSelectedKey(""); - await useInboxStore.getState().fetch(); - } catch { - toast.error("Failed to archive completed"); - } + const handleArchiveCompleted = () => { + setSelectedKey(""); + archiveCompletedMutation.mutate(undefined, { + onError: () => toast.error("Failed to archive completed"), + }); }; if (loading) { diff --git a/apps/web/core/inbox/index.ts b/apps/web/core/inbox/index.ts new file mode 100644 index 00000000..95a8ffa1 --- /dev/null +++ b/apps/web/core/inbox/index.ts @@ -0,0 +1,16 @@ +export { + inboxKeys, + inboxListOptions, + deduplicateInboxItems, +} from "./queries"; + +export { + useMarkInboxRead, + useArchiveInbox, + useMarkAllInboxRead, + useArchiveAllInbox, + useArchiveAllReadInbox, + useArchiveCompletedInbox, +} from "./mutations"; + +export { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "./ws-updaters"; diff --git a/apps/web/core/inbox/mutations.ts b/apps/web/core/inbox/mutations.ts new file mode 100644 index 00000000..594a6ac7 --- /dev/null +++ b/apps/web/core/inbox/mutations.ts @@ -0,0 +1,104 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import { inboxKeys } from "./queries"; +import { useWorkspaceId } from "@core/hooks"; +import type { InboxItem } from "@/shared/types"; + +export function useMarkInboxRead() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.markInboxRead(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); + const prev = qc.getQueryData(inboxKeys.list(wsId)); + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((item) => (item.id === id ? { ...item, read: true } : item)), + ); + return { prev }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev); + }, + }); +} + +export function useArchiveInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: (id: string) => api.archiveInbox(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); + const prev = qc.getQueryData(inboxKeys.list(wsId)); + // Archive all items for the same issue (same behavior as store) + const target = prev?.find((i) => i.id === id); + const issueId = target?.issue_id; + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((item) => + item.id === id || (issueId && item.issue_id === issueId) + ? { ...item, archived: true } + : item, + ), + ); + return { prev }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev); + }, + }); +} + +export function useMarkAllInboxRead() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.markAllInboxRead(), + onMutate: async () => { + await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) }); + const prev = qc.getQueryData(inboxKeys.list(wsId)); + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((item) => + !item.archived ? { ...item, read: true } : item, + ), + ); + return { prev }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.prev) qc.setQueryData(inboxKeys.list(wsId), ctx.prev); + }, + }); +} + +export function useArchiveAllInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.archiveAllInbox(), + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} + +export function useArchiveAllReadInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.archiveAllReadInbox(), + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} + +export function useArchiveCompletedInbox() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + return useMutation({ + mutationFn: () => api.archiveCompletedInbox(), + onSettled: () => { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); + }, + }); +} diff --git a/apps/web/core/inbox/queries.ts b/apps/web/core/inbox/queries.ts new file mode 100644 index 00000000..d705b130 --- /dev/null +++ b/apps/web/core/inbox/queries.ts @@ -0,0 +1,43 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/shared/api"; +import type { InboxItem } from "@/shared/types"; + +export const inboxKeys = { + all: (wsId: string) => ["inbox", wsId] as const, + list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const, +}; + +export function inboxListOptions(wsId: string) { + return queryOptions({ + queryKey: inboxKeys.list(wsId), + queryFn: () => api.listInbox(), + }); +} + +/** + * Deduplicate inbox items by issue_id (one entry per issue, Linear-style). + * Exported for consumers to use in useMemo — not in queryOptions select + * (to avoid new array references on every cache update). + */ +export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] { + const active = items.filter((i) => !i.archived); + const groups = new Map(); + for (const item of active) { + const key = item.issue_id ?? item.id; + const group = groups.get(key) ?? []; + group.push(item); + groups.set(key, group); + } + const merged: InboxItem[] = []; + for (const group of groups.values()) { + group.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + if (group[0]) merged.push(group[0]); + } + return merged.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); +} diff --git a/apps/web/core/inbox/ws-updaters.ts b/apps/web/core/inbox/ws-updaters.ts new file mode 100644 index 00000000..64c800ec --- /dev/null +++ b/apps/web/core/inbox/ws-updaters.ts @@ -0,0 +1,30 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { inboxKeys } from "./queries"; +import type { InboxItem, IssueStatus } from "@/shared/types"; + +export function onInboxNew( + qc: QueryClient, + wsId: string, + _item: InboxItem, +) { + // Use invalidateQueries instead of setQueryData — triggers a refetch that + // reliably notifies all observers. The inbox list is small so this is cheap. + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); +} + +export function onInboxIssueStatusChanged( + qc: QueryClient, + wsId: string, + issueId: string, + status: IssueStatus, +) { + qc.setQueryData(inboxKeys.list(wsId), (old) => + old?.map((i) => + i.issue_id === issueId ? { ...i, issue_status: status } : i, + ), + ); +} + +export function onInboxInvalidate(qc: QueryClient, wsId: string) { + qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) }); +} diff --git a/apps/web/features/inbox/index.ts b/apps/web/features/inbox/index.ts index a78bf0e5..e599b14c 100644 --- a/apps/web/features/inbox/index.ts +++ b/apps/web/features/inbox/index.ts @@ -1 +1,13 @@ -export { useInboxStore } from "./store"; +// Inbox server state is managed by TanStack Query. +// See core/inbox/ for queries, mutations, and WS updaters. +export { + inboxKeys, + inboxListOptions, + deduplicateInboxItems, + useMarkInboxRead, + useArchiveInbox, + useMarkAllInboxRead, + useArchiveAllInbox, + useArchiveAllReadInbox, + useArchiveCompletedInbox, +} from "@core/inbox"; diff --git a/apps/web/features/inbox/store.ts b/apps/web/features/inbox/store.ts deleted file mode 100644 index 0489a30b..00000000 --- a/apps/web/features/inbox/store.ts +++ /dev/null @@ -1,127 +0,0 @@ -"use client"; - -import { create } from "zustand"; -import type { InboxItem, IssueStatus } from "@/shared/types"; -import { toast } from "sonner"; -import { api } from "@/shared/api"; -import { createLogger } from "@/shared/logger"; - -const logger = createLogger("inbox-store"); - -/** - * Deduplicate inbox items by issue_id (one entry per issue, Linear-style), - * keep latest, sort by time DESC. - * Memoized by reference — returns the same array if `items` hasn't changed. - */ -let _prevItems: InboxItem[] = []; -let _prevDeduped: InboxItem[] = []; - -function deduplicateInboxItems(items: InboxItem[]): InboxItem[] { - if (items === _prevItems) return _prevDeduped; - _prevItems = items; - - const active = items.filter((i) => !i.archived); - const groups = new Map(); - active.forEach((item) => { - const key = item.issue_id ?? item.id; - const group = groups.get(key) ?? []; - group.push(item); - groups.set(key, group); - }); - const merged: InboxItem[] = []; - groups.forEach((group) => { - const sorted = group.sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); - if (sorted[0]) merged.push(sorted[0]); - }); - _prevDeduped = merged.sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); - return _prevDeduped; -} - -interface InboxState { - items: InboxItem[]; - loading: boolean; - fetch: () => Promise; - setItems: (items: InboxItem[]) => void; - addItem: (item: InboxItem) => void; - markRead: (id: string) => void; - archive: (id: string) => void; - markAllRead: () => void; - archiveAll: () => void; - archiveAllRead: () => void; - updateIssueStatus: (issueId: string, status: IssueStatus) => void; - dedupedItems: () => InboxItem[]; - unreadCount: () => number; -} - -export const useInboxStore = create((set, get) => ({ - items: [], - loading: true, - - fetch: async () => { - logger.debug("fetch start"); - const isInitialLoad = get().items.length === 0; - if (isInitialLoad) set({ loading: true }); - try { - const data = await api.listInbox(); - logger.info("fetched", data.length, "items"); - set({ items: data, loading: false }); - } catch (err) { - logger.error("fetch failed", err); - toast.error("Failed to load inbox"); - if (isInitialLoad) set({ loading: false }); - } - }, - - setItems: (items) => set({ items }), - addItem: (item) => - set((s) => ({ - items: s.items.some((i) => i.id === item.id) - ? s.items - : [item, ...s.items], - })), - markRead: (id) => - set((s) => ({ - items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)), - })), - archive: (id) => - set((s) => { - const target = s.items.find((i) => i.id === id); - const issueId = target?.issue_id; - return { - items: s.items.map((i) => - i.id === id || (issueId && i.issue_id === issueId) - ? { ...i, archived: true } - : i, - ), - }; - }), - markAllRead: () => - set((s) => ({ - items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)), - })), - archiveAll: () => - set((s) => ({ - items: s.items.map((i) => (!i.archived ? { ...i, archived: true } : i)), - })), - archiveAllRead: () => - set((s) => ({ - items: s.items.map((i) => - i.read && !i.archived ? { ...i, archived: true } : i - ), - })), - updateIssueStatus: (issueId, status) => - set((s) => ({ - items: s.items.map((i) => - i.issue_id === issueId ? { ...i, issue_status: status } : i - ), - })), - dedupedItems: () => deduplicateInboxItems(get().items), - unreadCount: () => - get().dedupedItems().filter((i) => !i.read).length, -})); diff --git a/apps/web/features/realtime/use-realtime-sync.ts b/apps/web/features/realtime/use-realtime-sync.ts index a21484a9..dd231892 100644 --- a/apps/web/features/realtime/use-realtime-sync.ts +++ b/apps/web/features/realtime/use-realtime-sync.ts @@ -1,20 +1,21 @@ "use client"; import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import type { WSClient } from "@/shared/api"; import { toast } from "sonner"; -import { useInboxStore } from "@/features/inbox"; import { useWorkspaceStore } from "@/features/workspace"; import { useAuthStore } from "@/features/auth"; import { createLogger } from "@/shared/logger"; import { api } from "@/shared/api"; -import { getQueryClient } from "@core/query-client"; import { issueKeys } from "@core/issues/queries"; import { onIssueCreated, onIssueUpdated, onIssueDeleted, } from "@core/issues/ws-updaters"; +import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "@core/inbox/ws-updaters"; +import { inboxKeys } from "@core/inbox/queries"; import type { MemberAddedPayload, WorkspaceDeletedPayload, @@ -39,6 +40,7 @@ const logger = createLogger("realtime-sync"); * by individual components via useWSEvent — not here. */ export function useRealtimeSync(ws: WSClient | null) { + const qc = useQueryClient(); // Main sync: onAny → refreshMap with debounce useEffect(() => { if (!ws) return; @@ -49,7 +51,10 @@ export function useRealtimeSync(ws: WSClient | null) { ]); const refreshMap: Record void> = { - inbox: () => void useInboxStore.getState().fetch(), + inbox: () => { + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onInboxInvalidate(qc, wsId); + }, agent: () => void useWorkspaceStore.getState().refreshAgents(), member: () => void useWorkspaceStore.getState().refreshMembers(), workspace: () => { @@ -98,30 +103,34 @@ export function useRealtimeSync(ws: WSClient | null) { const unsubIssueUpdated = ws.on("issue:updated", (p) => { const { issue } = p as IssueUpdatedPayload; if (!issue?.id) return; - if (issue.status) { - useInboxStore.getState().updateIssueStatus(issue.id, issue.status); - } const wsId = useWorkspaceStore.getState().workspace?.id; - if (wsId) onIssueUpdated(getQueryClient(), wsId, issue); + if (wsId) { + onIssueUpdated(qc, wsId, issue); + if (issue.status) { + onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status); + } + } }); const unsubIssueCreated = ws.on("issue:created", (p) => { const { issue } = p as IssueCreatedPayload; if (!issue) return; const wsId = useWorkspaceStore.getState().workspace?.id; - if (wsId) onIssueCreated(getQueryClient(), wsId, issue); + if (wsId) onIssueCreated(qc, wsId, issue); }); const unsubIssueDeleted = ws.on("issue:deleted", (p) => { const { issue_id } = p as IssueDeletedPayload; if (!issue_id) return; const wsId = useWorkspaceStore.getState().workspace?.id; - if (wsId) onIssueDeleted(getQueryClient(), wsId, issue_id); + if (wsId) onIssueDeleted(qc, wsId, issue_id); }); const unsubInboxNew = ws.on("inbox:new", (p) => { const { item } = p as InboxNewPayload; - if (item) useInboxStore.getState().addItem(item); + if (!item) return; + const wsId = useWorkspaceStore.getState().workspace?.id; + if (wsId) onInboxNew(qc, wsId, item); }); // --- Side-effect handlers (toast, navigation) --- @@ -169,7 +178,7 @@ export function useRealtimeSync(ws: WSClient | null) { timers.forEach(clearTimeout); timers.clear(); }; - }, [ws]); + }, [ws, qc]); // Reconnect → refetch all data to recover missed events useEffect(() => { @@ -180,10 +189,10 @@ export function useRealtimeSync(ws: WSClient | null) { try { const wsId = useWorkspaceStore.getState().workspace?.id; if (wsId) { - getQueryClient().invalidateQueries({ queryKey: issueKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: issueKeys.all(wsId) }); + qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) }); } await Promise.all([ - useInboxStore.getState().fetch(), useWorkspaceStore.getState().refreshAgents(), useWorkspaceStore.getState().refreshMembers(), useWorkspaceStore.getState().refreshSkills(), @@ -194,5 +203,5 @@ export function useRealtimeSync(ws: WSClient | null) { }); return unsub; - }, [ws]); + }, [ws, qc]); } diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts index 477c3da3..142a658a 100644 --- a/apps/web/features/workspace/store.ts +++ b/apps/web/features/workspace/store.ts @@ -2,7 +2,6 @@ import { create } from "zustand"; import type { Workspace, MemberWithUser, Agent, Skill } from "@/shared/types"; -import { useInboxStore } from "@/features/inbox"; import { useRuntimeStore } from "@/features/runtimes"; import { toast } from "sonner"; import { api } from "@/shared/api"; @@ -87,7 +86,6 @@ export const useWorkspaceStore = create((set, get) => ({ return [] as Agent[]; }), api.listSkills().catch(() => [] as Skill[]), - useInboxStore.getState().fetch().catch(() => {}), ]); logger.info("hydrate complete", "members:", nextMembers.length, "agents:", nextAgents.length); set({ members: nextMembers, agents: nextAgents, skills: nextSkills }); @@ -109,8 +107,7 @@ export const useWorkspaceStore = create((set, get) => ({ localStorage.setItem("multica_workspace_id", ws.id); // Clear stale data across stores before hydrating. - // Issue cache is managed by TanStack Query (keyed by wsId, auto-refetches). - useInboxStore.getState().setItems([]); + // Issue + inbox caches are managed by TanStack Query (keyed by wsId, auto-refetches). useRuntimeStore.getState().setRuntimes([]); set({ workspace: ws, members: [], agents: [], skills: [] });